feat(events): add motion start/end flow with realtime client notifications
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
213
Backend/routes/events.ts
Normal 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;
|
||||
Reference in New Issue
Block a user