From 798fffa2a11c8b7672193d570d4423137ea878f3 Mon Sep 17 00:00:00 2001 From: Matiss Jurevics Date: Mon, 6 Apr 2026 13:30:00 +0100 Subject: [PATCH] fix(webapp): persist motion alerts in activity feed --- Backend/routes/events.ts | 20 ++++++++- WebApp/src/lib/app/api.js | 3 ++ WebApp/src/lib/app/controller.js | 75 +++++++++++++++++++++++++++++--- 3 files changed, 89 insertions(+), 9 deletions(-) diff --git a/Backend/routes/events.ts b/Backend/routes/events.ts index 4391dc4..52531b3 100644 --- a/Backend/routes/events.ts +++ b/Backend/routes/events.ts @@ -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, diff --git a/WebApp/src/lib/app/api.js b/WebApp/src/lib/app/api.js index effce30..24069a5 100644 --- a/WebApp/src/lib/app/api.js +++ b/WebApp/src/lib/app/api.js @@ -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({}) }) } }; diff --git a/WebApp/src/lib/app/controller.js b/WebApp/src/lib/app/controller.js index 295a05e..182313f 100644 --- a/WebApp/src/lib/app/controller.js +++ b/WebApp/src/lib/app/controller.js @@ -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() {