fix(webapp): persist motion alerts in activity feed
This commit is contained in:
@@ -3,7 +3,7 @@ import { Router } from 'express';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { db } from '../db/client';
|
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 { requireAuth } from '../middleware/auth';
|
||||||
import { requireDeviceAuth } from '../middleware/device-auth';
|
import { requireDeviceAuth } from '../middleware/device-auth';
|
||||||
import { sendRealtimeToDevice } from '../realtime/gateway';
|
import { sendRealtimeToDevice } from '../realtime/gateway';
|
||||||
@@ -108,7 +108,23 @@ router.post('/motion/start', requireDeviceAuth, async (req, res) => {
|
|||||||
sentAt: now,
|
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({
|
await enqueuePushNotification({
|
||||||
ownerUserId: deviceAuth.userId,
|
ownerUserId: deviceAuth.userId,
|
||||||
recipientDeviceId: link.clientDeviceId,
|
recipientDeviceId: link.clientDeviceId,
|
||||||
|
|||||||
@@ -79,5 +79,8 @@ export const api = {
|
|||||||
listRecordings: () => request('/recordings/me/list'),
|
listRecordings: () => request('/recordings/me/list'),
|
||||||
getRecordingDownloadUrl: (recordingId) => request(`/recordings/${recordingId}/download-url`),
|
getRecordingDownloadUrl: (recordingId) => request(`/recordings/${recordingId}/download-url`),
|
||||||
listNotifications: () => request('/push-notifications/me')
|
listNotifications: () => request('/push-notifications/me')
|
||||||
|
},
|
||||||
|
pushNotifications: {
|
||||||
|
markRead: (notificationId) => request(`/push-notifications/${notificationId}/read`, { method: 'POST', body: JSON.stringify({}) })
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -811,11 +811,52 @@ const getCameraLabel = (cameraDeviceId, cameraName) => {
|
|||||||
return `Camera ${cameraDeviceId?.substring(0, 6) ?? 'Unknown'}`;
|
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) => {
|
const pushMotionNotification = (cameraDeviceId) => {
|
||||||
if (!cameraDeviceId) return;
|
if (!cameraDeviceId) return;
|
||||||
|
|
||||||
const notification = {
|
const notification = {
|
||||||
id: makeId(),
|
id: makeId(),
|
||||||
|
eventId: null,
|
||||||
cameraDeviceId,
|
cameraDeviceId,
|
||||||
message: `${getCameraLabel(cameraDeviceId)} has detected movement`,
|
message: `${getCameraLabel(cameraDeviceId)} has detected movement`,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
@@ -825,22 +866,38 @@ const pushMotionNotification = (cameraDeviceId) => {
|
|||||||
patchAppState((state) => ({
|
patchAppState((state) => ({
|
||||||
motionNotifications: [notification, ...state.motionNotifications].slice(0, 50)
|
motionNotifications: [notification, ...state.motionNotifications].slice(0, 50)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
void refreshMotionNotifications();
|
||||||
};
|
};
|
||||||
|
|
||||||
const markMotionNotificationRead = (notificationId) => {
|
const markMotionNotificationRead = async (notificationId) => {
|
||||||
patchAppState((state) => ({
|
patchAppState((state) => ({
|
||||||
motionNotifications: state.motionNotifications.map((notification) =>
|
motionNotifications: state.motionNotifications.map((notification) =>
|
||||||
notification.id === notificationId ? { ...notification, isRead: true } : 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) => ({
|
patchAppState((state) => ({
|
||||||
motionNotifications: state.motionNotifications.map((notification) =>
|
motionNotifications: state.motionNotifications.map((notification) =>
|
||||||
notification.isRead ? notification : { ...notification, isRead: true }
|
notification.isRead ? notification : { ...notification, isRead: true }
|
||||||
)
|
)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
await Promise.allSettled(unreadIds.map((notificationId) => api.pushNotifications.markRead(notificationId)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const openRecordingModal = (downloadUrl, title) => {
|
const openRecordingModal = (downloadUrl, title) => {
|
||||||
@@ -1697,10 +1754,11 @@ const pollClientData = async () => {
|
|||||||
const { device } = getAppState();
|
const { device } = getAppState();
|
||||||
if (!device || device.role !== 'client') return;
|
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.ops.listRecordings().catch(() => ({ recordings: [] })),
|
||||||
api.devices.listLinks().catch(() => ({ links: [] })),
|
api.devices.listLinks().catch(() => ({ links: [] })),
|
||||||
api.devices.list().catch(() => ({ devices: [] }))
|
api.devices.list().catch(() => ({ devices: [] })),
|
||||||
|
api.ops.listNotifications().catch(() => ({ notifications: [] }))
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const cameraById = new Map(
|
const cameraById = new Map(
|
||||||
@@ -1722,6 +1780,7 @@ const pollClientData = async () => {
|
|||||||
recordings: recs.recordings || [],
|
recordings: recs.recordings || [],
|
||||||
linkedCameras
|
linkedCameras
|
||||||
});
|
});
|
||||||
|
syncMotionNotificationsFromDeliveries(notifications.notifications);
|
||||||
};
|
};
|
||||||
|
|
||||||
const startPolling = () => {
|
const startPolling = () => {
|
||||||
@@ -2194,7 +2253,7 @@ const actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async openMotionNotificationTarget(notificationId, cameraDeviceId) {
|
async openMotionNotificationTarget(notificationId, cameraDeviceId) {
|
||||||
markMotionNotificationRead(notificationId);
|
await markMotionNotificationRead(notificationId);
|
||||||
if (!cameraDeviceId) return;
|
if (!cameraDeviceId) return;
|
||||||
|
|
||||||
const recs = await api.ops.listRecordings().catch(() => ({ recordings: [] }));
|
const recs = await api.ops.listRecordings().catch(() => ({ recordings: [] }));
|
||||||
@@ -2212,11 +2271,13 @@ const actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
markAllNotificationsRead() {
|
markAllNotificationsRead() {
|
||||||
markAllNotificationsRead();
|
void markAllNotificationsRead();
|
||||||
},
|
},
|
||||||
|
|
||||||
clearNotifications() {
|
clearNotifications() {
|
||||||
setAppState({ motionNotifications: [] });
|
patchAppState((state) => ({
|
||||||
|
motionNotifications: state.motionNotifications.filter((notification) => !notification.isRead)
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
refreshClientData() {
|
refreshClientData() {
|
||||||
|
|||||||
Reference in New Issue
Block a user