From e4b0f961b75952b6907cc2291fa256215c09c80c Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sun, 26 Apr 2026 22:15:00 +0200 Subject: [PATCH] fix: prevent backend crash on archive when admin_email is null (#318) Archiving an event with no admin_email queued an email_queue row with recipient_email=null, violating the NOT NULL constraint. The error was thrown inside the output.on('close') callback (detached from the caller), becoming an unhandled rejection that crashed Node and dropped admin sessions on bulk archive. - Skip queueEmail when event.admin_email is null/empty (admin_email has been nullable since migration 073). - Wrap the close handler in try/catch so any post-archive failure logs instead of crashing the process. --- backend/src/services/archiveService.js | 59 +++++++++++++++----------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/backend/src/services/archiveService.js b/backend/src/services/archiveService.js index f4e62ea2..59521393 100644 --- a/backend/src/services/archiveService.js +++ b/backend/src/services/archiveService.js @@ -61,32 +61,43 @@ async function archiveEvent(event) { } output.on('close', async () => { - logger.info(`Archive created: ${archiveName} (${archive.pointer()} bytes)`); - - // Update database - await db('events').where('id', event.id).update({ - is_archived: true, - archive_path: path.relative(getStoragePath(), archivePath), - archived_at: new Date() - }); - - // Delete original files - await fs.rm(eventPath, { recursive: true }); - - // Delete thumbnails - const photos = await db('photos').where('event_id', event.id); - for (const photo of photos) { - if (photo.thumbnail_path) { - const thumbPath = path.join(getStoragePath(), photo.thumbnail_path); - await fs.unlink(thumbPath).catch(() => {}); // Ignore if already deleted + try { + logger.info(`Archive created: ${archiveName} (${archive.pointer()} bytes)`); + + // Update database + await db('events').where('id', event.id).update({ + is_archived: true, + archive_path: path.relative(getStoragePath(), archivePath), + archived_at: new Date() + }); + + // Delete original files + await fs.rm(eventPath, { recursive: true }); + + // Delete thumbnails + const photos = await db('photos').where('event_id', event.id); + for (const photo of photos) { + if (photo.thumbnail_path) { + const thumbPath = path.join(getStoragePath(), photo.thumbnail_path); + await fs.unlink(thumbPath).catch(() => {}); // Ignore if already deleted + } } + + // Queue completion email — admin_email is nullable on events (migration 073); + // skip queueing rather than violating email_queue.recipient_email NOT NULL. + if (event.admin_email) { + await queueEmail(event.id, event.admin_email, 'archive_complete', { + event_name: event.event_name, + archive_size: (archive.pointer() / 1024 / 1024).toFixed(2) + ' MB' + }); + } else { + logger.info(`Skipping archive_complete email for event ${event.slug}: no admin_email set`); + } + } catch (err) { + // Never let the close handler reject — it runs detached from the caller, + // and an unhandled rejection here crashes the backend process. + logger.error(`Post-archive cleanup failed for event ${event.slug}:`, err); } - - // Queue completion email - await queueEmail(event.id, event.admin_email, 'archive_complete', { - event_name: event.event_name, - archive_size: (archive.pointer() / 1024 / 1024).toFixed(2) + ' MB' - }); }); archive.pipe(output);