Files
Final-Year-Project/Backend/routes/commands.ts

221 lines
6.2 KiB
TypeScript

import { and, desc, eq } from 'drizzle-orm';
import { Router } from 'express';
import { z } from 'zod';
import { db } from '../db/client';
import { commands, deviceLinks, devices } from '../db/schema';
import { simpleStreamingEnabled } from '../media/config';
import { requireAuth } from '../middleware/auth';
import { requireDeviceAuth } from '../middleware/device-auth';
import { dispatchCommandById } from '../realtime/gateway';
const router = Router();
const commandCreateSchema = z.object({
sourceDeviceId: z.string().uuid(),
targetDeviceId: z.string().uuid(),
commandType: z.enum(['start_stream', 'stop_stream', 'ping', 'update_settings']),
payload: z.record(z.string(), z.unknown()).optional(),
});
const ackSchema = z.object({
status: z.enum(['acknowledged', 'rejected']),
error: z.string().optional(),
});
const commandParamSchema = z.object({
commandId: z.string().uuid(),
});
const querySchema = z.object({
sourceDeviceId: z.string().uuid().optional(),
targetDeviceId: z.string().uuid().optional(),
status: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(25),
});
router.post('/', requireAuth, async (req, res) => {
const parsed = commandCreateSchema.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 (simpleStreamingEnabled && parsed.data.commandType === 'start_stream') {
res.status(409).json({
message: 'start_stream commands are disabled while SIMPLE_STREAMING is enabled; use /streams/request instead',
});
return;
}
if (parsed.data.sourceDeviceId === parsed.data.targetDeviceId) {
res.status(400).json({ message: 'sourceDeviceId and targetDeviceId must differ' });
return;
}
const [sourceDevice, targetDevice] = await Promise.all([
db.query.devices.findFirst({
where: and(eq(devices.id, parsed.data.sourceDeviceId), eq(devices.userId, authSession.user.id)),
}),
db.query.devices.findFirst({
where: and(eq(devices.id, parsed.data.targetDeviceId), eq(devices.userId, authSession.user.id)),
}),
]);
if (!sourceDevice || !targetDevice) {
res.status(400).json({ message: 'Both source and target devices must belong to the authenticated user' });
return;
}
const validRoleDirection = sourceDevice.role === 'client' && targetDevice.role === 'camera';
if (!validRoleDirection) {
res.status(400).json({ message: 'Commands are currently allowed only from client -> camera devices' });
return;
}
const link = await db.query.deviceLinks.findFirst({
where: and(
eq(deviceLinks.ownerUserId, authSession.user.id),
eq(deviceLinks.cameraDeviceId, targetDevice.id),
eq(deviceLinks.clientDeviceId, sourceDevice.id),
eq(deviceLinks.status, 'active'),
),
});
if (!link) {
res.status(403).json({ message: 'No active camera-client link found for command routing' });
return;
}
const now = new Date();
const [command] = await db
.insert(commands)
.values({
ownerUserId: authSession.user.id,
sourceDeviceId: sourceDevice.id,
targetDeviceId: targetDevice.id,
commandType: parsed.data.commandType,
payload: parsed.data.payload ?? null,
status: 'queued',
lastDispatchedAt: now,
updatedAt: now,
})
.returning();
if (!command) {
res.status(500).json({ message: 'Failed creating command' });
return;
}
await dispatchCommandById(command.id);
const refreshed = await db.query.commands.findFirst({ where: eq(commands.id, command.id) });
res.status(201).json({
message: 'Command queued',
command: refreshed ?? command,
});
});
router.get('/', requireAuth, async (req, res) => {
const parsed = querySchema.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 commandResults = await db.query.commands.findMany({
where: eq(commands.ownerUserId, authSession.user.id),
orderBy: [desc(commands.createdAt)],
limit: parsed.data.limit,
});
const filtered = commandResults.filter((command) => {
if (parsed.data.sourceDeviceId && command.sourceDeviceId !== parsed.data.sourceDeviceId) {
return false;
}
if (parsed.data.targetDeviceId && command.targetDeviceId !== parsed.data.targetDeviceId) {
return false;
}
if (parsed.data.status && command.status !== parsed.data.status) {
return false;
}
return true;
});
res.json({ count: filtered.length, commands: filtered });
});
router.post('/:commandId/ack', requireDeviceAuth, async (req, res) => {
const parsedParams = commandParamSchema.safeParse(req.params);
if (!parsedParams.success) {
res.status(400).json({ message: 'Invalid commandId', errors: parsedParams.error.flatten() });
return;
}
const parsed = ackSchema.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 command = await db.query.commands.findFirst({
where: eq(commands.id, parsedParams.data.commandId),
});
if (!command) {
res.status(404).json({ message: 'Command not found' });
return;
}
if (command.targetDeviceId !== deviceAuth.deviceId) {
res.status(403).json({ message: 'Only target device can acknowledge this command' });
return;
}
const now = new Date();
const [updated] = await db
.update(commands)
.set({
status: parsed.data.status,
acknowledgedAt: now,
updatedAt: now,
error: parsed.data.status === 'rejected' ? parsed.data.error ?? 'rejected' : null,
})
.where(eq(commands.id, command.id))
.returning();
res.json({ message: 'Command acknowledged', command: updated });
});
export default router;