fix(webapp): persist motion alerts in activity feed

This commit is contained in:
2026-04-06 13:30:00 +01:00
parent 3e0635fec3
commit 798fffa2a1
3 changed files with 89 additions and 9 deletions

View File

@@ -3,7 +3,7 @@ import { Router } from 'express';
import { z } from 'zod';
import { db } from '../db/client';
import { deviceLinks, devices, events, notifications } from '../db/schema';
import { deviceLinks, devices, events, notificationDeliveries, notifications } from '../db/schema';
import { requireAuth } from '../middleware/auth';
import { requireDeviceAuth } from '../middleware/device-auth';
import { sendRealtimeToDevice } from '../realtime/gateway';
@@ -108,7 +108,23 @@ router.post('/motion/start', requireDeviceAuth, async (req, res) => {
sentAt: now,
});
if (!delivered) {
if (delivered) {
await db.insert(notificationDeliveries).values({
ownerUserId: deviceAuth.userId,
recipientDeviceId: link.clientDeviceId,
type: 'motion_detected',
payload: {
eventId: event.id,
cameraDeviceId: cameraDevice.id,
startedAt: event.startedAt.toISOString(),
},
status: 'delivered',
attempts: 1,
sentAt: now,
nextAttemptAt: now,
updatedAt: now,
});
} else {
await enqueuePushNotification({
ownerUserId: deviceAuth.userId,
recipientDeviceId: link.clientDeviceId,

View File

@@ -79,5 +79,8 @@ export const api = {
listRecordings: () => request('/recordings/me/list'),
getRecordingDownloadUrl: (recordingId) => request(`/recordings/${recordingId}/download-url`),
listNotifications: () => request('/push-notifications/me')
},
pushNotifications: {
markRead: (notificationId) => request(`/push-notifications/${notificationId}/read`, { method: 'POST', body: JSON.stringify({}) })
}
};

View File

@@ -811,11 +811,52 @@ const getCameraLabel = (cameraDeviceId, cameraName) => {
return `Camera ${cameraDeviceId?.substring(0, 6) ?? 'Unknown'}`;
};
const mapNotificationDeliveryToMotionNotification = (delivery) => {
const payload = delivery?.payload ?? {};
const cameraDeviceId = typeof payload.cameraDeviceId === 'string' ? payload.cameraDeviceId : '';
const deliveryType = typeof delivery?.type === 'string' ? delivery.type : '';
if (deliveryType !== 'motion_detected') {
return null;
}
return {
id: delivery.id,
eventId: typeof payload.eventId === 'string' ? payload.eventId : null,
cameraDeviceId,
message: `${getCameraLabel(cameraDeviceId)} has detected movement`,
createdAt: delivery.sentAt || delivery.createdAt || new Date().toISOString(),
isRead: delivery.status === 'read'
};
};
const syncMotionNotificationsFromDeliveries = (deliveries) => {
const motionNotifications = (deliveries || [])
.map(mapNotificationDeliveryToMotionNotification)
.filter(Boolean)
.sort((left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime());
setAppState({ motionNotifications });
};
const refreshMotionNotifications = async () => {
const { device } = getAppState();
if (!device || device.role !== 'client') {
setAppState({ motionNotifications: [] });
return [];
}
const result = await api.ops.listNotifications().catch(() => ({ notifications: [] }));
syncMotionNotificationsFromDeliveries(result.notifications);
return result.notifications || [];
};
const pushMotionNotification = (cameraDeviceId) => {
if (!cameraDeviceId) return;
const notification = {
id: makeId(),
eventId: null,
cameraDeviceId,
message: `${getCameraLabel(cameraDeviceId)} has detected movement`,
createdAt: new Date().toISOString(),
@@ -825,22 +866,38 @@ const pushMotionNotification = (cameraDeviceId) => {
patchAppState((state) => ({
motionNotifications: [notification, ...state.motionNotifications].slice(0, 50)
}));
void refreshMotionNotifications();
};
const markMotionNotificationRead = (notificationId) => {
const markMotionNotificationRead = async (notificationId) => {
patchAppState((state) => ({
motionNotifications: state.motionNotifications.map((notification) =>
notification.id === notificationId ? { ...notification, isRead: true } : notification
)
}));
try {
await api.pushNotifications.markRead(notificationId);
} catch (error) {
console.error('Failed to persist notification read state', error);
}
};
const markAllNotificationsRead = () => {
const markAllNotificationsRead = async () => {
const unreadIds = getAppState().motionNotifications.filter((notification) => !notification.isRead).map((notification) => notification.id);
if (unreadIds.length === 0) {
return;
}
patchAppState((state) => ({
motionNotifications: state.motionNotifications.map((notification) =>
notification.isRead ? notification : { ...notification, isRead: true }
)
}));
await Promise.allSettled(unreadIds.map((notificationId) => api.pushNotifications.markRead(notificationId)));
};
const openRecordingModal = (downloadUrl, title) => {
@@ -1697,10 +1754,11 @@ const pollClientData = async () => {
const { device } = getAppState();
if (!device || device.role !== 'client') return;
const [recs, links, deviceList] = await Promise.all([
const [recs, links, deviceList, notifications] = await Promise.all([
api.ops.listRecordings().catch(() => ({ recordings: [] })),
api.devices.listLinks().catch(() => ({ links: [] })),
api.devices.list().catch(() => ({ devices: [] }))
api.devices.list().catch(() => ({ devices: [] })),
api.ops.listNotifications().catch(() => ({ notifications: [] }))
]);
const cameraById = new Map(
@@ -1722,6 +1780,7 @@ const pollClientData = async () => {
recordings: recs.recordings || [],
linkedCameras
});
syncMotionNotificationsFromDeliveries(notifications.notifications);
};
const startPolling = () => {
@@ -2194,7 +2253,7 @@ const actions = {
},
async openMotionNotificationTarget(notificationId, cameraDeviceId) {
markMotionNotificationRead(notificationId);
await markMotionNotificationRead(notificationId);
if (!cameraDeviceId) return;
const recs = await api.ops.listRecordings().catch(() => ({ recordings: [] }));
@@ -2212,11 +2271,13 @@ const actions = {
},
markAllNotificationsRead() {
markAllNotificationsRead();
void markAllNotificationsRead();
},
clearNotifications() {
setAppState({ motionNotifications: [] });
patchAppState((state) => ({
motionNotifications: state.motionNotifications.filter((notification) => !notification.isRead)
}));
},
refreshClientData() {