feat(push): add phase7 offline push queue, worker, APIs, and simulator inbox
This commit is contained in:
@@ -7,6 +7,7 @@ import { deviceLinks, devices, events, notifications } from '../db/schema';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
import { requireDeviceAuth } from '../middleware/device-auth';
|
||||
import { sendRealtimeToDevice } from '../realtime/gateway';
|
||||
import { enqueuePushNotification } from '../services/push';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -105,6 +106,19 @@ router.post('/motion/start', requireDeviceAuth, async (req, res) => {
|
||||
isRead: false,
|
||||
sentAt: now,
|
||||
});
|
||||
|
||||
if (!delivered) {
|
||||
await enqueuePushNotification({
|
||||
ownerUserId: deviceAuth.userId,
|
||||
recipientDeviceId: link.clientDeviceId,
|
||||
type: 'motion_detected',
|
||||
payload: {
|
||||
eventId: event.id,
|
||||
cameraDeviceId: cameraDevice.id,
|
||||
startedAt: event.startedAt.toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
@@ -172,13 +186,27 @@ router.post('/:eventId/motion/end', requireDeviceAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
for (const link of activeLinks) {
|
||||
sendRealtimeToDevice(link.clientDeviceId, 'motion:ended', {
|
||||
const delivered = sendRealtimeToDevice(link.clientDeviceId, 'motion:ended', {
|
||||
eventId: event.id,
|
||||
cameraDeviceId: deviceAuth.deviceId,
|
||||
status: parsed.data.status,
|
||||
endedAt: now,
|
||||
videoUrl: parsed.data.videoUrl ?? event.videoUrl,
|
||||
});
|
||||
|
||||
if (!delivered) {
|
||||
await enqueuePushNotification({
|
||||
ownerUserId: deviceAuth.userId,
|
||||
recipientDeviceId: link.clientDeviceId,
|
||||
type: 'motion_ended',
|
||||
payload: {
|
||||
eventId: event.id,
|
||||
cameraDeviceId: deviceAuth.deviceId,
|
||||
status: parsed.data.status,
|
||||
endedAt: now.toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ message: 'Motion event ended', event: updated, notifiedClients: activeLinks.length });
|
||||
|
||||
100
Backend/routes/push-notifications.ts
Normal file
100
Backend/routes/push-notifications.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { db } from '../db/client';
|
||||
import { pushNotifications } from '../db/schema';
|
||||
import { requireDeviceAuth } from '../middleware/device-auth';
|
||||
import { dispatchPushQueueOnce } from '../services/push';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const listSchema = z.object({
|
||||
status: z.string().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(25),
|
||||
});
|
||||
|
||||
const notificationParamSchema = z.object({
|
||||
notificationId: z.string().uuid(),
|
||||
});
|
||||
|
||||
router.get('/me', requireDeviceAuth, async (req, res) => {
|
||||
const parsed = listSchema.safeParse(req.query);
|
||||
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ message: 'Invalid query params', errors: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceAuth = req.deviceAuth;
|
||||
|
||||
if (!deviceAuth) {
|
||||
res.status(401).json({ message: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await db.query.pushNotifications.findMany({
|
||||
where: and(
|
||||
eq(pushNotifications.ownerUserId, deviceAuth.userId),
|
||||
eq(pushNotifications.recipientDeviceId, deviceAuth.deviceId),
|
||||
),
|
||||
orderBy: [desc(pushNotifications.createdAt)],
|
||||
limit: parsed.data.limit,
|
||||
});
|
||||
|
||||
const filtered = parsed.data.status ? result.filter((item) => item.status === parsed.data.status) : result;
|
||||
|
||||
res.json({ count: filtered.length, notifications: filtered });
|
||||
});
|
||||
|
||||
router.post('/:notificationId/read', requireDeviceAuth, async (req, res) => {
|
||||
const parsedParams = notificationParamSchema.safeParse(req.params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
res.status(400).json({ message: 'Invalid notificationId', errors: parsedParams.error.flatten() });
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceAuth = req.deviceAuth;
|
||||
|
||||
if (!deviceAuth) {
|
||||
res.status(401).json({ message: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(pushNotifications)
|
||||
.set({
|
||||
status: 'read',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(pushNotifications.id, parsedParams.data.notificationId),
|
||||
eq(pushNotifications.ownerUserId, deviceAuth.userId),
|
||||
eq(pushNotifications.recipientDeviceId, deviceAuth.deviceId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
res.status(404).json({ message: 'Notification not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ message: 'Notification marked as read', notification: updated });
|
||||
});
|
||||
|
||||
router.post('/worker/dispatch', requireDeviceAuth, async (req, res) => {
|
||||
const deviceAuth = req.deviceAuth;
|
||||
|
||||
if (!deviceAuth) {
|
||||
res.status(401).json({ message: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const processed = await dispatchPushQueueOnce();
|
||||
res.json({ message: 'Push queue dispatch completed', processed });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -9,6 +9,7 @@ import { deviceCommands, deviceLinks, devices, streamSessions } from '../db/sche
|
||||
import { mediaProvider } from '../media/service';
|
||||
import { requireDeviceAuth } from '../middleware/device-auth';
|
||||
import { dispatchCommandById, sendRealtimeToDevice } from '../realtime/gateway';
|
||||
import { enqueuePushNotification } from '../services/push';
|
||||
import { createRecordingForStream } from './recordings';
|
||||
|
||||
const router = Router();
|
||||
@@ -167,13 +168,25 @@ router.post('/request', requireDeviceAuth, async (req, res) => {
|
||||
|
||||
const refreshedCommand = await db.query.deviceCommands.findFirst({ where: eq(deviceCommands.id, command.id) });
|
||||
|
||||
sendRealtimeToDevice(sourceDevice.id, 'stream:requested', {
|
||||
const deliveredToRequester = sendRealtimeToDevice(sourceDevice.id, 'stream:requested', {
|
||||
streamSessionId: session.id,
|
||||
cameraDeviceId: cameraDevice.id,
|
||||
status: session.status,
|
||||
reason: session.reason,
|
||||
});
|
||||
|
||||
if (!deliveredToRequester) {
|
||||
await enqueuePushNotification({
|
||||
ownerUserId: sourceDevice.userId,
|
||||
recipientDeviceId: sourceDevice.id,
|
||||
type: 'stream_requested',
|
||||
payload: {
|
||||
streamSessionId: session.id,
|
||||
cameraDeviceId: cameraDevice.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Stream request sent',
|
||||
streamSession: session,
|
||||
@@ -251,7 +264,7 @@ router.post('/:streamSessionId/accept', requireDeviceAuth, async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
sendRealtimeToDevice(session.requesterDeviceId, 'stream:started', {
|
||||
const deliveredToRequester = sendRealtimeToDevice(session.requesterDeviceId, 'stream:started', {
|
||||
streamSessionId: updated.id,
|
||||
cameraDeviceId: updated.cameraDeviceId,
|
||||
status: updated.status,
|
||||
@@ -261,6 +274,18 @@ router.post('/:streamSessionId/accept', requireDeviceAuth, async (req, res) => {
|
||||
subscribeEndpoint: updated.subscribeEndpoint,
|
||||
});
|
||||
|
||||
if (!deliveredToRequester) {
|
||||
await enqueuePushNotification({
|
||||
ownerUserId: session.ownerUserId,
|
||||
recipientDeviceId: session.requesterDeviceId,
|
||||
type: 'stream_started',
|
||||
payload: {
|
||||
streamSessionId: updated.id,
|
||||
cameraDeviceId: updated.cameraDeviceId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ message: 'Stream accepted', streamSession: updated });
|
||||
});
|
||||
|
||||
@@ -405,18 +430,42 @@ router.post('/:streamSessionId/end', requireDeviceAuth, async (req, res) => {
|
||||
|
||||
await createRecordingForStream(session.id);
|
||||
|
||||
sendRealtimeToDevice(session.requesterDeviceId, 'stream:ended', {
|
||||
const deliveredToRequester = sendRealtimeToDevice(session.requesterDeviceId, 'stream:ended', {
|
||||
streamSessionId: session.id,
|
||||
status: parsed.data.reason,
|
||||
endedAt: now,
|
||||
});
|
||||
|
||||
sendRealtimeToDevice(session.cameraDeviceId, 'stream:ended', {
|
||||
const deliveredToCamera = sendRealtimeToDevice(session.cameraDeviceId, 'stream:ended', {
|
||||
streamSessionId: session.id,
|
||||
status: parsed.data.reason,
|
||||
endedAt: now,
|
||||
});
|
||||
|
||||
if (!deliveredToRequester) {
|
||||
await enqueuePushNotification({
|
||||
ownerUserId: session.ownerUserId,
|
||||
recipientDeviceId: session.requesterDeviceId,
|
||||
type: 'stream_ended',
|
||||
payload: {
|
||||
streamSessionId: session.id,
|
||||
status: parsed.data.reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!deliveredToCamera) {
|
||||
await enqueuePushNotification({
|
||||
ownerUserId: session.ownerUserId,
|
||||
recipientDeviceId: session.cameraDeviceId,
|
||||
type: 'stream_ended',
|
||||
payload: {
|
||||
streamSessionId: session.id,
|
||||
status: parsed.data.reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ message: 'Stream ended', streamSession: updated });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user