fix(backend): add recording upload diagnostics
This commit is contained in:
@@ -101,59 +101,110 @@ router.post('/:recordingId/finalize', requireDeviceAuth, async (req, res) => {
|
|||||||
const bucket = parsed.data.bucket;
|
const bucket = parsed.data.bucket;
|
||||||
const objectKey = parsed.data.objectKey;
|
const objectKey = parsed.data.objectKey;
|
||||||
|
|
||||||
await ensureMinioBucket();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await minioClient.statObject(bucket, objectKey);
|
await ensureMinioBucket();
|
||||||
} catch (error) {
|
console.info('[recording.finalize] checking storage object', {
|
||||||
if (objectKey.startsWith('sim/')) {
|
recordingId: recording.id,
|
||||||
const placeholder = Buffer.from(
|
streamSessionId: recording.streamSessionId,
|
||||||
JSON.stringify({
|
bucket,
|
||||||
message: 'simulated recording placeholder',
|
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,
|
recordingId: recording.id,
|
||||||
streamSessionId: recording.streamSessionId,
|
streamSessionId: recording.streamSessionId,
|
||||||
createdAt: now.toISOString(),
|
bucket,
|
||||||
}),
|
objectKey,
|
||||||
'utf8',
|
error: error instanceof Error ? error.message : String(error),
|
||||||
);
|
});
|
||||||
|
res.status(409).json({ message: 'Recording object does not exist in storage yet' });
|
||||||
await minioClient.putObject(bucket, objectKey, placeholder, placeholder.byteLength, {
|
return;
|
||||||
'Content-Type': 'application/json',
|
} else {
|
||||||
});
|
console.error('[recording.finalize] storage verification failed', {
|
||||||
} else if (isMissingStorageObjectError(error)) {
|
recordingId: recording.id,
|
||||||
res.status(409).json({ message: 'Recording object does not exist in storage yet' });
|
bucket,
|
||||||
return;
|
objectKey,
|
||||||
} else {
|
error: error instanceof Error ? error.message : String(error),
|
||||||
throw error;
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(recordings)
|
.update(recordings)
|
||||||
.set({
|
.set({
|
||||||
objectKey,
|
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,
|
bucket,
|
||||||
durationSeconds: parsed.data.durationSeconds,
|
objectKey,
|
||||||
sizeBytes: parsed.data.sizeBytes,
|
durationSeconds: parsed.data.durationSeconds ?? null,
|
||||||
status: 'ready',
|
sizeBytes: parsed.data.sizeBytes ?? null,
|
||||||
availableAt: now,
|
});
|
||||||
updatedAt: now,
|
res.json({ message: 'Recording finalized', recording: updated });
|
||||||
error: null,
|
|
||||||
})
|
|
||||||
.where(eq(recordings.id, recording.id))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
res.json({ message: 'Recording finalized', recording: updated });
|
await writeAuditLog({
|
||||||
|
ownerUserId: recording.ownerUserId,
|
||||||
await writeAuditLog({
|
actorDeviceId: recording.cameraDeviceId,
|
||||||
ownerUserId: recording.ownerUserId,
|
action: 'recording.finalized',
|
||||||
actorDeviceId: recording.cameraDeviceId,
|
targetType: 'recording',
|
||||||
action: 'recording.finalized',
|
targetId: recording.id,
|
||||||
targetType: 'recording',
|
metadata: { objectKey: parsed.data.objectKey, bucket: parsed.data.bucket },
|
||||||
targetId: recording.id,
|
ipAddress: req.ip,
|
||||||
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) => {
|
router.get('/:recordingId/download-url', requireDeviceAuth, async (req, res) => {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const buildObjectKey = (userId: string, fileName: string, prefix?: string): stri
|
|||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
|
|
||||||
router.post('/upload-url', async (req, res) => {
|
router.post('/upload-url', async (req, res) => {
|
||||||
const parsed = uploadUrlSchema.safeParse(req.body);
|
const parsed = uploadUrlSchema.safeParse(req.body);
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
res.status(400).json({ message: 'Invalid request body', errors: parsed.error.flatten() });
|
res.status(400).json({ message: 'Invalid request body', errors: parsed.error.flatten() });
|
||||||
@@ -57,94 +57,120 @@ router.post('/upload-url', async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureMinioBucket();
|
try {
|
||||||
|
await ensureMinioBucket();
|
||||||
|
|
||||||
const device = await db.query.devices.findFirst({
|
const device = await db.query.devices.findFirst({
|
||||||
where: and(eq(devices.id, parsed.data.deviceId), eq(devices.userId, authSession.user.id)),
|
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)),
|
|
||||||
columns: { id: true },
|
columns: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!event) {
|
if (!device) {
|
||||||
res.status(400).json({ message: 'Invalid eventId for this user' });
|
res.status(400).json({ message: 'Invalid deviceId for this user' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const objectKey = buildObjectKey(authSession.user.id, parsed.data.fileName, parsed.data.prefix);
|
if (parsed.data.eventId) {
|
||||||
const uploadUrl = await minioClient.presignedPutObject(minioBucket, objectKey, minioPresignedExpirySeconds);
|
const event = await db.query.events.findFirst({
|
||||||
const now = new Date();
|
where: and(eq(events.id, parsed.data.eventId), eq(events.userId, authSession.user.id)),
|
||||||
const expiresAt = new Date(now.getTime() + minioPresignedExpirySeconds * 1000);
|
columns: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
let persistedRecording;
|
if (!event) {
|
||||||
|
res.status(400).json({ message: 'Invalid eventId for this user' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (parsed.data.recordingId) {
|
const objectKey = buildObjectKey(authSession.user.id, parsed.data.fileName, parsed.data.prefix);
|
||||||
const existingRecording = await db.query.recordings.findFirst({
|
const uploadUrl = await minioClient.presignedPutObject(minioBucket, objectKey, minioPresignedExpirySeconds);
|
||||||
where: and(eq(recordings.id, parsed.data.recordingId), eq(recordings.ownerUserId, authSession.user.id)),
|
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) {
|
let persistedRecording;
|
||||||
res.status(404).json({ message: 'Recording not found' });
|
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
[persistedRecording] = await db
|
res.status(201).json({
|
||||||
.update(recordings)
|
message: 'Upload URL generated',
|
||||||
.set({
|
bucket: minioBucket,
|
||||||
objectKey,
|
objectKey,
|
||||||
bucket: minioBucket,
|
uploadUrl,
|
||||||
status: 'awaiting_upload',
|
expiresInSeconds: minioPresignedExpirySeconds,
|
||||||
updatedAt: now,
|
video: {
|
||||||
error: null,
|
id: persistedRecording.id,
|
||||||
})
|
objectKey: persistedRecording.objectKey,
|
||||||
.where(eq(recordings.id, existingRecording.id))
|
bucket: persistedRecording.bucket,
|
||||||
.returning();
|
status: persistedRecording.status,
|
||||||
} else {
|
createdAt: persistedRecording.createdAt,
|
||||||
[persistedRecording] = await db
|
expiresAt,
|
||||||
.insert(recordings)
|
},
|
||||||
.values({
|
});
|
||||||
ownerUserId: authSession.user.id,
|
} catch (error) {
|
||||||
cameraDeviceId: parsed.data.deviceId,
|
console.error('[recording.upload-url] failed', {
|
||||||
requesterDeviceId: null,
|
ownerUserId: authSession.user.id,
|
||||||
eventId: parsed.data.eventId ?? null,
|
deviceId: parsed.data.deviceId,
|
||||||
objectKey,
|
recordingId: parsed.data.recordingId ?? null,
|
||||||
bucket: minioBucket,
|
eventId: parsed.data.eventId ?? null,
|
||||||
status: 'awaiting_upload',
|
fileName: parsed.data.fileName,
|
||||||
updatedAt: now,
|
prefix: parsed.data.prefix ?? null,
|
||||||
})
|
error: error instanceof Error ? error.message : String(error),
|
||||||
.returning();
|
});
|
||||||
|
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) => {
|
router.get('/download-url', async (req, res) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user