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, notificationDeliveries, notifications } from '../db/schema'; import { requireAuth } from '../middleware/auth'; import { requireDeviceAuth } from '../middleware/device-auth'; import { sendRealtimeToDevice } from '../realtime/gateway'; import { writeAuditLog } from '../services/audit'; import { enqueuePushNotification } from '../services/push'; 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, }); 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, type: 'motion_detected', payload: { eventId: event.id, cameraDeviceId: cameraDevice.id, startedAt: event.startedAt.toISOString(), }, }); } } res.status(201).json({ message: 'Motion event started', event, notifiedClients: activeLinks.length, }); await writeAuditLog({ ownerUserId: deviceAuth.userId, actorDeviceId: cameraDevice.id, action: 'event.motion_started', targetType: 'event', targetId: event.id, metadata: { notifiedClients: activeLinks.length }, ipAddress: req.ip, }); }); 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) { 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 }); await writeAuditLog({ ownerUserId: deviceAuth.userId, actorDeviceId: deviceAuth.deviceId, action: 'event.motion_ended', targetType: 'event', targetId: event.id, metadata: { status: parsed.data.status, notifiedClients: activeLinks.length }, ipAddress: req.ip, }); }); 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;