import { and, desc, eq } from 'drizzle-orm'; import { Router } from 'express'; import { z } from 'zod'; import { db } from '../db/client'; import { recordings } from '../db/schema'; import { requireDeviceAuth } from '../middleware/device-auth'; import { writeAuditLog } from '../services/audit'; import { ensureMinioBucket, minioBucket, minioClient, minioPresignClient, 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(), }); const isMissingStorageObjectError = (error: unknown): boolean => { if (!error || typeof error !== 'object') { return false; } const code = 'code' in error ? String((error as { code?: unknown }).code) : ''; return code === 'NoSuchKey' || code === 'NotFound'; }; 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 bucket = parsed.data.bucket; const objectKey = parsed.data.objectKey; try { await ensureMinioBucket(); console.info('[recording.finalize] checking storage object', { recordingId: recording.id, streamSessionId: recording.streamSessionId, bucket, objectKey, durationSeconds: parsed.data.durationSeconds ?? null, sizeBytes: parsed.data.sizeBytes ?? null, }); try { await minioClient.statObject(bucket, objectKey); console.info('[recording.finalize] storage object found', { recordingId: recording.id, bucket, objectKey, }); } catch (error) { if (isMissingStorageObjectError(error)) { console.warn('[recording.finalize] storage object missing', { recordingId: recording.id, streamSessionId: recording.streamSessionId, bucket, objectKey, error: error instanceof Error ? error.message : String(error), }); res.status(409).json({ message: 'Recording object does not exist in storage yet' }); return; } console.error('[recording.finalize] storage verification failed', { recordingId: recording.id, bucket, objectKey, error: error instanceof Error ? error.message : String(error), }); throw error; } const [updated] = await db .update(recordings) .set({ objectKey, bucket, durationSeconds: parsed.data.durationSeconds, sizeBytes: parsed.data.sizeBytes, status: 'ready', availableAt: now, updatedAt: now, error: null, }) .where(eq(recordings.id, recording.id)) .returning(); console.info('[recording.finalize] recording marked ready', { recordingId: recording.id, streamSessionId: recording.streamSessionId, bucket, objectKey, durationSeconds: parsed.data.durationSeconds ?? null, sizeBytes: parsed.data.sizeBytes ?? null, }); res.json({ message: 'Recording finalized', recording: updated }); await writeAuditLog({ ownerUserId: recording.ownerUserId, actorDeviceId: recording.cameraDeviceId, action: 'recording.finalized', targetType: 'recording', targetId: recording.id, metadata: { objectKey: parsed.data.objectKey, bucket: parsed.data.bucket }, ipAddress: req.ip, }); } catch (error) { console.error('[recording.finalize] failed', { recordingId: recording.id, streamSessionId: recording.streamSessionId, bucket, objectKey, error: error instanceof Error ? error.message : String(error), }); throw error; } }); 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; } if (recording.status !== 'ready' || !recording.objectKey || !recording.bucket) { res.status(409).json({ message: 'Recording is not available yet' }); return; } try { await minioClient.statObject(recording.bucket, recording.objectKey); } catch (error) { if (isMissingStorageObjectError(error)) { res.status(409).json({ message: 'Recording file is missing from storage' }); return; } throw error; } const downloadUrl = await minioPresignClient.presignedGetObject( recording.bucket, recording.objectKey, minioPresignedExpirySeconds, ); res.json({ recordingId: recording.id, objectKey: recording.objectKey, bucket: recording.bucket, downloadUrl, expiresInSeconds: minioPresignedExpirySeconds, }); await writeAuditLog({ ownerUserId: recording.ownerUserId, actorDeviceId: deviceAuth.deviceId, action: 'recording.download_url_issued', targetType: 'recording', targetId: recording.id, metadata: { objectKey: recording.objectKey, bucket: recording.bucket }, ipAddress: req.ip, }); }); export default router;