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;

219
Backend/routes/devices.ts Normal file
View File

@@ -0,0 +1,219 @@
import { and, desc, eq } from 'drizzle-orm';
import { Router } from 'express';
import { z } from 'zod';
import { db } from '../db/client';
import { devices } from '../db/schema';
import { requireAuth } from '../middleware/auth';
import { requireDeviceAuth } from '../middleware/device-auth';
import { createDeviceToken } from '../utils/device-token';
const router = Router();
const roleSchema = z.enum(['camera', 'client']);
const registerSchema = z.object({
name: z.string().trim().min(1).max(255).optional(),
role: roleSchema,
platform: z.string().trim().min(1).max(32).optional(),
appVersion: z.string().trim().min(1).max(64).optional(),
pushToken: z.string().trim().min(1).optional(),
});
const updateSchema = z.object({
name: z.string().trim().min(1).max(255).optional(),
role: roleSchema.optional(),
platform: z.string().trim().min(1).max(32).optional(),
appVersion: z.string().trim().min(1).max(64).optional(),
pushToken: z.string().trim().min(1).optional(),
status: z.enum(['online', 'offline']).optional(),
});
const heartbeatSchema = z.object({
status: z.enum(['online', 'offline']).default('online'),
});
const deviceParamSchema = z.object({
deviceId: z.string().uuid(),
});
router.post('/register', requireAuth, async (req, res) => {
const parsed = registerSchema.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;
}
const now = new Date();
const [device] = await db
.insert(devices)
.values({
userId: authSession.user.id,
name: parsed.data.name,
role: parsed.data.role,
isCamera: parsed.data.role === 'camera',
platform: parsed.data.platform,
appVersion: parsed.data.appVersion,
pushToken: parsed.data.pushToken,
status: 'online',
lastSeenAt: now,
updatedAt: now,
})
.returning({
id: devices.id,
userId: devices.userId,
name: devices.name,
role: devices.role,
status: devices.status,
platform: devices.platform,
appVersion: devices.appVersion,
lastSeenAt: devices.lastSeenAt,
createdAt: devices.createdAt,
updatedAt: devices.updatedAt,
});
if (!device) {
res.status(500).json({ message: 'Unable to register device' });
return;
}
const deviceToken = createDeviceToken({
userId: device.userId,
deviceId: device.id,
role: device.role as 'camera' | 'client',
});
res.status(201).json({
message: 'Device registered',
device,
deviceToken,
});
});
router.get('/', requireAuth, async (req, res) => {
const authSession = req.auth;
if (!authSession?.user?.id) {
res.status(401).json({ message: 'Unauthorized' });
return;
}
const result = await db.query.devices.findMany({
where: eq(devices.userId, authSession.user.id),
orderBy: [desc(devices.updatedAt)],
});
res.json({ count: result.length, devices: result });
});
router.patch('/:deviceId', requireAuth, async (req, res) => {
const parsedParams = deviceParamSchema.safeParse(req.params);
if (!parsedParams.success) {
res.status(400).json({ message: 'Invalid device id', errors: parsedParams.error.flatten() });
return;
}
const parsed = updateSchema.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;
}
const existing = await db.query.devices.findFirst({
where: and(eq(devices.id, parsedParams.data.deviceId), eq(devices.userId, authSession.user.id)),
});
if (!existing) {
res.status(404).json({ message: 'Device not found' });
return;
}
const role = parsed.data.role ?? existing.role;
const now = new Date();
const [updated] = await db
.update(devices)
.set({
name: parsed.data.name ?? existing.name,
role,
isCamera: role === 'camera',
platform: parsed.data.platform ?? existing.platform,
appVersion: parsed.data.appVersion ?? existing.appVersion,
pushToken: parsed.data.pushToken ?? existing.pushToken,
status: parsed.data.status ?? existing.status,
updatedAt: now,
lastSeenAt: parsed.data.status === 'online' ? now : existing.lastSeenAt,
})
.where(eq(devices.id, existing.id))
.returning();
res.json({ message: 'Device updated', device: updated });
});
router.post('/:deviceId/heartbeat', requireDeviceAuth, async (req, res) => {
const parsedParams = deviceParamSchema.safeParse(req.params);
if (!parsedParams.success) {
res.status(400).json({ message: 'Invalid device id', errors: parsedParams.error.flatten() });
return;
}
const parsed = heartbeatSchema.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;
}
if (deviceAuth.deviceId !== parsedParams.data.deviceId) {
res.status(403).json({ message: 'Device token does not match target device' });
return;
}
const now = new Date();
const [updated] = await db
.update(devices)
.set({
status: parsed.data.status,
lastSeenAt: now,
updatedAt: now,
})
.where(and(eq(devices.id, parsedParams.data.deviceId), eq(devices.userId, deviceAuth.userId)))
.returning({
id: devices.id,
status: devices.status,
lastSeenAt: devices.lastSeenAt,
updatedAt: devices.updatedAt,
});
if (!updated) {
res.status(404).json({ message: 'Device not found' });
return;
}
res.json({ message: 'Heartbeat recorded', device: updated });
});
export default router;