From aa8b278c46aee96adb2ee3bd4b6963c43920e6e3 Mon Sep 17 00:00:00 2001 From: Matiss Jurevics Date: Sat, 10 Jan 2026 11:20:00 +0000 Subject: [PATCH] feat(events): add motion start/end flow with realtime client notifications --- Backend/index.ts | 2 + Backend/realtime/gateway.ts | 15 +++ Backend/routes/events.ts | 213 ++++++++++++++++++++++++++++++++++++ 3 files changed, 230 insertions(+) create mode 100644 Backend/routes/events.ts diff --git a/Backend/index.ts b/Backend/index.ts index f50b0cf..c322530 100644 --- a/Backend/index.ts +++ b/Backend/index.ts @@ -10,6 +10,7 @@ import adminRoutes from './routes/admin'; import devicesRoutes from './routes/devices'; import deviceLinksRoutes from './routes/device-links'; import commandsRoutes from './routes/commands'; +import eventsRoutes from './routes/events'; import { setupRealtimeGateway } from './realtime/gateway'; import { ensureMinioBucket } from './utils/minio'; @@ -34,6 +35,7 @@ app.use('/admin', adminRoutes); app.use('/devices', devicesRoutes); app.use('/device-links', deviceLinksRoutes); app.use('/commands', commandsRoutes); +app.use('/events', eventsRoutes); app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { console.error(err); diff --git a/Backend/realtime/gateway.ts b/Backend/realtime/gateway.ts index 4047eca..5c11098 100644 --- a/Backend/realtime/gateway.ts +++ b/Backend/realtime/gateway.ts @@ -31,6 +31,21 @@ const countSocketsForDevice = (deviceId: string): number => { return io.sockets.adapter.rooms.get(roomForDevice(deviceId))?.size ?? 0; }; +export const isDeviceOnline = (deviceId: string): boolean => countSocketsForDevice(deviceId) > 0; + +export const sendRealtimeToDevice = ( + deviceId: string, + eventName: string, + payload: Record, +): boolean => { + if (!io || !isDeviceOnline(deviceId)) { + return false; + } + + io.to(roomForDevice(deviceId)).emit(eventName, payload); + return true; +}; + const markDevicePresence = async (deviceId: string, status: 'online' | 'offline') => { const now = new Date(); diff --git a/Backend/routes/events.ts b/Backend/routes/events.ts new file mode 100644 index 0000000..c994d00 --- /dev/null +++ b/Backend/routes/events.ts @@ -0,0 +1,213 @@ +import { and, desc, eq, isNull } from 'drizzle-orm'; +import { Router } from 'express'; +import { z } from 'zod'; + +import { db } from '../db/client'; +import { deviceLinks, devices, events, notifications } from '../db/schema'; +import { requireAuth } from '../middleware/auth'; +import { requireDeviceAuth } from '../middleware/device-auth'; +import { sendRealtimeToDevice } from '../realtime/gateway'; + +const router = Router(); + +const startMotionSchema = z.object({ + title: z.string().trim().min(1).max(255).optional(), + triggeredBy: z.string().trim().min(1).max(64).default('motion'), + videoUrl: z.string().trim().url().optional(), +}); + +const endMotionSchema = z.object({ + status: z.enum(['completed', 'cancelled', 'failed']).default('completed'), + videoUrl: z.string().trim().url().optional(), +}); + +const listEventsSchema = z.object({ + status: z.string().trim().optional(), + limit: z.coerce.number().int().min(1).max(100).default(25), +}); + +const eventParamSchema = z.object({ + eventId: z.string().uuid(), +}); + +router.post('/motion/start', requireDeviceAuth, async (req, res) => { + const parsed = startMotionSchema.safeParse(req.body ?? {}); + + if (!parsed.success) { + res.status(400).json({ message: 'Invalid request body', errors: parsed.error.flatten() }); + return; + } + + const deviceAuth = req.deviceAuth; + + if (!deviceAuth) { + res.status(401).json({ message: 'Unauthorized' }); + return; + } + + const cameraDevice = await db.query.devices.findFirst({ + where: and(eq(devices.id, deviceAuth.deviceId), eq(devices.userId, deviceAuth.userId)), + }); + + if (!cameraDevice) { + res.status(404).json({ message: 'Device not found' }); + return; + } + + if (cameraDevice.role !== 'camera') { + res.status(403).json({ message: 'Only camera devices can start motion events' }); + return; + } + + const now = new Date(); + + const [event] = await db + .insert(events) + .values({ + userId: deviceAuth.userId, + deviceId: cameraDevice.id, + title: parsed.data.title, + triggeredBy: parsed.data.triggeredBy, + status: 'recording', + startedAt: now, + videoUrl: parsed.data.videoUrl, + updatedAt: now, + }) + .returning(); + + if (!event) { + res.status(500).json({ message: 'Failed to create motion event' }); + return; + } + + const activeLinks = await db.query.deviceLinks.findMany({ + where: and( + eq(deviceLinks.ownerUserId, deviceAuth.userId), + eq(deviceLinks.cameraDeviceId, cameraDevice.id), + eq(deviceLinks.status, 'active'), + ), + }); + + for (const link of activeLinks) { + const delivered = sendRealtimeToDevice(link.clientDeviceId, 'motion:detected', { + eventId: event.id, + cameraDeviceId: cameraDevice.id, + title: event.title, + triggeredBy: event.triggeredBy, + startedAt: event.startedAt, + }); + + await db.insert(notifications).values({ + eventId: event.id, + userId: deviceAuth.userId, + channel: delivered ? 'realtime' : 'queued', + status: delivered ? 'delivered' : 'queued', + isRead: false, + sentAt: now, + }); + } + + res.status(201).json({ + message: 'Motion event started', + event, + notifiedClients: activeLinks.length, + }); +}); + +router.post('/:eventId/motion/end', requireDeviceAuth, async (req, res) => { + const parsedParams = eventParamSchema.safeParse(req.params); + + if (!parsedParams.success) { + res.status(400).json({ message: 'Invalid eventId', errors: parsedParams.error.flatten() }); + return; + } + + const parsed = endMotionSchema.safeParse(req.body ?? {}); + + if (!parsed.success) { + res.status(400).json({ message: 'Invalid request body', errors: parsed.error.flatten() }); + return; + } + + const deviceAuth = req.deviceAuth; + + if (!deviceAuth) { + res.status(401).json({ message: 'Unauthorized' }); + return; + } + + const event = await db.query.events.findFirst({ + where: and( + eq(events.id, parsedParams.data.eventId), + eq(events.userId, deviceAuth.userId), + eq(events.deviceId, deviceAuth.deviceId), + isNull(events.endedAt), + ), + }); + + if (!event) { + res.status(404).json({ message: 'Active event not found for this camera device' }); + return; + } + + const now = new Date(); + + const [updated] = await db + .update(events) + .set({ + endedAt: now, + status: parsed.data.status, + videoUrl: parsed.data.videoUrl ?? event.videoUrl, + updatedAt: now, + }) + .where(eq(events.id, event.id)) + .returning(); + + const activeLinks = await db.query.deviceLinks.findMany({ + where: and( + eq(deviceLinks.ownerUserId, deviceAuth.userId), + eq(deviceLinks.cameraDeviceId, deviceAuth.deviceId), + eq(deviceLinks.status, 'active'), + ), + }); + + for (const link of activeLinks) { + sendRealtimeToDevice(link.clientDeviceId, 'motion:ended', { + eventId: event.id, + cameraDeviceId: deviceAuth.deviceId, + status: parsed.data.status, + endedAt: now, + videoUrl: parsed.data.videoUrl ?? event.videoUrl, + }); + } + + res.json({ message: 'Motion event ended', event: updated, notifiedClients: activeLinks.length }); +}); + +router.get('/', requireAuth, async (req, res) => { + const parsed = listEventsSchema.safeParse(req.query); + + if (!parsed.success) { + res.status(400).json({ message: 'Invalid query params', errors: parsed.error.flatten() }); + return; + } + + const authSession = req.auth; + + if (!authSession?.user?.id) { + res.status(401).json({ message: 'Unauthorized' }); + return; + } + + const result = await db.query.events.findMany({ + where: eq(events.userId, authSession.user.id), + orderBy: [desc(events.startedAt)], + limit: parsed.data.limit, + }); + + const filtered = parsed.data.status ? result.filter((event) => event.status === parsed.data.status) : result; + + res.json({ count: filtered.length, events: filtered }); +}); + +export default router;