From 8cad36deb3cb0293cd3473da89099a77b8f7f584 Mon Sep 17 00:00:00 2001 From: Matiss Jurevics Date: Wed, 15 Apr 2026 13:50:00 +0100 Subject: [PATCH] fix(backend): add recording upload diagnostics --- Backend/routes/recordings.ts | 143 +++++++++++++++++++--------- Backend/routes/videos.ts | 174 ++++++++++++++++++++--------------- 2 files changed, 197 insertions(+), 120 deletions(-) diff --git a/Backend/routes/recordings.ts b/Backend/routes/recordings.ts index af29bcc..627ae28 100644 --- a/Backend/routes/recordings.ts +++ b/Backend/routes/recordings.ts @@ -101,59 +101,110 @@ router.post('/:recordingId/finalize', requireDeviceAuth, async (req, res) => { const bucket = parsed.data.bucket; const objectKey = parsed.data.objectKey; - await ensureMinioBucket(); - try { - await minioClient.statObject(bucket, objectKey); - } catch (error) { - if (objectKey.startsWith('sim/')) { - const placeholder = Buffer.from( - JSON.stringify({ - message: 'simulated recording placeholder', + 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 (objectKey.startsWith('sim/')) { + console.warn('[recording.finalize] creating simulator fallback object', { + recordingId: recording.id, + bucket, + objectKey, + error: error instanceof Error ? error.message : String(error), + }); + const placeholder = Buffer.from( + JSON.stringify({ + message: 'simulated recording placeholder', + recordingId: recording.id, + streamSessionId: recording.streamSessionId, + createdAt: now.toISOString(), + }), + 'utf8', + ); + + await minioClient.putObject(bucket, objectKey, placeholder, placeholder.byteLength, { + 'Content-Type': 'application/json', + }); + } else if (isMissingStorageObjectError(error)) { + console.warn('[recording.finalize] storage object missing', { recordingId: recording.id, streamSessionId: recording.streamSessionId, - createdAt: now.toISOString(), - }), - 'utf8', - ); - - await minioClient.putObject(bucket, objectKey, placeholder, placeholder.byteLength, { - 'Content-Type': 'application/json', - }); - } else if (isMissingStorageObjectError(error)) { - res.status(409).json({ message: 'Recording object does not exist in storage yet' }); - return; - } else { - throw error; + bucket, + objectKey, + error: error instanceof Error ? error.message : String(error), + }); + res.status(409).json({ message: 'Recording object does not exist in storage yet' }); + return; + } else { + 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, + 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, - durationSeconds: parsed.data.durationSeconds, - sizeBytes: parsed.data.sizeBytes, - status: 'ready', - availableAt: now, - updatedAt: now, - error: null, - }) - .where(eq(recordings.id, recording.id)) - .returning(); + objectKey, + durationSeconds: parsed.data.durationSeconds ?? null, + sizeBytes: parsed.data.sizeBytes ?? null, + }); + res.json({ message: 'Recording finalized', recording: updated }); - 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, - }); + 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) => { diff --git a/Backend/routes/videos.ts b/Backend/routes/videos.ts index 5314aed..1755d02 100644 --- a/Backend/routes/videos.ts +++ b/Backend/routes/videos.ts @@ -43,7 +43,7 @@ const buildObjectKey = (userId: string, fileName: string, prefix?: string): stri router.use(requireAuth); router.post('/upload-url', async (req, res) => { - const parsed = uploadUrlSchema.safeParse(req.body); + const parsed = uploadUrlSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ message: 'Invalid request body', errors: parsed.error.flatten() }); @@ -57,94 +57,120 @@ router.post('/upload-url', async (req, res) => { return; } - await ensureMinioBucket(); + try { + await ensureMinioBucket(); - const device = await db.query.devices.findFirst({ - where: and(eq(devices.id, parsed.data.deviceId), eq(devices.userId, authSession.user.id)), - columns: { id: true }, - }); - - if (!device) { - res.status(400).json({ message: 'Invalid deviceId for this user' }); - return; - } - - if (parsed.data.eventId) { - const event = await db.query.events.findFirst({ - where: and(eq(events.id, parsed.data.eventId), eq(events.userId, authSession.user.id)), + const device = await db.query.devices.findFirst({ + where: and(eq(devices.id, parsed.data.deviceId), eq(devices.userId, authSession.user.id)), columns: { id: true }, }); - if (!event) { - res.status(400).json({ message: 'Invalid eventId for this user' }); + if (!device) { + res.status(400).json({ message: 'Invalid deviceId for this user' }); return; } - } - const objectKey = buildObjectKey(authSession.user.id, parsed.data.fileName, parsed.data.prefix); - const uploadUrl = await minioClient.presignedPutObject(minioBucket, objectKey, minioPresignedExpirySeconds); - const now = new Date(); - const expiresAt = new Date(now.getTime() + minioPresignedExpirySeconds * 1000); + if (parsed.data.eventId) { + const event = await db.query.events.findFirst({ + where: and(eq(events.id, parsed.data.eventId), eq(events.userId, authSession.user.id)), + columns: { id: true }, + }); - let persistedRecording; + if (!event) { + res.status(400).json({ message: 'Invalid eventId for this user' }); + return; + } + } - if (parsed.data.recordingId) { - const existingRecording = await db.query.recordings.findFirst({ - where: and(eq(recordings.id, parsed.data.recordingId), eq(recordings.ownerUserId, authSession.user.id)), + const objectKey = buildObjectKey(authSession.user.id, parsed.data.fileName, parsed.data.prefix); + const uploadUrl = await minioClient.presignedPutObject(minioBucket, objectKey, minioPresignedExpirySeconds); + const now = new Date(); + const expiresAt = new Date(now.getTime() + minioPresignedExpirySeconds * 1000); + + console.info('[recording.upload-url]', { + ownerUserId: authSession.user.id, + deviceId: parsed.data.deviceId, + recordingId: parsed.data.recordingId ?? null, + eventId: parsed.data.eventId ?? null, + objectKey, + bucket: minioBucket, + expiresInSeconds: minioPresignedExpirySeconds, + minioEndpoint: process.env.MINIO_ENDPOINT ?? 'localhost', + minioPort: Number(process.env.MINIO_PORT ?? 9000), + minioUseSSL: (process.env.MINIO_USE_SSL ?? 'false').toLowerCase() === 'true', }); - if (!existingRecording) { - res.status(404).json({ message: 'Recording not found' }); + let persistedRecording; + + if (parsed.data.recordingId) { + const existingRecording = await db.query.recordings.findFirst({ + where: and(eq(recordings.id, parsed.data.recordingId), eq(recordings.ownerUserId, authSession.user.id)), + }); + + if (!existingRecording) { + res.status(404).json({ message: 'Recording not found' }); + return; + } + + [persistedRecording] = await db + .update(recordings) + .set({ + objectKey, + bucket: minioBucket, + status: 'awaiting_upload', + updatedAt: now, + error: null, + }) + .where(eq(recordings.id, existingRecording.id)) + .returning(); + } else { + [persistedRecording] = await db + .insert(recordings) + .values({ + ownerUserId: authSession.user.id, + cameraDeviceId: parsed.data.deviceId, + requesterDeviceId: null, + eventId: parsed.data.eventId ?? null, + objectKey, + bucket: minioBucket, + status: 'awaiting_upload', + updatedAt: now, + }) + .returning(); + } + + if (!persistedRecording) { + res.status(500).json({ message: 'Unable to persist recording metadata' }); return; } - [persistedRecording] = await db - .update(recordings) - .set({ - objectKey, - bucket: minioBucket, - status: 'awaiting_upload', - updatedAt: now, - error: null, - }) - .where(eq(recordings.id, existingRecording.id)) - .returning(); - } else { - [persistedRecording] = await db - .insert(recordings) - .values({ - ownerUserId: authSession.user.id, - cameraDeviceId: parsed.data.deviceId, - requesterDeviceId: null, - eventId: parsed.data.eventId ?? null, - objectKey, - bucket: minioBucket, - status: 'awaiting_upload', - updatedAt: now, - }) - .returning(); + res.status(201).json({ + message: 'Upload URL generated', + bucket: minioBucket, + objectKey, + uploadUrl, + expiresInSeconds: minioPresignedExpirySeconds, + video: { + id: persistedRecording.id, + objectKey: persistedRecording.objectKey, + bucket: persistedRecording.bucket, + status: persistedRecording.status, + createdAt: persistedRecording.createdAt, + expiresAt, + }, + }); + } catch (error) { + console.error('[recording.upload-url] failed', { + ownerUserId: authSession.user.id, + deviceId: parsed.data.deviceId, + recordingId: parsed.data.recordingId ?? null, + eventId: parsed.data.eventId ?? null, + fileName: parsed.data.fileName, + prefix: parsed.data.prefix ?? null, + error: error instanceof Error ? error.message : String(error), + }); + throw error; } - - if (!persistedRecording) { - res.status(500).json({ message: 'Unable to persist recording metadata' }); - return; - } - - res.status(201).json({ - message: 'Upload URL generated', - bucket: minioBucket, - objectKey, - uploadUrl, - expiresInSeconds: minioPresignedExpirySeconds, - video: { - id: persistedRecording.id, - objectKey: persistedRecording.objectKey, - bucket: persistedRecording.bucket, - status: persistedRecording.status, - createdAt: persistedRecording.createdAt, - expiresAt, - }, - }); }); router.get('/download-url', async (req, res) => {