214 lines
5.7 KiB
TypeScript
214 lines
5.7 KiB
TypeScript
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;
|