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.
135 lines
5.0 KiB
JavaScript
135 lines
5.0 KiB
JavaScript
const archiver = require('archiver');
|
|
const fs = require('fs').promises;
|
|
const path = require('path');
|
|
const { db } = require('../database/db');
|
|
const { queueEmail } = require('./emailProcessor');
|
|
const logger = require('../utils/logger');
|
|
const feedbackService = require('./feedbackService');
|
|
|
|
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
|
const ACTIVE_PATH = () => path.join(getStoragePath(), 'events/active');
|
|
const ARCHIVE_PATH = () => path.join(getStoragePath(), 'events/archived');
|
|
|
|
async function archiveEvent(event) {
|
|
try {
|
|
const eventPath = path.join(ACTIVE_PATH(), event.slug);
|
|
const archiveName = `${event.slug}.zip`;
|
|
const archivePath = path.join(ARCHIVE_PATH(), archiveName);
|
|
|
|
// Ensure archive directory exists
|
|
await fs.mkdir(ARCHIVE_PATH(), { recursive: true });
|
|
|
|
// Create archive
|
|
const output = require('fs').createWriteStream(archivePath);
|
|
const archive = archiver('zip', {
|
|
zlib: { level: 9 } // Maximum compression
|
|
});
|
|
|
|
archive.on('error', (err) => {
|
|
throw err;
|
|
});
|
|
|
|
// Export feedback data before archiving
|
|
const feedbackSettings = await feedbackService.getEventFeedbackSettings(event.id);
|
|
if (feedbackSettings.feedback_enabled) {
|
|
try {
|
|
logger.info(`Exporting feedback data for event ${event.slug}`);
|
|
const feedbackData = await feedbackService.exportEventFeedback(event.id);
|
|
|
|
if (feedbackData && feedbackData.length > 0) {
|
|
// Create feedback JSON file
|
|
const feedbackJson = JSON.stringify(feedbackData, null, 2);
|
|
const feedbackJsonPath = path.join(eventPath, 'feedback_data.json');
|
|
await fs.writeFile(feedbackJsonPath, feedbackJson, 'utf8');
|
|
|
|
// Create feedback CSV file
|
|
const feedbackCsv = convertToCSV(feedbackData);
|
|
const feedbackCsvPath = path.join(eventPath, 'feedback_data.csv');
|
|
await fs.writeFile(feedbackCsvPath, feedbackCsv, 'utf8');
|
|
|
|
// Create feedback summary
|
|
const summary = await feedbackService.getEventFeedbackSummary(event.id);
|
|
const summaryPath = path.join(eventPath, 'feedback_summary.json');
|
|
await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2), 'utf8');
|
|
|
|
logger.info(`Feedback data exported: ${feedbackData.length} entries`);
|
|
}
|
|
} catch (error) {
|
|
logger.error(`Error exporting feedback for event ${event.slug}:`, error);
|
|
// Continue with archiving even if feedback export fails
|
|
}
|
|
}
|
|
|
|
output.on('close', async () => {
|
|
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);
|
|
}
|
|
});
|
|
|
|
archive.pipe(output);
|
|
archive.directory(eventPath, false);
|
|
await archive.finalize();
|
|
|
|
} catch (error) {
|
|
logger.error(`Error archiving event ${event.slug}:`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Helper function to convert JSON to CSV
|
|
function convertToCSV(data) {
|
|
if (!data || data.length === 0) return '';
|
|
|
|
const headers = Object.keys(data[0]);
|
|
const csvHeaders = headers.join(',');
|
|
|
|
const csvRows = data.map(row => {
|
|
return headers.map(header => {
|
|
const value = row[header];
|
|
// Escape quotes and wrap in quotes if contains comma
|
|
if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) {
|
|
return `"${value.replace(/"/g, '""')}"`;
|
|
}
|
|
return value || '';
|
|
}).join(',');
|
|
});
|
|
|
|
return [csvHeaders, ...csvRows].join('\n');
|
|
}
|
|
|
|
module.exports = { archiveEvent };
|