feat(events): add motion start/end flow with realtime client notifications

This commit is contained in:
2026-01-10 11:20:00 +00:00
parent 71401e1973
commit aa8b278c46
3 changed files with 230 additions and 0 deletions

View File

@@ -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);

View File

@@ -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<string, unknown>,
): 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();

213
Backend/routes/events.ts Normal file
View File

@@ -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;