feat(devices): add registration, heartbeat, linking, and device tokens

This commit is contained in:
2026-01-09 10:30:00 +00:00
parent 4fa525d8db
commit d51bac5a66
6 changed files with 462 additions and 0 deletions

View File

@@ -0,0 +1,117 @@
import { and, desc, eq } from 'drizzle-orm';
import { Router } from 'express';
import { z } from 'zod';
import { db } from '../db/client';
import { deviceLinks, devices } from '../db/schema';
import { requireAuth } from '../middleware/auth';
const router = Router();
const createLinkSchema = z.object({
cameraDeviceId: z.string().uuid(),
clientDeviceId: z.string().uuid(),
});
router.use(requireAuth);
router.post('/', async (req, res) => {
const parsed = createLinkSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ message: 'Invalid request body', errors: parsed.error.flatten() });
return;
}
const authSession = req.auth;
if (!authSession?.user?.id) {
res.status(401).json({ message: 'Unauthorized' });
return;
}
if (parsed.data.cameraDeviceId === parsed.data.clientDeviceId) {
res.status(400).json({ message: 'cameraDeviceId and clientDeviceId must be different' });
return;
}
const [camera, client] = await Promise.all([
db.query.devices.findFirst({
where: and(eq(devices.id, parsed.data.cameraDeviceId), eq(devices.userId, authSession.user.id)),
}),
db.query.devices.findFirst({
where: and(eq(devices.id, parsed.data.clientDeviceId), eq(devices.userId, authSession.user.id)),
}),
]);
if (!camera || !client) {
res.status(400).json({ message: 'Both devices must exist and belong to the authenticated user' });
return;
}
if (camera.role !== 'camera') {
res.status(400).json({ message: 'cameraDeviceId must belong to a camera role device' });
return;
}
if (client.role !== 'client') {
res.status(400).json({ message: 'clientDeviceId must belong to a client role device' });
return;
}
try {
const [created] = await db
.insert(deviceLinks)
.values({
ownerUserId: authSession.user.id,
cameraDeviceId: parsed.data.cameraDeviceId,
clientDeviceId: parsed.data.clientDeviceId,
status: 'active',
})
.returning();
res.status(201).json({ message: 'Device link created', link: created });
} catch (error) {
console.error('Failed to create device link', error);
res.status(409).json({ message: 'Device link already exists' });
}
});
router.get('/', async (req, res) => {
const authSession = req.auth;
if (!authSession?.user?.id) {
res.status(401).json({ message: 'Unauthorized' });
return;
}
const links = await db.query.deviceLinks.findMany({
where: eq(deviceLinks.ownerUserId, authSession.user.id),
orderBy: [desc(deviceLinks.updatedAt)],
});
res.json({ count: links.length, links });
});
router.delete('/:linkId', async (req, res) => {
const authSession = req.auth;
if (!authSession?.user?.id) {
res.status(401).json({ message: 'Unauthorized' });
return;
}
const [deleted] = await db
.delete(deviceLinks)
.where(and(eq(deviceLinks.id, req.params.linkId), eq(deviceLinks.ownerUserId, authSession.user.id)))
.returning({ id: deviceLinks.id });
if (!deleted) {
res.status(404).json({ message: 'Link not found' });
return;
}
res.json({ message: 'Device link removed', linkId: deleted.id });
});
export default router;