import { and, desc, eq } from 'drizzle-orm'; import { Router } from 'express'; import { z } from 'zod'; import { db } from '../db/client'; import { recordings, streamSessions } from '../db/schema'; import { requireDeviceAuth } from '../middleware/device-auth'; import { minioBucket, minioClient, minioPresignedExpirySeconds } from '../utils/minio'; const router = Router(); const listSchema = z.object({ status: z.string().optional(), limit: z.coerce.number().int().min(1).max(100).default(25), }); const finalizeSchema = z.object({ objectKey: z.string().trim().min(1), bucket: z.string().trim().min(1).default(minioBucket), durationSeconds: z.coerce.number().int().nonnegative().optional(), sizeBytes: z.coerce.number().int().nonnegative().optional(), }); const recordingParamSchema = z.object({ recordingId: z.string().uuid(), }); router.get('/me/list', requireDeviceAuth, async (req, res) => { const parsed = listSchema.safeParse(req.query); if (!parsed.success) { res.status(400).json({ message: 'Invalid query params', errors: parsed.error.flatten() }); return; } const deviceAuth = req.deviceAuth; if (!deviceAuth) { res.status(401).json({ message: 'Unauthorized' }); return; } const result = await db.query.recordings.findMany({ where: eq(recordings.ownerUserId, deviceAuth.userId), orderBy: [desc(recordings.createdAt)], limit: parsed.data.limit, }); const filtered = parsed.data.status ? result.filter((recording) => recording.status === parsed.data.status) : result; res.json({ count: filtered.length, recordings: filtered }); }); router.post('/:recordingId/finalize', requireDeviceAuth, async (req, res) => { const parsedParams = recordingParamSchema.safeParse(req.params); if (!parsedParams.success) { res.status(400).json({ message: 'Invalid recordingId', errors: parsedParams.error.flatten() }); return; } const parsed = finalizeSchema.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 recording = await db.query.recordings.findFirst({ where: and(eq(recordings.id, parsedParams.data.recordingId), eq(recordings.ownerUserId, deviceAuth.userId)), }); if (!recording) { res.status(404).json({ message: 'Recording not found' }); return; } if (recording.cameraDeviceId !== deviceAuth.deviceId) { res.status(403).json({ message: 'Only camera device can finalize this recording' }); return; } const now = new Date(); const [updated] = await db .update(recordings) .set({ objectKey: parsed.data.objectKey, bucket: parsed.data.bucket, durationSeconds: parsed.data.durationSeconds, sizeBytes: parsed.data.sizeBytes, status: 'ready', availableAt: now, updatedAt: now, error: null, }) .where(eq(recordings.id, recording.id)) .returning(); res.json({ message: 'Recording finalized', recording: updated }); }); router.get('/:recordingId/download-url', requireDeviceAuth, async (req, res) => { const parsedParams = recordingParamSchema.safeParse(req.params); if (!parsedParams.success) { res.status(400).json({ message: 'Invalid recordingId', errors: parsedParams.error.flatten() }); return; } const deviceAuth = req.deviceAuth; if (!deviceAuth) { res.status(401).json({ message: 'Unauthorized' }); return; } const recording = await db.query.recordings.findFirst({ where: and(eq(recordings.id, parsedParams.data.recordingId), eq(recordings.ownerUserId, deviceAuth.userId)), }); if (!recording) { res.status(404).json({ message: 'Recording not found' }); return; } const canAccess = recording.requesterDeviceId === deviceAuth.deviceId || recording.cameraDeviceId === deviceAuth.deviceId; if (!canAccess) { res.status(403).json({ message: 'Device cannot access this recording' }); return; } if (recording.status !== 'ready' || !recording.objectKey || !recording.bucket) { res.status(409).json({ message: 'Recording is not available yet' }); return; } const downloadUrl = await minioClient.presignedGetObject( recording.bucket, recording.objectKey, minioPresignedExpirySeconds, ); res.json({ recordingId: recording.id, objectKey: recording.objectKey, bucket: recording.bucket, downloadUrl, expiresInSeconds: minioPresignedExpirySeconds, }); }); // Internal helper used by stream lifecycle to create recording placeholder rows. export const createRecordingForStream = async (streamSessionId: string): Promise => { const stream = await db.query.streamSessions.findFirst({ where: eq(streamSessions.id, streamSessionId) }); if (!stream) { return; } const existing = await db.query.recordings.findFirst({ where: eq(recordings.streamSessionId, stream.id) }); if (existing) { return; } await db.insert(recordings).values({ ownerUserId: stream.ownerUserId, streamSessionId: stream.id, cameraDeviceId: stream.cameraDeviceId, requesterDeviceId: stream.requesterDeviceId, status: 'awaiting_upload', updatedAt: new Date(), }); }; export default router;