fix(backend): add recording upload diagnostics

This commit is contained in:
2026-04-15 13:50:00 +01:00
parent 9dc202ce03
commit 8cad36deb3
2 changed files with 197 additions and 120 deletions

View File

@@ -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) => {

View File

@@ -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) => {