diff --git a/backend/src/middleware/secureImageMiddleware.js b/backend/src/middleware/secureImageMiddleware.js index d881c1be..3ae5807a 100644 --- a/backend/src/middleware/secureImageMiddleware.js +++ b/backend/src/middleware/secureImageMiddleware.js @@ -265,7 +265,7 @@ class SecureImageMiddleware { 'X-Frame-Options': 'DENY', 'X-XSS-Protection': '1; mode=block', 'Referrer-Policy': 'strict-origin-when-cross-origin', - 'Content-Security-Policy': "default-src 'none'; img-src 'self'", + 'Content-Security-Policy': 'default-src \'none\'; img-src \'self\'', // Custom security headers 'X-Protected-Content': 'true', diff --git a/backend/src/middleware/secureStatic.js b/backend/src/middleware/secureStatic.js index 88361dd3..f562eed5 100644 --- a/backend/src/middleware/secureStatic.js +++ b/backend/src/middleware/secureStatic.js @@ -44,7 +44,7 @@ function secureStatic(basePath, options = {}) { // `default-src 'none'` already implies script-src 'none'; // style-src + img-src(data:) keep normal SVG rendering working. if (/\.svg$/i.test(filePath)) { - resp.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:"); + resp.setHeader('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\'; img-src \'self\' data:'); resp.setHeader('X-Content-Type-Options', 'nosniff'); } } diff --git a/backend/src/routes/adminBackup.js b/backend/src/routes/adminBackup.js index 5cf39f18..0c1174a0 100644 --- a/backend/src/routes/adminBackup.js +++ b/backend/src/routes/adminBackup.js @@ -44,22 +44,22 @@ router.put('/config', adminAuth, requirePermission('backup.create'), async (req, // Validate required fields based on destination type if (updates.backup_destination_type) { switch (updates.backup_destination_type) { - case 'local': - if (!updates.backup_destination_path) { - return res.status(400).json({ error: 'Local backup requires destination path' }); - } - break; - case 'rsync': - if (!updates.backup_rsync_host || !updates.backup_rsync_path) { - return res.status(400).json({ error: 'Rsync backup requires host and path' }); - } - break; - case 's3': - if (!updates.backup_s3_endpoint || !updates.backup_s3_bucket || + case 'local': + if (!updates.backup_destination_path) { + return res.status(400).json({ error: 'Local backup requires destination path' }); + } + break; + case 'rsync': + if (!updates.backup_rsync_host || !updates.backup_rsync_path) { + return res.status(400).json({ error: 'Rsync backup requires host and path' }); + } + break; + case 's3': + if (!updates.backup_s3_endpoint || !updates.backup_s3_bucket || !updates.backup_s3_access_key || !updates.backup_s3_secret_key) { - return res.status(400).json({ error: 'S3 backup requires endpoint, bucket, and credentials' }); - } - break; + return res.status(400).json({ error: 'S3 backup requires endpoint, bucket, and credentials' }); + } + break; } } @@ -210,125 +210,125 @@ router.post('/test-connection', adminAuth, requirePermission('backup.create'), a const { destination_type, ...config } = req.body; switch (destination_type) { - case 'local': - // Test local path access - const fs = require('fs').promises; - try { - await fs.access(config.path, fs.constants.W_OK); - res.json({ success: true, message: 'Local path is writable' }); - } catch (error) { - logger.warn('Local backup path not writable', { - path: config.path, - error: error.message - }); - res.json({ success: false, message: 'Cannot write to local path. Check server logs for details.' }); - } - break; + case 'local': + // Test local path access + const fs = require('fs').promises; + try { + await fs.access(config.path, fs.constants.W_OK); + res.json({ success: true, message: 'Local path is writable' }); + } catch (error) { + logger.warn('Local backup path not writable', { + path: config.path, + error: error.message + }); + res.json({ success: false, message: 'Cannot write to local path. Check server logs for details.' }); + } + break; - case 'rsync': - // Test rsync connection using spawn with argument arrays to prevent command injection - const { spawn } = require('child_process'); + case 'rsync': + // Test rsync connection using spawn with argument arrays to prevent command injection + const { spawn } = require('child_process'); - // Validate and sanitize inputs to prevent command injection - const sanitizeInput = (input) => { - if (!input || typeof input !== 'string') return null; - // Remove any shell metacharacters and limit length - return input.replace(/[;&|`$(){}[\]<>\\!#*?"'\n\r]/g, '').substring(0, 255); - }; + // Validate and sanitize inputs to prevent command injection + const sanitizeInput = (input) => { + if (!input || typeof input !== 'string') return null; + // Remove any shell metacharacters and limit length + return input.replace(/[;&|`$(){}[\]<>\\!#*?"'\n\r]/g, '').substring(0, 255); + }; - const host = sanitizeInput(config.host); - const user = sanitizeInput(config.user); - const sshKeyPath = sanitizeInput(config.ssh_key); + const host = sanitizeInput(config.host); + const user = sanitizeInput(config.user); + const sshKeyPath = sanitizeInput(config.ssh_key); - if (!host) { - res.json({ success: false, message: 'Invalid host specified' }); + if (!host) { + res.json({ success: false, message: 'Invalid host specified' }); + break; + } + + // Validate host format (hostname or IP only) + const hostRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/; + const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/; + if (!hostRegex.test(host) && !ipRegex.test(host)) { + res.json({ success: false, message: 'Invalid host format' }); + break; + } + + // SSRF protection: block connections to private/internal addresses + const { isPrivateIP } = require('../utils/networkValidation'); + if (isPrivateIP(host)) { + res.json({ success: false, message: 'Host cannot be a private or internal network address' }); + break; + } + + // Validate username format if provided + if (user && !/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(user)) { + res.json({ success: false, message: 'Invalid username format' }); + break; + } + + // Build SSH arguments as array (safe from injection) + const sshArgs = []; + if (sshKeyPath) { + // Validate SSH key path exists and is a file + const fsSync = require('fs'); + if (!fsSync.existsSync(sshKeyPath) || !fsSync.statSync(sshKeyPath).isFile()) { + res.json({ success: false, message: 'SSH key file not found' }); break; } + sshArgs.push('-i', sshKeyPath); + } + sshArgs.push('-o', 'StrictHostKeyChecking=no'); + sshArgs.push('-o', 'ConnectTimeout=10'); + sshArgs.push('-o', 'BatchMode=yes'); - // Validate host format (hostname or IP only) - const hostRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/; - const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/; - if (!hostRegex.test(host) && !ipRegex.test(host)) { - res.json({ success: false, message: 'Invalid host format' }); - break; - } + // Add target (user@host or just host) + const target = user ? `${user}@${host}` : host; + sshArgs.push(target); + sshArgs.push('echo', 'Connection successful'); - // SSRF protection: block connections to private/internal addresses - const { isPrivateIP } = require('../utils/networkValidation'); - if (isPrivateIP(host)) { - res.json({ success: false, message: 'Host cannot be a private or internal network address' }); - break; - } - - // Validate username format if provided - if (user && !/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(user)) { - res.json({ success: false, message: 'Invalid username format' }); - break; - } - - // Build SSH arguments as array (safe from injection) - const sshArgs = []; - if (sshKeyPath) { - // Validate SSH key path exists and is a file - const fsSync = require('fs'); - if (!fsSync.existsSync(sshKeyPath) || !fsSync.statSync(sshKeyPath).isFile()) { - res.json({ success: false, message: 'SSH key file not found' }); - break; - } - sshArgs.push('-i', sshKeyPath); - } - sshArgs.push('-o', 'StrictHostKeyChecking=no'); - sshArgs.push('-o', 'ConnectTimeout=10'); - sshArgs.push('-o', 'BatchMode=yes'); - - // Add target (user@host or just host) - const target = user ? `${user}@${host}` : host; - sshArgs.push(target); - sshArgs.push('echo', 'Connection successful'); - - try { - const result = await new Promise((resolve, reject) => { - const sshProcess = spawn('ssh', sshArgs, { - timeout: 15000, - stdio: ['ignore', 'pipe', 'pipe'] - }); - - let stdout = ''; - let stderr = ''; - - sshProcess.stdout.on('data', (data) => { stdout += data; }); - sshProcess.stderr.on('data', (data) => { stderr += data; }); - - sshProcess.on('close', (code) => { - if (code === 0) { - resolve({ success: true, stdout }); - } else { - reject(new Error(stderr || `SSH exited with code ${code}`)); - } - }); - - sshProcess.on('error', (err) => { - reject(err); - }); + try { + const result = await new Promise((resolve, reject) => { + const sshProcess = spawn('ssh', sshArgs, { + timeout: 15000, + stdio: ['ignore', 'pipe', 'pipe'] }); - res.json({ success: true, message: 'Rsync connection successful' }); - } catch (error) { - logger.warn('Rsync connection test failed', { - destination: host, - error: error.message + let stdout = ''; + let stderr = ''; + + sshProcess.stdout.on('data', (data) => { stdout += data; }); + sshProcess.stderr.on('data', (data) => { stderr += data; }); + + sshProcess.on('close', (code) => { + if (code === 0) { + resolve({ success: true, stdout }); + } else { + reject(new Error(stderr || `SSH exited with code ${code}`)); + } }); - res.json({ success: false, message: 'Rsync connection failed. Check server logs for details.' }); - } - break; + + sshProcess.on('error', (err) => { + reject(err); + }); + }); + + res.json({ success: true, message: 'Rsync connection successful' }); + } catch (error) { + logger.warn('Rsync connection test failed', { + destination: host, + error: error.message + }); + res.json({ success: false, message: 'Rsync connection failed. Check server logs for details.' }); + } + break; - case 's3': - // Test S3 connection (would need AWS SDK) - res.json({ success: false, message: 'S3 testing not implemented yet' }); - break; + case 's3': + // Test S3 connection (would need AWS SDK) + res.json({ success: false, message: 'S3 testing not implemented yet' }); + break; - default: - res.status(400).json({ error: 'Invalid destination type' }); + default: + res.status(400).json({ error: 'Invalid destination type' }); } } catch (error) { errorResponse(res, error, 500, 'Failed to test connection'); @@ -691,65 +691,65 @@ router.get('/download/:backupId', adminAuth, requirePermission('backup.view'), a // Handle different backup types switch (config.backup_destination_type) { - case 'local': - // Stream local backup as zip - const backupPath = path.join(config.backup_destination_path, `backup-${backupRun.id}`); - const archive = archiver('zip', { zlib: { level: 9 } }); + case 'local': + // Stream local backup as zip + const backupPath = path.join(config.backup_destination_path, `backup-${backupRun.id}`); + const archive = archiver('zip', { zlib: { level: 9 } }); - res.attachment(`picpeak-backup-${backupRun.id}.zip`); - archive.pipe(res); + res.attachment(`picpeak-backup-${backupRun.id}.zip`); + archive.pipe(res); - // Add backup directory contents - archive.directory(backupPath, false); + // Add backup directory contents + archive.directory(backupPath, false); - // Add manifest if exists - if (backupRun.manifest_path && await fs.access(backupRun.manifest_path).then(() => true).catch(() => false)) { - archive.file(backupRun.manifest_path, { name: 'manifest.json' }); - } + // Add manifest if exists + if (backupRun.manifest_path && await fs.access(backupRun.manifest_path).then(() => true).catch(() => false)) { + archive.file(backupRun.manifest_path, { name: 'manifest.json' }); + } - await archive.finalize(); - break; + await archive.finalize(); + break; - case 's3': - // For S3, provide pre-signed URLs or stream files - const s3Adapter = new S3StorageAdapter({ - endpoint: config.backup_s3_endpoint, - bucket: config.backup_s3_bucket, - accessKeyId: config.backup_s3_access_key, - secretAccessKey: config.backup_s3_secret_key, - region: config.backup_s3_region || 'us-east-1', - forcePathStyle: config.backup_s3_force_path_style || false + case 's3': + // For S3, provide pre-signed URLs or stream files + const s3Adapter = new S3StorageAdapter({ + endpoint: config.backup_s3_endpoint, + bucket: config.backup_s3_bucket, + accessKeyId: config.backup_s3_access_key, + secretAccessKey: config.backup_s3_secret_key, + region: config.backup_s3_region || 'us-east-1', + forcePathStyle: config.backup_s3_force_path_style || false + }); + + // List all files for this backup + const prefix = `backups/${backupRun.id}/`; + const files = await s3Adapter.list(prefix, { maxKeys: 1000 }); + + // Generate pre-signed URLs + const urls = []; + for (const file of files.objects || []) { + const url = await s3Adapter.getSignedUrl('getObject', file.key, { expiresIn: 3600 }); // 1 hour + urls.push({ + key: file.key, + size: file.size, + url: url }); + } - // List all files for this backup - const prefix = `backups/${backupRun.id}/`; - const files = await s3Adapter.list(prefix, { maxKeys: 1000 }); + res.json({ + backupId: backupRun.id, + type: 's3', + files: urls, + expiresIn: 3600, + message: 'Use the provided URLs to download individual files' + }); + break; - // Generate pre-signed URLs - const urls = []; - for (const file of files.objects || []) { - const url = await s3Adapter.getSignedUrl('getObject', file.key, { expiresIn: 3600 }); // 1 hour - urls.push({ - key: file.key, - size: file.size, - url: url - }); - } + case 'rsync': + return res.status(400).json({ error: 'Direct download not available for rsync backups' }); - res.json({ - backupId: backupRun.id, - type: 's3', - files: urls, - expiresIn: 3600, - message: 'Use the provided URLs to download individual files' - }); - break; - - case 'rsync': - return res.status(400).json({ error: 'Direct download not available for rsync backups' }); - - default: - return res.status(400).json({ error: 'Unknown backup type' }); + default: + return res.status(400).json({ error: 'Unknown backup type' }); } } catch (error) { errorResponse(res, error, 500, 'Failed to download backup'); diff --git a/backend/src/routes/adminEvents/archiveBulk.js b/backend/src/routes/adminEvents/archiveBulk.js index 01168464..23655e70 100644 --- a/backend/src/routes/adminEvents/archiveBulk.js +++ b/backend/src/routes/adminEvents/archiveBulk.js @@ -32,165 +32,165 @@ const BULK_DELETE_MAX = 100; module.exports = (router) => { -// Archive event -router.post('/:id/archive', adminAuth, requirePermission('events.archive'), requireEventOwnership, async (req, res) => { - try { - const { id } = req.params; + // Archive event + router.post('/:id/archive', adminAuth, requirePermission('events.archive'), requireEventOwnership, async (req, res) => { + try { + const { id } = req.params; - const event = await db('events').where('id', id).first(); - if (!event) { - return res.status(404).json({ error: 'Event not found' }); + const event = await db('events').where('id', id).first(); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + if (event.is_archived) { + return res.status(400).json({ error: 'Event is already archived' }); + } + + // Use the archive service to create ZIP archive + await archiveEvent(event); + + // Log activity + await logActivity('event_archived', + { eventName: event.event_name }, + id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ message: 'Event archived successfully' }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to archive event'); } + }); - if (event.is_archived) { - return res.status(400).json({ error: 'Event is already archived' }); - } + // Bulk archive events + router.post('/bulk-archive', adminAuth, requirePermission('events.archive'), [ + body('eventIds').isArray().withMessage('eventIds must be an array'), + body('eventIds.*').isInt().withMessage('Each eventId must be an integer') + ], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } - // Use the archive service to create ZIP archive - await archiveEvent(event); - - // Log activity - await logActivity('event_archived', - { eventName: event.event_name }, - id, - { type: 'admin', id: req.admin.id, name: req.admin.username } - ); - - res.json({ message: 'Event archived successfully' }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to archive event'); - } -}); - -// Bulk archive events -router.post('/bulk-archive', adminAuth, requirePermission('events.archive'), [ - body('eventIds').isArray().withMessage('eventIds must be an array'), - body('eventIds.*').isInt().withMessage('Each eventId must be an integer') -], async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } - - const { eventIds } = req.body; + const { eventIds } = req.body; - if (eventIds.length === 0) { - return res.status(400).json({ error: 'No events selected for archiving' }); - } + if (eventIds.length === 0) { + return res.status(400).json({ error: 'No events selected for archiving' }); + } - // Get all events to archive - const events = await db('events') - .whereIn('id', eventIds) - .where('is_archived', formatBoolean(false)); + // Get all events to archive + const events = await db('events') + .whereIn('id', eventIds) + .where('is_archived', formatBoolean(false)); - if (events.length === 0) { - return res.status(400).json({ error: 'No valid events found to archive' }); - } + if (events.length === 0) { + return res.status(400).json({ error: 'No valid events found to archive' }); + } - const results = { - successful: [], - failed: [] - }; + const results = { + successful: [], + failed: [] + }; - // Process each event - for (const event of events) { - try { + // Process each event + for (const event of events) { + try { // Use the archive service to create ZIP archive - await archiveEvent(event); + await archiveEvent(event); - // Log activity - await logActivity('event_archived', - { eventName: event.event_name, bulkOperation: true }, - event.id, - { type: 'admin', id: req.admin.id, name: req.admin.username } - ); + // Log activity + await logActivity('event_archived', + { eventName: event.event_name, bulkOperation: true }, + event.id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); - results.successful.push({ - id: event.id, - name: event.event_name - }); - } catch (error) { - logger.error(`Failed to archive event ${event.id}:`, error); - results.failed.push({ - id: event.id, - name: event.event_name, - error: 'Failed to archive event. Check server logs for details.' - }); + results.successful.push({ + id: event.id, + name: event.event_name + }); + } catch (error) { + logger.error(`Failed to archive event ${event.id}:`, error); + results.failed.push({ + id: event.id, + name: event.event_name, + error: 'Failed to archive event. Check server logs for details.' + }); + } } + + // Log bulk archive activity + await logActivity('bulk_archive_completed', + { + totalEvents: eventIds.length, + successfulCount: results.successful.length, + failedCount: results.failed.length + }, + null, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ + message: `Bulk archive completed: ${results.successful.length} succeeded, ${results.failed.length} failed`, + results + }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to perform bulk archive'); } - - // Log bulk archive activity - await logActivity('bulk_archive_completed', - { - totalEvents: eventIds.length, - successfulCount: results.successful.length, - failedCount: results.failed.length - }, - null, - { type: 'admin', id: req.admin.id, name: req.admin.username } - ); - - res.json({ - message: `Bulk archive completed: ${results.successful.length} succeeded, ${results.failed.length} failed`, - results - }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to perform bulk archive'); - } -}); -router.post('/bulk-delete', adminAuth, requirePermission('events.delete'), [ - body('eventIds').isArray({ min: 1, max: BULK_DELETE_MAX }).withMessage(`eventIds must be an array of 1-${BULK_DELETE_MAX} ids`), - body('eventIds.*').isInt().withMessage('Each eventId must be an integer') -], async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } - - const { eventIds } = req.body; - - // Editor-role events.delete permission is already gated by the route - // middleware. We do NOT additionally filter to created_by here because - // the per-event delete-cascade is global (matches DELETE /:id which - // also has no role-based filter — that's why events.delete is a - // sensitive permission). - - const results = { successful: [], failed: [] }; - const adminContext = { id: req.admin.id, username: req.admin.username }; - - for (const eventId of eventIds) { - try { - const deleted = await deleteEventCascade(eventId, adminContext); - results.successful.push(deleted); - } catch (err) { - results.failed.push({ - id: eventId, - name: null, - error: err.code === 'EVENT_NOT_FOUND' ? 'Event not found' : 'Failed to delete event' - }); - logger.warn('Bulk-delete: per-event failure', { eventId, error: err.message }); + }); + router.post('/bulk-delete', adminAuth, requirePermission('events.delete'), [ + body('eventIds').isArray({ min: 1, max: BULK_DELETE_MAX }).withMessage(`eventIds must be an array of 1-${BULK_DELETE_MAX} ids`), + body('eventIds.*').isInt().withMessage('Each eventId must be an integer') + ], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); } + + const { eventIds } = req.body; + + // Editor-role events.delete permission is already gated by the route + // middleware. We do NOT additionally filter to created_by here because + // the per-event delete-cascade is global (matches DELETE /:id which + // also has no role-based filter — that's why events.delete is a + // sensitive permission). + + const results = { successful: [], failed: [] }; + const adminContext = { id: req.admin.id, username: req.admin.username }; + + for (const eventId of eventIds) { + try { + const deleted = await deleteEventCascade(eventId, adminContext); + results.successful.push(deleted); + } catch (err) { + results.failed.push({ + id: eventId, + name: null, + error: err.code === 'EVENT_NOT_FOUND' ? 'Event not found' : 'Failed to delete event' + }); + logger.warn('Bulk-delete: per-event failure', { eventId, error: err.message }); + } + } + + await logActivity('bulk_delete_completed', + { + totalEvents: eventIds.length, + successfulCount: results.successful.length, + failedCount: results.failed.length + }, + null, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ + message: `Bulk delete completed: ${results.successful.length} succeeded, ${results.failed.length} failed`, + results + }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to perform bulk delete'); } - - await logActivity('bulk_delete_completed', - { - totalEvents: eventIds.length, - successfulCount: results.successful.length, - failedCount: results.failed.length - }, - null, - { type: 'admin', id: req.admin.id, name: req.admin.username } - ); - - res.json({ - message: `Bulk delete completed: ${results.successful.length} succeeded, ${results.failed.length} failed`, - results - }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to perform bulk delete'); - } -}); + }); }; diff --git a/backend/src/routes/adminEvents/crud.js b/backend/src/routes/adminEvents/crud.js index 6b9e5599..c7f3264b 100644 --- a/backend/src/routes/adminEvents/crud.js +++ b/backend/src/routes/adminEvents/crud.js @@ -31,771 +31,771 @@ const { validateHeroImageAnchor, getEventFieldRequirements, readBooleanSetting, module.exports = (router) => { -// Create new event -router.post('/', adminAuth, requirePermission('events.create'), [ - body('event_type').notEmpty().trim().custom(async (value) => { - const isValid = await eventTypeService.isValidEventType(value); - if (!isValid) { - throw new Error('Invalid event type'); - } - return true; - }), - body('event_name').notEmpty().trim(), - body('event_date').optional({ values: 'falsy' }).isDate(), - // Migration 137 — calendar time fields. - body('event_time_start').optional({ values: 'falsy' }).matches(/^([01]\d|2[0-3]):[0-5]\d$/) - .withMessage('event_time_start must be HH:MM 24h'), - body('event_time_end').optional({ values: 'falsy' }).matches(/^([01]\d|2[0-3]):[0-5]\d$/) - .withMessage('event_time_end must be HH:MM 24h'), - body('is_full_day').optional().isBoolean().toBoolean(), - body('customer_name').optional().trim(), - body('customer_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL), - body('customer_phone').optional({ nullable: true, checkFalsy: true }) - .isString().trim() - .isLength({ max: 32 }).withMessage('Phone number must be at most 32 characters'), - body('admin_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL), - body('require_password').optional().isBoolean(), - body('password').optional().isString().custom((value, { req }) => { - const input = req.body.require_password; - const normalizeBoolean = (val, defaultValue = true) => { - if (val === undefined || val === null) return defaultValue; - if (typeof val === 'boolean') return val; - if (typeof val === 'number') return val !== 0; - if (typeof val === 'string') { - const normalized = val.trim().toLowerCase(); - if (['false', '0', 'no', 'off'].includes(normalized)) return false; - if (['true', '1', 'yes', 'on'].includes(normalized)) return true; + // Create new event + router.post('/', adminAuth, requirePermission('events.create'), [ + body('event_type').notEmpty().trim().custom(async (value) => { + const isValid = await eventTypeService.isValidEventType(value); + if (!isValid) { + throw new Error('Invalid event type'); } - return defaultValue; - }; - - const requirePassword = normalizeBoolean(input, true); - if (!requirePassword) { return true; - } - if (typeof value !== 'string' || value.trim().length < 6) { - throw new Error('Password must be at least 6 characters long'); - } - return true; - }), - body('expiration_days').isInt({ min: 1, max: 365 }).optional(), - body('welcome_message').optional().trim(), - body('color_theme').optional().trim(), - body('allow_user_uploads').optional().isBoolean().toBoolean(), - body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(), - body('allow_downloads').optional().isBoolean(), - body('disable_right_click').optional().isBoolean(), - body('enable_devtools_protection').optional().isBoolean(), - body('watermark_downloads').optional().isBoolean(), - body('watermark_text').optional().trim(), - // #328 follow-up: per-event opt-in for presigned-URL "Download All". - // Bypasses watermarks; admin must enable knowingly. - body('allow_presigned_download').optional().isBoolean(), - body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(), - // Hero logo settings - body('hero_logo_visible').optional().isBoolean(), - body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']), - body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']), - // Header style settings (decoupled from layout) - body('header_style').optional().isIn(['hero', 'standard', 'banner', 'minimal', 'none']), - body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']), - // Hero image anchor position (#162) – accepts legacy keywords or "X% Y%" focal point - body('hero_image_anchor').optional().custom(validateHeroImageAnchor), - // Client access settings (#172) - body('client_access_enabled').optional().isBoolean(), - body('client_password').optional().isString(), - body('default_photo_sort').optional().isIn([ - 'upload_date_desc', 'upload_date_asc', - 'capture_date_desc', 'capture_date_asc', - 'filename_asc', 'filename_desc' - ]), - // Per-event promotional override (#440). Three-way mode: - // inherit → fall back to global branding_promo_markdown - // custom → render this event's promo_markdown verbatim - // off → suppress entirely for this event - body('promo_mode').optional().isIn(['inherit', 'custom', 'off']), - body('promo_markdown').optional({ nullable: true }).isString(), - // Per-event opt-in for using hero photo as the social-share preview - // image (#474). When false (default), galleryOgService falls back to - // the brand logo for og:image / Twitter Card. - body('og_image_share_enabled').optional().isBoolean(), - // Customer accounts assigned to this event (#354). Optional array of - // customer_accounts.id — many-to-many via event_customer_assignments. - body('customer_account_ids').optional().isArray(), - body('customer_account_ids.*').optional().isInt({ min: 1 }) -], async (req, res) => { - try { - logger.debug('Create event request body', { body: req.body }); - const errors = validationResult(req); - if (!errors.isEmpty()) { - logger.error('Validation errors:', errors.array()); - return res.status(400).json({ errors: errors.array() }); - } - - // Get field requirements from settings - const fieldRequirements = await getEventFieldRequirements(); - - const { - event_type, - event_name, - event_date, - // Migration 137 — calendar time fields. is_full_day defaults to - // true at the service layer when undefined (legacy form payloads). - event_time_start, - event_time_end, - is_full_day, - admin_email, - password, - welcome_message = '', - color_theme = null, - expiration_days = 30, - allow_user_uploads = false, - upload_category_id = null, - allow_downloads = true, - disable_right_click = false, - enable_devtools_protection: enableDevtoolsProtectionInput, - watermark_downloads = false, - watermark_text = null, - allow_presigned_download = false, - require_password: requirePasswordInput, - // Feedback settings - feedback_enabled: feedbackEnabledInput, - allow_ratings = true, - allow_likes = true, - allow_comments = true, - allow_favorites = true, - require_name_email = false, - moderate_comments = true, - show_feedback_to_guests = true, - // CSS Template - css_template_id = null, - // Hero logo settings - hero_logo_visible = true, - hero_logo_size = 'medium', - hero_logo_position = 'top', - // Header style settings - header_style = 'standard', - hero_divider_style = 'wave', - // Hero image anchor position (#162) - hero_image_anchor = 'center', - // Photo cap - photo_cap = null, - // Client access settings (#172) - client_access_enabled = false, - client_password = null, - // Draft mode - is_draft = true, - // Default photo sort - default_photo_sort = 'upload_date_desc' - } = req.body; - - const customerName = getCustomerNameFromPayload(req.body); - const customerEmail = getCustomerEmailFromPayload(req.body); - // Phone field is opt-in via the global setting (#322). If disabled, - // ignore whatever the client posted — defence in depth against form - // bypass. - const phoneEnabled = await isPhoneFieldEnabled(); - const customerPhone = phoneEnabled ? getCustomerPhoneFromPayload(req.body) : null; - - const customerColumnsAvailable = await hasCustomerContactColumns(); - - // Conditional validation based on settings - const validationErrors = []; - if (fieldRequirements.require_customer_name && !customerName) { - validationErrors.push({ path: 'customer_name', msg: 'Customer name is required' }); - } - if (fieldRequirements.require_customer_email && !customerEmail) { - validationErrors.push({ path: 'customer_email', msg: 'Customer email is required' }); - } - if (fieldRequirements.require_admin_email && !admin_email) { - validationErrors.push({ path: 'admin_email', msg: 'Admin email is required' }); - } - if (fieldRequirements.require_event_date && !event_date) { - validationErrors.push({ path: 'event_date', msg: 'Event date is required' }); - } - - if (validationErrors.length > 0) { - return res.status(400).json({ errors: validationErrors }); - } - - // Default require_password from global "event_default_require_password" - // setting when the body omits it (#317 — admins want to flip the default). - let requirePasswordFallback = true; - if (requirePasswordInput === undefined) { - const setting = await readBooleanSetting('event_default_require_password'); - if (setting !== undefined) requirePasswordFallback = setting; - } - const requirePassword = parseBooleanInput(requirePasswordInput, requirePasswordFallback); - - // Default feedback_enabled from global "event_default_feedback_enabled" - // setting when the body omits it (#520 — same pattern as require_password - // above, lets admins make Guest Feedback ON the out-of-box default for - // new events instead of toggling it on every time). - let feedbackEnabledFallback = false; - if (feedbackEnabledInput === undefined) { - const setting = await readBooleanSetting('event_default_feedback_enabled'); - if (setting !== undefined) feedbackEnabledFallback = setting; - } - const feedback_enabled = parseBooleanInput(feedbackEnabledInput, feedbackEnabledFallback); - - // Debug logging - logger.debug('Download control values', { - allow_downloads, - disable_right_click, - watermark_downloads, - watermark_text, - require_password: requirePassword, - types: { - allow_downloads: typeof allow_downloads, - disable_right_click: typeof disable_right_click, - watermark_downloads: typeof watermark_downloads - } - }); - - let passwordValidation = null; - - if (requirePassword) { - passwordValidation = await validatePasswordInContext(password, 'gallery', { - eventName: event_name - }); - - if (!passwordValidation.valid) { - return res.status(400).json({ - error: 'Password does not meet security requirements', - details: passwordValidation.errors, - score: passwordValidation.score, - feedback: passwordValidation.feedback - }); - } - } - - // Generate unique slug. Uses the shared util so accented names - // (Família, Decoração, etc.) get transliterated instead of dropped - // — see backend/src/utils/slug.js for the why (#525). - const processedEventName = slugify(event_name); - - // Use event_date in slug if provided, otherwise use random suffix - const slugSuffix = event_date || crypto.randomBytes(3).toString('hex'); - const baseSlug = `${event_type}-${processedEventName}-${slugSuffix}`; - let slug = baseSlug; - let counter = 1; - - while (await db('events').where({ slug }).first()) { - slug = `${baseSlug}-${counter}`; - counter++; - } - - // Generate share link respecting configured format - const shareToken = crypto.randomBytes(16).toString('hex'); - const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken }); - - // Hash password with configurable rounds (random placeholder when not required) - const password_hash = requirePassword - ? await bcrypt.hash(password, getBcryptRounds()) - : await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds()); - - // Calculate expiration date (days after event date) - // If expiration is not required, expires_at will be null (never expires) - // If event_date is not provided, use current date as base for expiration - let expires_at = null; - if (fieldRequirements.require_expiration) { - const baseDate = event_date || new Date().toISOString().split('T')[0]; - // Parse YYYY-MM-DD format as local date to avoid timezone issues - if (baseDate.match(/^\d{4}-\d{2}-\d{2}$/)) { - const [year, month, day] = baseDate.split('-').map(num => parseInt(num, 10)); - expires_at = new Date(year, month - 1, day); - } else { - expires_at = new Date(baseDate); - } - expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10)); - } - - // Create folder structure - const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../../storage'); - const eventPath = path.join(storagePath, 'events/active', slug); - await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true }); - await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true }); - - // Sync header_style / hero_divider_style from color_theme JSON when not - // explicitly provided in the request body (#158). - let effectiveHeaderStyle = header_style; - let effectiveDividerStyle = hero_divider_style; - if (color_theme && (!req.body.header_style || !req.body.hero_divider_style)) { - try { - if (typeof color_theme === 'string' && color_theme.startsWith('{')) { - const parsed = JSON.parse(color_theme); - if (!req.body.header_style && parsed.headerStyle) { - effectiveHeaderStyle = parsed.headerStyle; - } - if (!req.body.hero_divider_style && parsed.heroDividerStyle) { - effectiveDividerStyle = parsed.heroDividerStyle; - } + }), + body('event_name').notEmpty().trim(), + body('event_date').optional({ values: 'falsy' }).isDate(), + // Migration 137 — calendar time fields. + body('event_time_start').optional({ values: 'falsy' }).matches(/^([01]\d|2[0-3]):[0-5]\d$/) + .withMessage('event_time_start must be HH:MM 24h'), + body('event_time_end').optional({ values: 'falsy' }).matches(/^([01]\d|2[0-3]):[0-5]\d$/) + .withMessage('event_time_end must be HH:MM 24h'), + body('is_full_day').optional().isBoolean().toBoolean(), + body('customer_name').optional().trim(), + body('customer_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL), + body('customer_phone').optional({ nullable: true, checkFalsy: true }) + .isString().trim() + .isLength({ max: 32 }).withMessage('Phone number must be at most 32 characters'), + body('admin_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL), + body('require_password').optional().isBoolean(), + body('password').optional().isString().custom((value, { req }) => { + const input = req.body.require_password; + const normalizeBoolean = (val, defaultValue = true) => { + if (val === undefined || val === null) return defaultValue; + if (typeof val === 'boolean') return val; + if (typeof val === 'number') return val !== 0; + if (typeof val === 'string') { + const normalized = val.trim().toLowerCase(); + if (['false', '0', 'no', 'off'].includes(normalized)) return false; + if (['true', '1', 'yes', 'on'].includes(normalized)) return true; } - } catch (_) { - // color_theme is not JSON – nothing to extract + return defaultValue; + }; + + const requirePassword = normalizeBoolean(input, true); + if (!requirePassword) { + return true; + } + if (typeof value !== 'string' || value.trim().length < 6) { + throw new Error('Password must be at least 6 characters long'); + } + return true; + }), + body('expiration_days').isInt({ min: 1, max: 365 }).optional(), + body('welcome_message').optional().trim(), + body('color_theme').optional().trim(), + body('allow_user_uploads').optional().isBoolean().toBoolean(), + body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(), + body('allow_downloads').optional().isBoolean(), + body('disable_right_click').optional().isBoolean(), + body('enable_devtools_protection').optional().isBoolean(), + body('watermark_downloads').optional().isBoolean(), + body('watermark_text').optional().trim(), + // #328 follow-up: per-event opt-in for presigned-URL "Download All". + // Bypasses watermarks; admin must enable knowingly. + body('allow_presigned_download').optional().isBoolean(), + body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(), + // Hero logo settings + body('hero_logo_visible').optional().isBoolean(), + body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']), + body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']), + // Header style settings (decoupled from layout) + body('header_style').optional().isIn(['hero', 'standard', 'banner', 'minimal', 'none']), + body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']), + // Hero image anchor position (#162) – accepts legacy keywords or "X% Y%" focal point + body('hero_image_anchor').optional().custom(validateHeroImageAnchor), + // Client access settings (#172) + body('client_access_enabled').optional().isBoolean(), + body('client_password').optional().isString(), + body('default_photo_sort').optional().isIn([ + 'upload_date_desc', 'upload_date_asc', + 'capture_date_desc', 'capture_date_asc', + 'filename_asc', 'filename_desc' + ]), + // Per-event promotional override (#440). Three-way mode: + // inherit → fall back to global branding_promo_markdown + // custom → render this event's promo_markdown verbatim + // off → suppress entirely for this event + body('promo_mode').optional().isIn(['inherit', 'custom', 'off']), + body('promo_markdown').optional({ nullable: true }).isString(), + // Per-event opt-in for using hero photo as the social-share preview + // image (#474). When false (default), galleryOgService falls back to + // the brand logo for og:image / Twitter Card. + body('og_image_share_enabled').optional().isBoolean(), + // Customer accounts assigned to this event (#354). Optional array of + // customer_accounts.id — many-to-many via event_customer_assignments. + body('customer_account_ids').optional().isArray(), + body('customer_account_ids.*').optional().isInt({ min: 1 }) + ], async (req, res) => { + try { + logger.debug('Create event request body', { body: req.body }); + const errors = validationResult(req); + if (!errors.isEmpty()) { + logger.error('Validation errors:', errors.array()); + return res.status(400).json({ errors: errors.array() }); } - } - // Get branding defaults for hero logo settings (Feature 7: Branding Inheritance) - const brandingDefaults = await getBrandingDefaults(); - const effectiveHeroLogoVisible = req.body.hero_logo_visible !== undefined ? hero_logo_visible : brandingDefaults.hero_logo_visible; - const effectiveHeroLogoSize = req.body.hero_logo_size || brandingDefaults.hero_logo_size; - const effectiveHeroLogoPosition = req.body.hero_logo_position || brandingDefaults.hero_logo_position; + // Get field requirements from settings + const fieldRequirements = await getEventFieldRequirements(); - // Inherit "Detect dev tools" from the global Image Security setting unless - // the request explicitly overrides it (#317 — admin disabled it globally - // but new events still got it ON because the column default is true). - const protectionDefaults = await getDownloadProtectionDefaults(); - const effectiveEnableDevtoolsProtection = + const { + event_type, + event_name, + event_date, + // Migration 137 — calendar time fields. is_full_day defaults to + // true at the service layer when undefined (legacy form payloads). + event_time_start, + event_time_end, + is_full_day, + admin_email, + password, + welcome_message = '', + color_theme = null, + expiration_days = 30, + allow_user_uploads = false, + upload_category_id = null, + allow_downloads = true, + disable_right_click = false, + enable_devtools_protection: enableDevtoolsProtectionInput, + watermark_downloads = false, + watermark_text = null, + allow_presigned_download = false, + require_password: requirePasswordInput, + // Feedback settings + feedback_enabled: feedbackEnabledInput, + allow_ratings = true, + allow_likes = true, + allow_comments = true, + allow_favorites = true, + require_name_email = false, + moderate_comments = true, + show_feedback_to_guests = true, + // CSS Template + css_template_id = null, + // Hero logo settings + hero_logo_visible = true, + hero_logo_size = 'medium', + hero_logo_position = 'top', + // Header style settings + header_style = 'standard', + hero_divider_style = 'wave', + // Hero image anchor position (#162) + hero_image_anchor = 'center', + // Photo cap + photo_cap = null, + // Client access settings (#172) + client_access_enabled = false, + client_password = null, + // Draft mode + is_draft = true, + // Default photo sort + default_photo_sort = 'upload_date_desc' + } = req.body; + + const customerName = getCustomerNameFromPayload(req.body); + const customerEmail = getCustomerEmailFromPayload(req.body); + // Phone field is opt-in via the global setting (#322). If disabled, + // ignore whatever the client posted — defence in depth against form + // bypass. + const phoneEnabled = await isPhoneFieldEnabled(); + const customerPhone = phoneEnabled ? getCustomerPhoneFromPayload(req.body) : null; + + const customerColumnsAvailable = await hasCustomerContactColumns(); + + // Conditional validation based on settings + const validationErrors = []; + if (fieldRequirements.require_customer_name && !customerName) { + validationErrors.push({ path: 'customer_name', msg: 'Customer name is required' }); + } + if (fieldRequirements.require_customer_email && !customerEmail) { + validationErrors.push({ path: 'customer_email', msg: 'Customer email is required' }); + } + if (fieldRequirements.require_admin_email && !admin_email) { + validationErrors.push({ path: 'admin_email', msg: 'Admin email is required' }); + } + if (fieldRequirements.require_event_date && !event_date) { + validationErrors.push({ path: 'event_date', msg: 'Event date is required' }); + } + + if (validationErrors.length > 0) { + return res.status(400).json({ errors: validationErrors }); + } + + // Default require_password from global "event_default_require_password" + // setting when the body omits it (#317 — admins want to flip the default). + let requirePasswordFallback = true; + if (requirePasswordInput === undefined) { + const setting = await readBooleanSetting('event_default_require_password'); + if (setting !== undefined) requirePasswordFallback = setting; + } + const requirePassword = parseBooleanInput(requirePasswordInput, requirePasswordFallback); + + // Default feedback_enabled from global "event_default_feedback_enabled" + // setting when the body omits it (#520 — same pattern as require_password + // above, lets admins make Guest Feedback ON the out-of-box default for + // new events instead of toggling it on every time). + let feedbackEnabledFallback = false; + if (feedbackEnabledInput === undefined) { + const setting = await readBooleanSetting('event_default_feedback_enabled'); + if (setting !== undefined) feedbackEnabledFallback = setting; + } + const feedback_enabled = parseBooleanInput(feedbackEnabledInput, feedbackEnabledFallback); + + // Debug logging + logger.debug('Download control values', { + allow_downloads, + disable_right_click, + watermark_downloads, + watermark_text, + require_password: requirePassword, + types: { + allow_downloads: typeof allow_downloads, + disable_right_click: typeof disable_right_click, + watermark_downloads: typeof watermark_downloads + } + }); + + let passwordValidation = null; + + if (requirePassword) { + passwordValidation = await validatePasswordInContext(password, 'gallery', { + eventName: event_name + }); + + if (!passwordValidation.valid) { + return res.status(400).json({ + error: 'Password does not meet security requirements', + details: passwordValidation.errors, + score: passwordValidation.score, + feedback: passwordValidation.feedback + }); + } + } + + // Generate unique slug. Uses the shared util so accented names + // (Família, Decoração, etc.) get transliterated instead of dropped + // — see backend/src/utils/slug.js for the why (#525). + const processedEventName = slugify(event_name); + + // Use event_date in slug if provided, otherwise use random suffix + const slugSuffix = event_date || crypto.randomBytes(3).toString('hex'); + const baseSlug = `${event_type}-${processedEventName}-${slugSuffix}`; + let slug = baseSlug; + let counter = 1; + + while (await db('events').where({ slug }).first()) { + slug = `${baseSlug}-${counter}`; + counter++; + } + + // Generate share link respecting configured format + const shareToken = crypto.randomBytes(16).toString('hex'); + const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken }); + + // Hash password with configurable rounds (random placeholder when not required) + const password_hash = requirePassword + ? await bcrypt.hash(password, getBcryptRounds()) + : await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds()); + + // Calculate expiration date (days after event date) + // If expiration is not required, expires_at will be null (never expires) + // If event_date is not provided, use current date as base for expiration + let expires_at = null; + if (fieldRequirements.require_expiration) { + const baseDate = event_date || new Date().toISOString().split('T')[0]; + // Parse YYYY-MM-DD format as local date to avoid timezone issues + if (baseDate.match(/^\d{4}-\d{2}-\d{2}$/)) { + const [year, month, day] = baseDate.split('-').map(num => parseInt(num, 10)); + expires_at = new Date(year, month - 1, day); + } else { + expires_at = new Date(baseDate); + } + expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10)); + } + + // Create folder structure + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../../storage'); + const eventPath = path.join(storagePath, 'events/active', slug); + await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true }); + await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true }); + + // Sync header_style / hero_divider_style from color_theme JSON when not + // explicitly provided in the request body (#158). + let effectiveHeaderStyle = header_style; + let effectiveDividerStyle = hero_divider_style; + if (color_theme && (!req.body.header_style || !req.body.hero_divider_style)) { + try { + if (typeof color_theme === 'string' && color_theme.startsWith('{')) { + const parsed = JSON.parse(color_theme); + if (!req.body.header_style && parsed.headerStyle) { + effectiveHeaderStyle = parsed.headerStyle; + } + if (!req.body.hero_divider_style && parsed.heroDividerStyle) { + effectiveDividerStyle = parsed.heroDividerStyle; + } + } + } catch (_) { + // color_theme is not JSON – nothing to extract + } + } + + // Get branding defaults for hero logo settings (Feature 7: Branding Inheritance) + const brandingDefaults = await getBrandingDefaults(); + const effectiveHeroLogoVisible = req.body.hero_logo_visible !== undefined ? hero_logo_visible : brandingDefaults.hero_logo_visible; + const effectiveHeroLogoSize = req.body.hero_logo_size || brandingDefaults.hero_logo_size; + const effectiveHeroLogoPosition = req.body.hero_logo_position || brandingDefaults.hero_logo_position; + + // Inherit "Detect dev tools" from the global Image Security setting unless + // the request explicitly overrides it (#317 — admin disabled it globally + // but new events still got it ON because the column default is true). + const protectionDefaults = await getDownloadProtectionDefaults(); + const effectiveEnableDevtoolsProtection = enableDevtoolsProtectionInput !== undefined ? enableDevtoolsProtectionInput : protectionDefaults.enable_devtools_protection !== undefined ? protectionDefaults.enable_devtools_protection : true; - // Migration 137 — normalise calendar time triple. Throws AppError - // 400 when is_full_day=false but times are malformed/inverted. - const calendarTriple = normaliseEventTimeTriple({ - event_time_start, event_time_end, is_full_day, - }); - const calendarColumnsExist = await hasColumnCached('events', 'is_full_day'); + // Migration 137 — normalise calendar time triple. Throws AppError + // 400 when is_full_day=false but times are malformed/inverted. + const calendarTriple = normaliseEventTimeTriple({ + event_time_start, event_time_end, is_full_day, + }); + const calendarColumnsExist = await hasColumnCached('events', 'is_full_day'); - // Insert into database - // Seed the new event's Live Slideshow display style from the PICPEAK-WIDE - // preset (app_settings, Settings → Slideshow). New events inherit it and the - // admin can still override per event. Watermark is left NULL = inherit the - // global watermark; the share token is minted on demand, not seeded. Guarded - // so un-migrated installs (mid-branch) don't reference missing columns. - let slideshowSeed = {}; - if (await hasColumnCached('events', 'show_interval_ms')) { - try { - const intP = (v, min, max) => (Number.isFinite(+v) ? Math.min(max, Math.max(min, parseInt(v, 10))) : undefined); - const oneOf = (v, allowed) => (allowed.includes(v) ? v : undefined); - const i = intP(await getAppSetting('slideshow_interval_ms', undefined), 1000, 120000); - const tr = oneOf(await getAppSetting('slideshow_transition', undefined), SLIDESHOW_TRANSITIONS); - const tms = intP(await getAppSetting('slideshow_transition_ms', undefined), 100, 5000); - const cf = oneOf(await getAppSetting('slideshow_colorfilter', undefined), SLIDESHOW_COLORFILTERS); - if (i !== undefined) slideshowSeed.show_interval_ms = i; - if (tr) slideshowSeed.show_transition = tr; - if (tms !== undefined) slideshowSeed.show_transition_ms = tms; - if (cf) slideshowSeed.show_colorfilter = cf; - } catch (e) { - logger.warn('Failed to seed slideshow settings from global preset', { error: e.message }); - } - } - - const insertResult = await db('events').insert({ - slug, - event_type, - event_name, - ...slideshowSeed, - event_date: event_date || null, - ...(calendarColumnsExist ? { - event_time_start: calendarTriple.event_time_start, - event_time_end: calendarTriple.event_time_end, - is_full_day: formatBoolean(calendarTriple.is_full_day), - } : {}), - ...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}), - ...(customerPhone ? { customer_phone: customerPhone } : {}), - host_name: customerName || null, - host_email: customerEmail || null, - admin_email: admin_email || null, - password_hash, - welcome_message, - color_theme, - share_link: shareLinkToStore, - share_token: shareToken, - expires_at: expires_at ? expires_at.toISOString() : null, - created_at: new Date().toISOString(), - created_by: req.admin.id, - allow_user_uploads, - upload_category_id, - allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true), - disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false), - enable_devtools_protection: formatBoolean(effectiveEnableDevtoolsProtection), - watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false), - watermark_text, - allow_presigned_download: formatBoolean(allow_presigned_download === true || allow_presigned_download === 'true'), - require_password: formatBoolean(requirePassword), - css_template_id: css_template_id || null, - hero_logo_visible: formatBoolean(effectiveHeroLogoVisible), - hero_logo_size: effectiveHeroLogoSize, - hero_logo_position: effectiveHeroLogoPosition, - header_style: effectiveHeaderStyle || 'standard', - hero_divider_style: effectiveDividerStyle || 'wave', - hero_image_anchor: hero_image_anchor || 'center', - photo_cap: photo_cap || null, - is_draft: formatBoolean(parseBooleanInput(is_draft, true)), - default_photo_sort: default_photo_sort || 'upload_date_desc', - // Client access (#172) - client_access_enabled: formatBoolean(client_access_enabled), - ...(client_access_enabled && client_password ? { - client_password_hash: await bcrypt.hash(client_password, getBcryptRounds()), - client_share_token: crypto.randomBytes(32).toString('hex') - } : {}), - // Per-event opt-in for hero-photo OG share image (#474). Defaults - // false on create — admin opts in from the event detail page once - // they've picked a hero they're comfortable surfacing publicly. - og_image_share_enabled: formatBoolean(req.body.og_image_share_enabled === true), - }).returning('id'); - - // Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs) - const eventId = insertResult[0]?.id || insertResult[0]; - - // Apply customer-account assignments (#354). Skip when the customer - // portal flag is off — the frontend hides the picker in that case, - // but a stale tab could still POST customer_account_ids; we ignore - // them rather than 403 the entire create. - if (Array.isArray(req.body.customer_account_ids)) { - try { - const customerAccountsService = require('../../services/customerAccountsService'); - if (await customerAccountsService.isCustomerPortalEnabled()) { - await customerAccountsService.setAssignmentsForEvent( - eventId, - req.body.customer_account_ids, - req.admin.id - ); + // Insert into database + // Seed the new event's Live Slideshow display style from the PICPEAK-WIDE + // preset (app_settings, Settings → Slideshow). New events inherit it and the + // admin can still override per event. Watermark is left NULL = inherit the + // global watermark; the share token is minted on demand, not seeded. Guarded + // so un-migrated installs (mid-branch) don't reference missing columns. + let slideshowSeed = {}; + if (await hasColumnCached('events', 'show_interval_ms')) { + try { + const intP = (v, min, max) => (Number.isFinite(+v) ? Math.min(max, Math.max(min, parseInt(v, 10))) : undefined); + const oneOf = (v, allowed) => (allowed.includes(v) ? v : undefined); + const i = intP(await getAppSetting('slideshow_interval_ms', undefined), 1000, 120000); + const tr = oneOf(await getAppSetting('slideshow_transition', undefined), SLIDESHOW_TRANSITIONS); + const tms = intP(await getAppSetting('slideshow_transition_ms', undefined), 100, 5000); + const cf = oneOf(await getAppSetting('slideshow_colorfilter', undefined), SLIDESHOW_COLORFILTERS); + if (i !== undefined) slideshowSeed.show_interval_ms = i; + if (tr) slideshowSeed.show_transition = tr; + if (tms !== undefined) slideshowSeed.show_transition_ms = tms; + if (cf) slideshowSeed.show_colorfilter = cf; + } catch (e) { + logger.warn('Failed to seed slideshow settings from global preset', { error: e.message }); } - } catch (e) { - logger.error('Failed to set customer assignments on event create', { - eventId, error: e.message, - }); } - } - // Insert feedback settings if feedback is enabled - if (feedback_enabled) { - await db('event_feedback_settings').insert({ - event_id: eventId, - feedback_enabled: formatBoolean(feedback_enabled), - allow_ratings: formatBoolean(allow_ratings), - allow_likes: formatBoolean(allow_likes), - allow_comments: formatBoolean(allow_comments), - allow_favorites: formatBoolean(allow_favorites), - require_name_email: formatBoolean(require_name_email), - moderate_comments: formatBoolean(moderate_comments), - show_feedback_to_guests: formatBoolean(show_feedback_to_guests), - created_at: new Date().toISOString(), - updated_at: new Date().toISOString() - }); - } - - // Log activity - await logActivity('event_created', - { event_type, expires_at, require_password: requirePassword, password_strength: passwordValidation?.score }, - eventId, - { type: 'admin', id: req.admin.id, name: req.admin.username } - ); - - // Fire event.created webhook (#327). If the event is being published - // immediately (not a draft), event.published also fires below. - // Payload uses canonical event subject (#341) so receivers always see - // the same shape (id/slug/event_name + customer contact + share_*). - try { - const webhookService = require('../../services/webhookService'); - await webhookService.fire('event.created', { - event: { - ...webhookService.buildEventSubject({ - id: eventId, - slug, - event_name, - event_type, - event_date, - share_url: shareUrl, - share_token: shareToken, - customer_name: customerName, - customer_email: customerEmail, - customer_phone: customerPhone, - }), - is_draft: parseBooleanInput(is_draft, true), - }, - }); - } catch (e) { /* webhookService.fire never throws but be defensive */ } - - // Queue creation email (only if there is a recipient and event is not a draft) - // Language detection is handled by email processor - const isDraft = parseBooleanInput(is_draft, true); - - if (customerEmail && !isDraft) { - // Build email data with optional client access info - const emailData = { - customer_name: customerName, - customer_email: customerEmail, - host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null), + const insertResult = await db('events').insert({ + slug, + event_type, event_name, - event_date: event_date, // Pass raw date - will be formatted by email processor - gallery_link: shareUrl, - gallery_password: requirePassword ? password : 'No password required', - expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor - welcome_message: welcome_message || '' - }; + ...slideshowSeed, + event_date: event_date || null, + ...(calendarColumnsExist ? { + event_time_start: calendarTriple.event_time_start, + event_time_end: calendarTriple.event_time_end, + is_full_day: formatBoolean(calendarTriple.is_full_day), + } : {}), + ...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}), + ...(customerPhone ? { customer_phone: customerPhone } : {}), + host_name: customerName || null, + host_email: customerEmail || null, + admin_email: admin_email || null, + password_hash, + welcome_message, + color_theme, + share_link: shareLinkToStore, + share_token: shareToken, + expires_at: expires_at ? expires_at.toISOString() : null, + created_at: new Date().toISOString(), + created_by: req.admin.id, + allow_user_uploads, + upload_category_id, + allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true), + disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false), + enable_devtools_protection: formatBoolean(effectiveEnableDevtoolsProtection), + watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false), + watermark_text, + allow_presigned_download: formatBoolean(allow_presigned_download === true || allow_presigned_download === 'true'), + require_password: formatBoolean(requirePassword), + css_template_id: css_template_id || null, + hero_logo_visible: formatBoolean(effectiveHeroLogoVisible), + hero_logo_size: effectiveHeroLogoSize, + hero_logo_position: effectiveHeroLogoPosition, + header_style: effectiveHeaderStyle || 'standard', + hero_divider_style: effectiveDividerStyle || 'wave', + hero_image_anchor: hero_image_anchor || 'center', + photo_cap: photo_cap || null, + is_draft: formatBoolean(parseBooleanInput(is_draft, true)), + default_photo_sort: default_photo_sort || 'upload_date_desc', + // Client access (#172) + client_access_enabled: formatBoolean(client_access_enabled), + ...(client_access_enabled && client_password ? { + client_password_hash: await bcrypt.hash(client_password, getBcryptRounds()), + client_share_token: crypto.randomBytes(32).toString('hex') + } : {}), + // Per-event opt-in for hero-photo OG share image (#474). Defaults + // false on create — admin opts in from the event detail page once + // they've picked a hero they're comfortable surfacing publicly. + og_image_share_enabled: formatBoolean(req.body.og_image_share_enabled === true), + }).returning('id'); + + // Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs) + const eventId = insertResult[0]?.id || insertResult[0]; - // Include client access info in email when enabled (#172) - if (client_access_enabled && client_password) { - const createdEvent = await db('events').where('id', eventId).first(); - const frontendUrl = process.env.FRONTEND_URL || process.env.APP_URL || ''; - emailData.client_link = `${frontendUrl}/gallery/${slug}/client-access?token=${createdEvent.client_share_token}`; - emailData.client_password = client_password; - } - - await db('email_queue').insert({ - event_id: eventId, - recipient_email: customerEmail, - email_type: 'gallery_created', - email_data: JSON.stringify(emailData), - status: 'pending', - created_at: new Date() - // scheduled_at will use default value - }); - } - - // WhatsApp gallery_ready notification (#640D). Fires when the event is - // created NOT as a draft, the `whatsapp` flag is on, a config exists, and - // the customer supplied a phone number. Non-fatal: a queue failure should - // never block gallery creation. - if (!isDraft && customerPhone) { - try { - const { queueWhatsapp, getWhatsAppConfig } = require('../../services/whatsappProcessor'); - const waConfig = await getWhatsAppConfig(); - if (waConfig && waConfig.enabled) { - await queueWhatsapp(eventId, customerPhone, 'gallery_created', { - customer_name: customerName || '', - event_name, - gallery_link: shareUrl, - gallery_password: requirePassword ? password : '', - expiry_date: expires_at ? expires_at.toISOString() : null, - language: null, // resolved by processor via general_default_language + // Apply customer-account assignments (#354). Skip when the customer + // portal flag is off — the frontend hides the picker in that case, + // but a stale tab could still POST customer_account_ids; we ignore + // them rather than 403 the entire create. + if (Array.isArray(req.body.customer_account_ids)) { + try { + const customerAccountsService = require('../../services/customerAccountsService'); + if (await customerAccountsService.isCustomerPortalEnabled()) { + await customerAccountsService.setAssignmentsForEvent( + eventId, + req.body.customer_account_ids, + req.admin.id + ); + } + } catch (e) { + logger.error('Failed to set customer assignments on event create', { + eventId, error: e.message, }); } - } catch (waError) { - logger.warn('Failed to queue WhatsApp notification on create', { error: waError.message }); } - } - // Fire event.published when the event is created NOT as a draft. The - // separate /publish endpoint fires it for the draft → live transition; - // this covers the "create-and-publish in one shot" path. - if (!isDraft) { + // Insert feedback settings if feedback is enabled + if (feedback_enabled) { + await db('event_feedback_settings').insert({ + event_id: eventId, + feedback_enabled: formatBoolean(feedback_enabled), + allow_ratings: formatBoolean(allow_ratings), + allow_likes: formatBoolean(allow_likes), + allow_comments: formatBoolean(allow_comments), + allow_favorites: formatBoolean(allow_favorites), + require_name_email: formatBoolean(require_name_email), + moderate_comments: formatBoolean(moderate_comments), + show_feedback_to_guests: formatBoolean(show_feedback_to_guests), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString() + }); + } + + // Log activity + await logActivity('event_created', + { event_type, expires_at, require_password: requirePassword, password_strength: passwordValidation?.score }, + eventId, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + // Fire event.created webhook (#327). If the event is being published + // immediately (not a draft), event.published also fires below. + // Payload uses canonical event subject (#341) so receivers always see + // the same shape (id/slug/event_name + customer contact + share_*). try { const webhookService = require('../../services/webhookService'); - await webhookService.fire('event.published', { - event: webhookService.buildEventSubject({ - id: eventId, - slug, - event_name, - event_type, - event_date, - share_url: shareUrl, - share_token: shareToken, - customer_name: customerName, - customer_email: customerEmail, - customer_phone: customerPhone, - }), + await webhookService.fire('event.created', { + event: { + ...webhookService.buildEventSubject({ + id: eventId, + slug, + event_name, + event_type, + event_date, + share_url: shareUrl, + share_token: shareToken, + customer_name: customerName, + customer_email: customerEmail, + customer_phone: customerPhone, + }), + is_draft: parseBooleanInput(is_draft, true), + }, }); - } catch (e) { /* non-fatal */ } - } + } catch (e) { /* webhookService.fire never throws but be defensive */ } - res.json({ - id: eventId, - slug, - event_name, - event_type, - customer_name: customerName, - customer_email: customerEmail, - require_password: requirePassword, - photo_cap: photo_cap || null, - is_draft: isDraft, - share_link: shareUrl, - expires_at: expires_at ? expires_at.toISOString() : null, - created_at: new Date().toISOString() - }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to create event'); - } -}); + // Queue creation email (only if there is a recipient and event is not a draft) + // Language detection is handled by email processor + const isDraft = parseBooleanInput(is_draft, true); -// Get all events with pagination and filters -router.get('/', adminAuth, requirePermission('events.view'), async (req, res) => { - try { - const page = parseInt(req.query.page) || 1; - const limit = parseInt(req.query.limit) || 20; - const offset = (page - 1) * limit; - const search = req.query.search || ''; - const status = req.query.status || 'all'; - const allowedSortBy = ['created_at', 'event_name', 'slug', 'updated_at', 'expires_at', 'capture_date']; - const sortBy = allowedSortBy.includes(req.query.sortBy) ? req.query.sortBy : 'created_at'; - const sortOrder = ['asc', 'desc'].includes(req.query.sortOrder) ? req.query.sortOrder : 'desc'; + if (customerEmail && !isDraft) { + // Build email data with optional client access info + const emailData = { + customer_name: customerName, + customer_email: customerEmail, + host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null), + event_name, + event_date: event_date, // Pass raw date - will be formatted by email processor + gallery_link: shareUrl, + gallery_password: requirePassword ? password : 'No password required', + expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor + welcome_message: welcome_message || '' + }; - // Build query - let query = db('events'); + // Include client access info in email when enabled (#172) + if (client_access_enabled && client_password) { + const createdEvent = await db('events').where('id', eventId).first(); + const frontendUrl = process.env.FRONTEND_URL || process.env.APP_URL || ''; + emailData.client_link = `${frontendUrl}/gallery/${slug}/client-access?token=${createdEvent.client_share_token}`; + emailData.client_password = client_password; + } - // Editor role can only see their own events - if (req.admin.roleName === 'editor') { - query = query.where('created_by', req.admin.id); - } - - // Apply search filter - if (search) { - const escapedSearch = escapeLikePattern(search); - query = query.where((builder) => { - builder.where('event_name', 'like', `%${escapedSearch}%`) - .orWhere('admin_email', 'like', `%${escapedSearch}%`) - .orWhere('customer_email', 'like', `%${escapedSearch}%`) - .orWhere('slug', 'like', `%${escapedSearch}%`); - }); - } - - // Apply status filter - if (status === 'active') { - query = query.where('is_active', formatBoolean(true)).where('is_archived', formatBoolean(false)); - } else if (status === 'archived') { - query = query.where('is_archived', formatBoolean(true)); - } else if (status === 'inactive') { - query = query.where('is_active', formatBoolean(false)).where('is_archived', formatBoolean(false)); - } else if (status === 'draft') { - query = query.where('is_draft', formatBoolean(true)); - } else if (status === 'expiring') { - const sevenDaysFromNow = new Date(); - sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7); - query = query - .where('is_active', formatBoolean(true)) - .where('is_archived', formatBoolean(false)) - .where('expires_at', '<=', sevenDaysFromNow.toISOString()) - .where('expires_at', '>', new Date().toISOString()); - } - - // Get total count for pagination - const countQuery = query.clone(); - const [{ count }] = await countQuery.count('* as count'); - - // Apply sorting and pagination - const events = await query - .orderBy(sortBy, sortOrder) - .limit(limit) - .offset(offset); - - // Get photo counts for each event - const eventIds = events.map(e => e.id); - const photoCounts = await db('photos') - .whereIn('event_id', eventIds) - .groupBy('event_id') - .select('event_id') - .count('* as count'); - - // Map photo counts to events - const photoCountMap = photoCounts.reduce((acc, { event_id, count }) => { - acc[event_id] = parseInt(count); - return acc; - }, {}); - - // Add photo counts to events and convert dates - const eventsWithCounts = events.map(event => ({ - ...event, - photo_count: photoCountMap[event.id] || 0, - // Convert Unix timestamps to ISO strings - created_at: event.created_at ? new Date(event.created_at).toISOString() : null, - expires_at: event.expires_at ? new Date(event.expires_at).toISOString() : null, - archived_at: event.archived_at ? new Date(event.archived_at).toISOString() : null - })).map(mapEventForApi); - - res.json({ - events: eventsWithCounts, - pagination: { - page, - limit, - total: parseInt(count), - totalPages: Math.ceil(count / limit) + await db('email_queue').insert({ + event_id: eventId, + recipient_email: customerEmail, + email_type: 'gallery_created', + email_data: JSON.stringify(emailData), + status: 'pending', + created_at: new Date() + // scheduled_at will use default value + }); } - }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to fetch events'); - } -}); -// Get single event details -router.get('/:id', adminAuth, requirePermission('events.view'), async (req, res) => { - try { - const { id } = req.params; + // WhatsApp gallery_ready notification (#640D). Fires when the event is + // created NOT as a draft, the `whatsapp` flag is on, a config exists, and + // the customer supplied a phone number. Non-fatal: a queue failure should + // never block gallery creation. + if (!isDraft && customerPhone) { + try { + const { queueWhatsapp, getWhatsAppConfig } = require('../../services/whatsappProcessor'); + const waConfig = await getWhatsAppConfig(); + if (waConfig && waConfig.enabled) { + await queueWhatsapp(eventId, customerPhone, 'gallery_created', { + customer_name: customerName || '', + event_name, + gallery_link: shareUrl, + gallery_password: requirePassword ? password : '', + expiry_date: expires_at ? expires_at.toISOString() : null, + language: null, // resolved by processor via general_default_language + }); + } + } catch (waError) { + logger.warn('Failed to queue WhatsApp notification on create', { error: waError.message }); + } + } - let query = db('events').where('id', id); + // Fire event.published when the event is created NOT as a draft. The + // separate /publish endpoint fires it for the draft → live transition; + // this covers the "create-and-publish in one shot" path. + if (!isDraft) { + try { + const webhookService = require('../../services/webhookService'); + await webhookService.fire('event.published', { + event: webhookService.buildEventSubject({ + id: eventId, + slug, + event_name, + event_type, + event_date, + share_url: shareUrl, + share_token: shareToken, + customer_name: customerName, + customer_email: customerEmail, + customer_phone: customerPhone, + }), + }); + } catch (e) { /* non-fatal */ } + } - // Editor role can only see their own events - if (req.admin.roleName === 'editor') { - query = query.where('created_by', req.admin.id); + res.json({ + id: eventId, + slug, + event_name, + event_type, + customer_name: customerName, + customer_email: customerEmail, + require_password: requirePassword, + photo_cap: photo_cap || null, + is_draft: isDraft, + share_link: shareUrl, + expires_at: expires_at ? expires_at.toISOString() : null, + created_at: new Date().toISOString() + }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to create event'); } + }); - const event = await query.first(); - - if (!event) { - return res.status(404).json({ error: 'Event not found' }); - } - - // Get photo count - const [{ count: photoCount }] = await db('photos') - .where('event_id', id) - .count('* as count'); - - // Get total size - const [{ totalSize }] = await db('photos') - .where('event_id', id) - .sum('size_bytes as totalSize'); - - // Get recent photos - const recentPhotos = await db('photos') - .where('event_id', id) - .orderBy('uploaded_at', 'desc') - .limit(10) - .select('filename', 'type', 'size_bytes', 'uploaded_at'); - - // Get view and download statistics - const [{ totalViews }] = await db('access_logs') - .where('event_id', id) - .where('action', 'view') - .count('* as totalViews'); - - const [{ totalDownloads }] = await db('access_logs') - .where('event_id', id) - .where('action', 'download') - .count('* as totalDownloads'); - - const [{ uniqueVisitors }] = await db('access_logs') - .where('event_id', id) - .countDistinct('ip_address as uniqueVisitors'); - - // Customer accounts assigned to this event (#354). Hydrates the - // CustomerAccountPicker on the EventDetailsPage admin form. Returns - // an empty array on installs missing the table (e.g. pre-migrate). - let customerAccounts = []; + // Get all events with pagination and filters + router.get('/', adminAuth, requirePermission('events.view'), async (req, res) => { try { - const customerAccountsService = require('../../services/customerAccountsService'); - customerAccounts = await customerAccountsService.getAssignmentsForEvent(parseInt(id, 10)); - } catch (e) { - logger.warn('Failed to load customer assignments for event', { eventId: id, error: e.message }); + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 20; + const offset = (page - 1) * limit; + const search = req.query.search || ''; + const status = req.query.status || 'all'; + const allowedSortBy = ['created_at', 'event_name', 'slug', 'updated_at', 'expires_at', 'capture_date']; + const sortBy = allowedSortBy.includes(req.query.sortBy) ? req.query.sortBy : 'created_at'; + const sortOrder = ['asc', 'desc'].includes(req.query.sortOrder) ? req.query.sortOrder : 'desc'; + + // Build query + let query = db('events'); + + // Editor role can only see their own events + if (req.admin.roleName === 'editor') { + query = query.where('created_by', req.admin.id); + } + + // Apply search filter + if (search) { + const escapedSearch = escapeLikePattern(search); + query = query.where((builder) => { + builder.where('event_name', 'like', `%${escapedSearch}%`) + .orWhere('admin_email', 'like', `%${escapedSearch}%`) + .orWhere('customer_email', 'like', `%${escapedSearch}%`) + .orWhere('slug', 'like', `%${escapedSearch}%`); + }); + } + + // Apply status filter + if (status === 'active') { + query = query.where('is_active', formatBoolean(true)).where('is_archived', formatBoolean(false)); + } else if (status === 'archived') { + query = query.where('is_archived', formatBoolean(true)); + } else if (status === 'inactive') { + query = query.where('is_active', formatBoolean(false)).where('is_archived', formatBoolean(false)); + } else if (status === 'draft') { + query = query.where('is_draft', formatBoolean(true)); + } else if (status === 'expiring') { + const sevenDaysFromNow = new Date(); + sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7); + query = query + .where('is_active', formatBoolean(true)) + .where('is_archived', formatBoolean(false)) + .where('expires_at', '<=', sevenDaysFromNow.toISOString()) + .where('expires_at', '>', new Date().toISOString()); + } + + // Get total count for pagination + const countQuery = query.clone(); + const [{ count }] = await countQuery.count('* as count'); + + // Apply sorting and pagination + const events = await query + .orderBy(sortBy, sortOrder) + .limit(limit) + .offset(offset); + + // Get photo counts for each event + const eventIds = events.map(e => e.id); + const photoCounts = await db('photos') + .whereIn('event_id', eventIds) + .groupBy('event_id') + .select('event_id') + .count('* as count'); + + // Map photo counts to events + const photoCountMap = photoCounts.reduce((acc, { event_id, count }) => { + acc[event_id] = parseInt(count); + return acc; + }, {}); + + // Add photo counts to events and convert dates + const eventsWithCounts = events.map(event => ({ + ...event, + photo_count: photoCountMap[event.id] || 0, + // Convert Unix timestamps to ISO strings + created_at: event.created_at ? new Date(event.created_at).toISOString() : null, + expires_at: event.expires_at ? new Date(event.expires_at).toISOString() : null, + archived_at: event.archived_at ? new Date(event.archived_at).toISOString() : null + })).map(mapEventForApi); + + res.json({ + events: eventsWithCounts, + pagination: { + page, + limit, + total: parseInt(count), + totalPages: Math.ceil(count / limit) + } + }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to fetch events'); } + }); - res.json(mapEventForApi({ - ...event, - photo_count: parseInt(photoCount) || 0, - total_size: parseInt(totalSize) || 0, - total_views: parseInt(totalViews) || 0, - total_downloads: parseInt(totalDownloads) || 0, - unique_visitors: parseInt(uniqueVisitors) || 0, - recent_photos: recentPhotos, - customer_accounts: customerAccounts.map((c) => ({ - id: c.id, - email: c.email, - display_name: c.display_name, - first_name: c.first_name, - last_name: c.last_name, - })), - })); - } catch (error) { - errorResponse(res, error, 500, 'Failed to fetch event details'); - } -}); + // Get single event details + router.get('/:id', adminAuth, requirePermission('events.view'), async (req, res) => { + try { + const { id } = req.params; -// Publish a draft event (set is_draft=false and queue creation email) -router.post('/:id/publish', adminAuth, requirePermission('events.edit'), requireEventOwnership, [ + let query = db('events').where('id', id); + + // Editor role can only see their own events + if (req.admin.roleName === 'editor') { + query = query.where('created_by', req.admin.id); + } + + const event = await query.first(); + + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + // Get photo count + const [{ count: photoCount }] = await db('photos') + .where('event_id', id) + .count('* as count'); + + // Get total size + const [{ totalSize }] = await db('photos') + .where('event_id', id) + .sum('size_bytes as totalSize'); + + // Get recent photos + const recentPhotos = await db('photos') + .where('event_id', id) + .orderBy('uploaded_at', 'desc') + .limit(10) + .select('filename', 'type', 'size_bytes', 'uploaded_at'); + + // Get view and download statistics + const [{ totalViews }] = await db('access_logs') + .where('event_id', id) + .where('action', 'view') + .count('* as totalViews'); + + const [{ totalDownloads }] = await db('access_logs') + .where('event_id', id) + .where('action', 'download') + .count('* as totalDownloads'); + + const [{ uniqueVisitors }] = await db('access_logs') + .where('event_id', id) + .countDistinct('ip_address as uniqueVisitors'); + + // Customer accounts assigned to this event (#354). Hydrates the + // CustomerAccountPicker on the EventDetailsPage admin form. Returns + // an empty array on installs missing the table (e.g. pre-migrate). + let customerAccounts = []; + try { + const customerAccountsService = require('../../services/customerAccountsService'); + customerAccounts = await customerAccountsService.getAssignmentsForEvent(parseInt(id, 10)); + } catch (e) { + logger.warn('Failed to load customer assignments for event', { eventId: id, error: e.message }); + } + + res.json(mapEventForApi({ + ...event, + photo_count: parseInt(photoCount) || 0, + total_size: parseInt(totalSize) || 0, + total_views: parseInt(totalViews) || 0, + total_downloads: parseInt(totalDownloads) || 0, + unique_visitors: parseInt(uniqueVisitors) || 0, + recent_photos: recentPhotos, + customer_accounts: customerAccounts.map((c) => ({ + id: c.id, + email: c.email, + display_name: c.display_name, + first_name: c.first_name, + last_name: c.last_name, + })), + })); + } catch (error) { + errorResponse(res, error, 500, 'Failed to fetch event details'); + } + }); + + // Publish a draft event (set is_draft=false and queue creation email) + router.post('/:id/publish', adminAuth, requirePermission('events.edit'), requireEventOwnership, [ // Optional password the admin re-types in the publish dialog so the // gallery_created email can carry the actual plaintext (#627). When the // event is password-protected and the body carries a password, picpeak @@ -803,777 +803,777 @@ router.post('/:id/publish', adminAuth, requirePermission('events.edit'), require // creation; this guarantees the email content matches the live login // password). When omitted, behaviour is the legacy sentinel for backward // compat with API-only consumers. - body('password').optional().isString().isLength({ min: 6 }) - .withMessage('Password must be at least 6 characters long'), -], async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } + body('password').optional().isString().isLength({ min: 6 }) + .withMessage('Password must be at least 6 characters long'), + ], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } - const { id } = req.params; - const { password } = req.body; - const event = await db('events').where('id', id).first(); + const { id } = req.params; + const { password } = req.body; + const event = await db('events').where('id', id).first(); - if (!event) { - return res.status(404).json({ error: 'Event not found' }); - } + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } - if (!parseBooleanInput(event.is_draft, false)) { - return res.status(400).json({ error: 'Event is already published' }); - } + if (!parseBooleanInput(event.is_draft, false)) { + return res.status(400).json({ error: 'Event is already published' }); + } - const requirePassword = parseBooleanInput(event.require_password, true); - const publishUpdates = { is_draft: formatBoolean(false) }; - if (requirePassword && password) { + const requirePassword = parseBooleanInput(event.require_password, true); + const publishUpdates = { is_draft: formatBoolean(false) }; + if (requirePassword && password) { // Re-hash so the stored hash matches what the email carries — even if // the admin mistypes vs. what was set at draft creation, the gallery // password the customer receives is the one that actually works. - publishUpdates.password_hash = await bcrypt.hash(password, getBcryptRounds()); - } - await db('events').where('id', id).update(publishUpdates); + publishUpdates.password_hash = await bcrypt.hash(password, getBcryptRounds()); + } + await db('events').where('id', id).update(publishUpdates); - // Queue creation email - const customerEmail = event.customer_email || event.host_email; - const customerName = event.customer_name || event.host_name; - if (customerEmail) { - const frontendBase = await getFrontendBaseUrl(); - const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token }); + // Queue creation email + const customerEmail = event.customer_email || event.host_email; + const customerName = event.customer_name || event.host_name; + if (customerEmail) { + const frontendBase = await getFrontendBaseUrl(); + const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token }); - let galleryPasswordForEmail; - if (!requirePassword) { - galleryPasswordForEmail = 'No password required'; - } else if (password) { + let galleryPasswordForEmail; + if (!requirePassword) { + galleryPasswordForEmail = 'No password required'; + } else if (password) { // Admin re-typed the password in the publish dialog — put it straight // into the email so the customer can actually log in (#627). - galleryPasswordForEmail = password; - } else { + galleryPasswordForEmail = password; + } else { // Legacy fallback for API-only publishes that don't carry the password. - galleryPasswordForEmail = '(set at creation)'; - } + galleryPasswordForEmail = '(set at creation)'; + } - const emailData = { - customer_name: customerName, - customer_email: customerEmail, - host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null), - event_name: event.event_name, - event_date: event.event_date, - gallery_link: shareUrl || `${frontendBase}/gallery/${event.slug}`, - gallery_password: galleryPasswordForEmail, - expiry_date: event.expires_at ? new Date(event.expires_at).toISOString() : null, - welcome_message: event.welcome_message || '' - }; + const emailData = { + customer_name: customerName, + customer_email: customerEmail, + host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null), + event_name: event.event_name, + event_date: event.event_date, + gallery_link: shareUrl || `${frontendBase}/gallery/${event.slug}`, + gallery_password: galleryPasswordForEmail, + expiry_date: event.expires_at ? new Date(event.expires_at).toISOString() : null, + welcome_message: event.welcome_message || '' + }; - await db('email_queue').insert({ - event_id: id, - recipient_email: customerEmail, - email_type: 'gallery_created', - email_data: JSON.stringify(emailData), - status: 'pending', - created_at: new Date() - }); - } else { + await db('email_queue').insert({ + event_id: id, + recipient_email: customerEmail, + email_type: 'gallery_created', + email_data: JSON.stringify(emailData), + status: 'pending', + created_at: new Date() + }); + } else { // No inline email, but the gallery may be assigned to registered customer // account(s). Notify them via the account "your galleries" email // (customer_gallery_assigned, in the customer's own language) instead of // the gallery_created mail, which needs an inline recipient. Best-effort. - try { - const customerAccountsService = require('../../services/customerAccountsService'); - const assigned = await customerAccountsService.getAssignmentsForEvent(parseInt(id, 10)); - for (const c of assigned.filter((a) => a.is_active !== false && a.is_active !== 0 && a.email)) { - await customerAccountsService - .notifyCustomerOfNewAssignments(c.id, [parseInt(id, 10)]) - .catch((err) => logger.warn('Publish: customer gallery notice failed', { customerId: c.id, error: err.message })); + try { + const customerAccountsService = require('../../services/customerAccountsService'); + const assigned = await customerAccountsService.getAssignmentsForEvent(parseInt(id, 10)); + for (const c of assigned.filter((a) => a.is_active !== false && a.is_active !== 0 && a.email)) { + await customerAccountsService + .notifyCustomerOfNewAssignments(c.id, [parseInt(id, 10)]) + .catch((err) => logger.warn('Publish: customer gallery notice failed', { customerId: c.id, error: err.message })); + } + } catch (err) { + logger.warn('Publish: assigned-customer notification skipped', { eventId: id, error: err.message }); } - } catch (err) { - logger.warn('Publish: assigned-customer notification skipped', { eventId: id, error: err.message }); } - } - // WhatsApp gallery_ready on publish-from-draft (#640D). The PublishGallery - // dialog (#627) hands us the password back so we can deliver it via - // WhatsApp as well. Uses customer_phone from the persisted event row. - if (event.customer_phone) { + // WhatsApp gallery_ready on publish-from-draft (#640D). The PublishGallery + // dialog (#627) hands us the password back so we can deliver it via + // WhatsApp as well. Uses customer_phone from the persisted event row. + if (event.customer_phone) { + try { + const { queueWhatsapp, getWhatsAppConfig } = require('../../services/whatsappProcessor'); + const waConfig = await getWhatsAppConfig(); + if (waConfig && waConfig.enabled) { + const { shareUrl: shareUrlForWa } = await buildShareLinkVariants({ + slug: event.slug, shareToken: event.share_token, + }); + await queueWhatsapp(parseInt(id, 10), event.customer_phone, 'gallery_created', { + customer_name: event.customer_name || event.host_name || '', + event_name: event.event_name, + gallery_link: shareUrlForWa || `${await getFrontendBaseUrl()}/gallery/${event.slug}`, + // Plaintext only when the admin re-typed at publish; otherwise + // omit so the buildComponents() helper renders an empty {{4}} + // line instead of leaking the "(set at creation)" sentinel. + gallery_password: requirePassword && password ? password : '', + expiry_date: event.expires_at ? new Date(event.expires_at).toISOString() : null, + language: null, // resolved by processor via general_default_language + }); + } + } catch (waError) { + logger.warn('Failed to queue WhatsApp notification on publish', { error: waError.message }); + } + } + + await logActivity('event_published', + { event_name: event.event_name }, + id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + // Fire event.published webhook (#327) — draft → live transition. + // Canonical payload (#341): includes customer contact + share_token. try { - const { queueWhatsapp, getWhatsAppConfig } = require('../../services/whatsappProcessor'); - const waConfig = await getWhatsAppConfig(); - if (waConfig && waConfig.enabled) { - const { shareUrl: shareUrlForWa } = await buildShareLinkVariants({ - slug: event.slug, shareToken: event.share_token, - }); - await queueWhatsapp(parseInt(id, 10), event.customer_phone, 'gallery_created', { - customer_name: event.customer_name || event.host_name || '', + const webhookService = require('../../services/webhookService'); + const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token }); + await webhookService.fire('event.published', { + event: webhookService.buildEventSubject({ + id: parseInt(id, 10), + slug: event.slug, event_name: event.event_name, - gallery_link: shareUrlForWa || `${await getFrontendBaseUrl()}/gallery/${event.slug}`, - // Plaintext only when the admin re-typed at publish; otherwise - // omit so the buildComponents() helper renders an empty {{4}} - // line instead of leaking the "(set at creation)" sentinel. - gallery_password: requirePassword && password ? password : '', - expiry_date: event.expires_at ? new Date(event.expires_at).toISOString() : null, - language: null, // resolved by processor via general_default_language - }); - } - } catch (waError) { - logger.warn('Failed to queue WhatsApp notification on publish', { error: waError.message }); - } + event_type: event.event_type, + event_date: event.event_date, + share_url: shareUrl, + share_token: event.share_token, + customer_name: event.customer_name || event.host_name, + customer_email: event.customer_email || event.host_email, + customer_phone: event.customer_phone, + }), + }); + } catch (e) { /* non-fatal */ } + + res.json({ message: 'Event published successfully', is_draft: false }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to publish event'); } + }); - await logActivity('event_published', - { event_name: event.event_name }, - id, - { type: 'admin', id: req.admin.id, name: req.admin.username } - ); - - // Fire event.published webhook (#327) — draft → live transition. - // Canonical payload (#341): includes customer contact + share_token. + // Duplicate an event (#626). Creates a new DRAFT gallery that inherits the + // source event's branding, behaviour, hero/header, feedback, and category + // configuration — admin then fills in customer + publishes via the publish + // dialog (#627), where the password is set. Photos, hero photo selection, + // client-access secrets, customer assignments, archive/sent state are NOT + // carried over. + router.post('/:id/duplicate', adminAuth, requirePermission('events.create'), requireEventOwnership, [ + body('event_name').trim().notEmpty().withMessage('Event name is required'), + body('event_date').optional({ values: 'falsy' }).isDate(), + body('customer_name').optional().trim(), + body('customer_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL), + ], async (req, res) => { try { - const webhookService = require('../../services/webhookService'); - const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token }); - await webhookService.fire('event.published', { - event: webhookService.buildEventSubject({ - id: parseInt(id, 10), - slug: event.slug, - event_name: event.event_name, - event_type: event.event_type, - event_date: event.event_date, - share_url: shareUrl, - share_token: event.share_token, - customer_name: event.customer_name || event.host_name, - customer_email: event.customer_email || event.host_email, - customer_phone: event.customer_phone, - }), - }); - } catch (e) { /* non-fatal */ } + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } - res.json({ message: 'Event published successfully', is_draft: false }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to publish event'); - } -}); + const { id } = req.params; + const source = await db('events').where('id', id).first(); + if (!source) { + return res.status(404).json({ error: 'Source event not found' }); + } -// Duplicate an event (#626). Creates a new DRAFT gallery that inherits the -// source event's branding, behaviour, hero/header, feedback, and category -// configuration — admin then fills in customer + publishes via the publish -// dialog (#627), where the password is set. Photos, hero photo selection, -// client-access secrets, customer assignments, archive/sent state are NOT -// carried over. -router.post('/:id/duplicate', adminAuth, requirePermission('events.create'), requireEventOwnership, [ - body('event_name').trim().notEmpty().withMessage('Event name is required'), - body('event_date').optional({ values: 'falsy' }).isDate(), - body('customer_name').optional().trim(), - body('customer_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL), -], async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } + const { event_name, event_date, customer_name, customer_email } = req.body; - const { id } = req.params; - const source = await db('events').where('id', id).first(); - if (!source) { - return res.status(404).json({ error: 'Source event not found' }); - } + // Generate a fresh unique slug using the same shape as the create path. + const slugify = require('../../utils/slug').slugify; + const processedEventName = slugify(event_name); + const slugSuffix = event_date || crypto.randomBytes(3).toString('hex'); + const baseSlug = `${source.event_type}-${processedEventName}-${slugSuffix}`; + let slug = baseSlug; + let counter = 1; + // eslint-disable-next-line no-await-in-loop + while (await db('events').where({ slug }).first()) { + slug = `${baseSlug}-${counter}`; + counter += 1; + } - const { event_name, event_date, customer_name, customer_email } = req.body; - - // Generate a fresh unique slug using the same shape as the create path. - const slugify = require('../../utils/slug').slugify; - const processedEventName = slugify(event_name); - const slugSuffix = event_date || crypto.randomBytes(3).toString('hex'); - const baseSlug = `${source.event_type}-${processedEventName}-${slugSuffix}`; - let slug = baseSlug; - let counter = 1; - // eslint-disable-next-line no-await-in-loop - while (await db('events').where({ slug }).first()) { - slug = `${baseSlug}-${counter}`; - counter += 1; - } - - // Recompute expires_at: preserve the source's expiration window (delta - // between source.expires_at and source.event_date) so the duplicate keeps - // the same "active for N days" feel. Falls back to 30 days if source had - // no expiration set. - let newExpiresAt = null; - if (event_date) { - let expirationDays = 30; - if (source.expires_at && source.event_date) { - const days = Math.round( - (new Date(source.expires_at).getTime() - new Date(source.event_date).getTime()) + // Recompute expires_at: preserve the source's expiration window (delta + // between source.expires_at and source.event_date) so the duplicate keeps + // the same "active for N days" feel. Falls back to 30 days if source had + // no expiration set. + let newExpiresAt = null; + if (event_date) { + let expirationDays = 30; + if (source.expires_at && source.event_date) { + const days = Math.round( + (new Date(source.expires_at).getTime() - new Date(source.event_date).getTime()) / (24 * 60 * 60 * 1000), - ); - if (days > 0) expirationDays = days; + ); + if (days > 0) expirationDays = days; + } + const [year, month, day] = event_date.split('-').map((s) => parseInt(s, 10)); + const baseDate = new Date(year, month - 1, day); + baseDate.setDate(baseDate.getDate() + expirationDays); + newExpiresAt = baseDate; } - const [year, month, day] = event_date.split('-').map((s) => parseInt(s, 10)); - const baseDate = new Date(year, month - 1, day); - baseDate.setDate(baseDate.getDate() + expirationDays); - newExpiresAt = baseDate; - } - const shareToken = crypto.randomBytes(16).toString('hex'); - const { shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken }); + const shareToken = crypto.randomBytes(16).toString('hex'); + const { shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken }); - // Random-placeholder password hash. When the admin publishes via the - // PublishGalleryDialog (#627), the dialog re-hashes whatever they type and - // overwrites this. Pattern matches the create path at line ~606. - const password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds()); + // Random-placeholder password hash. When the admin publishes via the + // PublishGalleryDialog (#627), the dialog re-hashes whatever they type and + // overwrites this. Pattern matches the create path at line ~606. + const password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds()); - // Create the storage folder structure (same as create path). - const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../../storage'); - const eventPath = path.join(storagePath, 'events/active', slug); - await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true }); - await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true }); + // Create the storage folder structure (same as create path). + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../../storage'); + const eventPath = path.join(storagePath, 'events/active', slug); + await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true }); + await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true }); - const customerColumnsAvailable = await hasCustomerContactColumns(); - const calendarColumnsExist = await hasColumnCached('events', 'is_full_day'); + const customerColumnsAvailable = await hasCustomerContactColumns(); + const calendarColumnsExist = await hasColumnCached('events', 'is_full_day'); - // Build the insert row. Copy behaviour + branding fields from source; - // leave per-gallery secrets / state / photos blank. - const insertResult = await db('events').insert({ - slug, - event_type: source.event_type, - event_name, - event_date: event_date || null, - ...(calendarColumnsExist ? { - event_time_start: source.event_time_start, - event_time_end: source.event_time_end, - is_full_day: source.is_full_day, - } : {}), - ...(customerColumnsAvailable ? { - customer_name: customer_name || null, - customer_email: customer_email || null, - } : {}), - host_name: customer_name || null, - host_email: customer_email || null, - admin_email: source.admin_email || null, - password_hash, - welcome_message: source.welcome_message || '', - color_theme: source.color_theme, - share_link: shareLinkToStore, - share_token: shareToken, - expires_at: newExpiresAt ? newExpiresAt.toISOString() : null, - created_at: new Date().toISOString(), - created_by: req.admin.id, - allow_user_uploads: source.allow_user_uploads, - upload_category_id: source.upload_category_id, - allow_downloads: source.allow_downloads, - disable_right_click: source.disable_right_click, - enable_devtools_protection: source.enable_devtools_protection, - watermark_downloads: source.watermark_downloads, - watermark_text: source.watermark_text, - allow_presigned_download: source.allow_presigned_download, - require_password: source.require_password, - css_template_id: source.css_template_id || null, - hero_logo_visible: source.hero_logo_visible, - hero_logo_size: source.hero_logo_size, - hero_logo_position: source.hero_logo_position, - header_style: source.header_style || 'standard', - hero_divider_style: source.hero_divider_style || 'wave', - hero_image_anchor: source.hero_image_anchor || 'center', - photo_cap: source.photo_cap || null, - is_draft: formatBoolean(true), - default_photo_sort: source.default_photo_sort || 'upload_date_desc', - // Client-access secrets and the OG-share opt-in deliberately do NOT - // carry over — admin re-decides per gallery. - client_access_enabled: formatBoolean(false), - og_image_share_enabled: formatBoolean(false), - }).returning('id'); - - const newEventId = insertResult[0]?.id || insertResult[0]; - - // Copy event_feedback_settings if the source had a row (only present when - // feedback_enabled was true on the source event). - const sourceFeedback = await db('event_feedback_settings').where({ event_id: id }).first(); - if (sourceFeedback) { - await db('event_feedback_settings').insert({ - event_id: newEventId, - feedback_enabled: sourceFeedback.feedback_enabled, - allow_ratings: sourceFeedback.allow_ratings, - allow_likes: sourceFeedback.allow_likes, - allow_comments: sourceFeedback.allow_comments, - allow_favorites: sourceFeedback.allow_favorites, - require_name_email: sourceFeedback.require_name_email, - moderate_comments: sourceFeedback.moderate_comments, - show_feedback_to_guests: sourceFeedback.show_feedback_to_guests, + // Build the insert row. Copy behaviour + branding fields from source; + // leave per-gallery secrets / state / photos blank. + const insertResult = await db('events').insert({ + slug, + event_type: source.event_type, + event_name, + event_date: event_date || null, + ...(calendarColumnsExist ? { + event_time_start: source.event_time_start, + event_time_end: source.event_time_end, + is_full_day: source.is_full_day, + } : {}), + ...(customerColumnsAvailable ? { + customer_name: customer_name || null, + customer_email: customer_email || null, + } : {}), + host_name: customer_name || null, + host_email: customer_email || null, + admin_email: source.admin_email || null, + password_hash, + welcome_message: source.welcome_message || '', + color_theme: source.color_theme, + share_link: shareLinkToStore, + share_token: shareToken, + expires_at: newExpiresAt ? newExpiresAt.toISOString() : null, created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }); - } + created_by: req.admin.id, + allow_user_uploads: source.allow_user_uploads, + upload_category_id: source.upload_category_id, + allow_downloads: source.allow_downloads, + disable_right_click: source.disable_right_click, + enable_devtools_protection: source.enable_devtools_protection, + watermark_downloads: source.watermark_downloads, + watermark_text: source.watermark_text, + allow_presigned_download: source.allow_presigned_download, + require_password: source.require_password, + css_template_id: source.css_template_id || null, + hero_logo_visible: source.hero_logo_visible, + hero_logo_size: source.hero_logo_size, + hero_logo_position: source.hero_logo_position, + header_style: source.header_style || 'standard', + hero_divider_style: source.hero_divider_style || 'wave', + hero_image_anchor: source.hero_image_anchor || 'center', + photo_cap: source.photo_cap || null, + is_draft: formatBoolean(true), + default_photo_sort: source.default_photo_sort || 'upload_date_desc', + // Client-access secrets and the OG-share opt-in deliberately do NOT + // carry over — admin re-decides per gallery. + client_access_enabled: formatBoolean(false), + og_image_share_enabled: formatBoolean(false), + }).returning('id'); - // Copy per-event photo categories (global categories are not duplicated — - // they apply to every event already). Mapping by name; photo_categories - // has no foreign key into photos here so we just clone the rows. - if (await db.schema.hasTable('photo_categories')) { - const sourceCategories = await db('photo_categories') - .where({ event_id: id }) - .where(function () { this.whereNull('is_global').orWhere('is_global', formatBoolean(false)); }) - .select('name', 'slug', 'is_global'); - if (sourceCategories.length > 0) { - await db('photo_categories').insert( - sourceCategories.map((c) => ({ - event_id: newEventId, - name: c.name, - slug: c.slug, - is_global: formatBoolean(false), - })), - ); + const newEventId = insertResult[0]?.id || insertResult[0]; + + // Copy event_feedback_settings if the source had a row (only present when + // feedback_enabled was true on the source event). + const sourceFeedback = await db('event_feedback_settings').where({ event_id: id }).first(); + if (sourceFeedback) { + await db('event_feedback_settings').insert({ + event_id: newEventId, + feedback_enabled: sourceFeedback.feedback_enabled, + allow_ratings: sourceFeedback.allow_ratings, + allow_likes: sourceFeedback.allow_likes, + allow_comments: sourceFeedback.allow_comments, + allow_favorites: sourceFeedback.allow_favorites, + require_name_email: sourceFeedback.require_name_email, + moderate_comments: sourceFeedback.moderate_comments, + show_feedback_to_guests: sourceFeedback.show_feedback_to_guests, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }); } + + // Copy per-event photo categories (global categories are not duplicated — + // they apply to every event already). Mapping by name; photo_categories + // has no foreign key into photos here so we just clone the rows. + if (await db.schema.hasTable('photo_categories')) { + const sourceCategories = await db('photo_categories') + .where({ event_id: id }) + .where(function () { this.whereNull('is_global').orWhere('is_global', formatBoolean(false)); }) + .select('name', 'slug', 'is_global'); + if (sourceCategories.length > 0) { + await db('photo_categories').insert( + sourceCategories.map((c) => ({ + event_id: newEventId, + name: c.name, + slug: c.slug, + is_global: formatBoolean(false), + })), + ); + } + } + + await logActivity('event_duplicated', + { source_event_id: parseInt(id, 10), source_event_name: source.event_name }, + newEventId, + { type: 'admin', id: req.admin.id, name: req.admin.username }, + ); + + res.json({ + message: 'Event duplicated successfully', + id: newEventId, + slug, + is_draft: true, + }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to duplicate event'); } + }); - await logActivity('event_duplicated', - { source_event_id: parseInt(id, 10), source_event_name: source.event_name }, - newEventId, - { type: 'admin', id: req.admin.id, name: req.admin.username }, - ); - - res.json({ - message: 'Event duplicated successfully', - id: newEventId, - slug, - is_draft: true, - }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to duplicate event'); - } -}); - -// Update event -router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwnership, [ - body('event_name').optional().trim().notEmpty(), - body('event_date').optional({ values: 'falsy' }).isDate(), - // Migration 137 — calendar time fields. Same regex/range rule as POST. - body('event_time_start').optional({ values: 'falsy', nullable: true }) - .matches(/^([01]\d|2[0-3]):[0-5]\d$/) - .withMessage('event_time_start must be HH:MM 24h'), - body('event_time_end').optional({ values: 'falsy', nullable: true }) - .matches(/^([01]\d|2[0-3]):[0-5]\d$/) - .withMessage('event_time_end must be HH:MM 24h'), - body('is_full_day').optional().isBoolean().toBoolean(), - body('admin_email').optional().isEmail(), - body('is_active').optional().isBoolean(), - body('expires_at').optional({ nullable: true, checkFalsy: true }).isISO8601(), - body('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(), - body('color_theme').optional({ nullable: true }), - body('allow_user_uploads').optional().isBoolean(), - // Migration 143 — per-event reminder overrides. All three are - // optional; nullable values are accepted so admins can clear an - // override (e.g. drop a custom offset back to the global default). - body('event_reminder_disabled').optional().isBoolean(), - body('event_reminder_offset_days').optional({ nullable: true }) - .custom((v) => v === null || (Number.isInteger(Number(v)) && Number(v) >= 0)) - .withMessage('event_reminder_offset_days must be a non-negative integer or null'), - body('event_reminder_body_override').optional({ nullable: true, checkFalsy: true }) - .isString().isLength({ max: 10_000 }), - body('customer_name').optional({ nullable: true, checkFalsy: true }).trim(), - body('customer_email').optional().isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL), - body('customer_phone').optional({ nullable: true, checkFalsy: true }) - .isString().trim() - .isLength({ max: 32 }).withMessage('Phone number must be at most 32 characters'), - body('upload_category_id').optional().custom((value) => { + // Update event + router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwnership, [ + body('event_name').optional().trim().notEmpty(), + body('event_date').optional({ values: 'falsy' }).isDate(), + // Migration 137 — calendar time fields. Same regex/range rule as POST. + body('event_time_start').optional({ values: 'falsy', nullable: true }) + .matches(/^([01]\d|2[0-3]):[0-5]\d$/) + .withMessage('event_time_start must be HH:MM 24h'), + body('event_time_end').optional({ values: 'falsy', nullable: true }) + .matches(/^([01]\d|2[0-3]):[0-5]\d$/) + .withMessage('event_time_end must be HH:MM 24h'), + body('is_full_day').optional().isBoolean().toBoolean(), + body('admin_email').optional().isEmail(), + body('is_active').optional().isBoolean(), + body('expires_at').optional({ nullable: true, checkFalsy: true }).isISO8601(), + body('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(), + body('color_theme').optional({ nullable: true }), + body('allow_user_uploads').optional().isBoolean(), + // Migration 143 — per-event reminder overrides. All three are + // optional; nullable values are accepted so admins can clear an + // override (e.g. drop a custom offset back to the global default). + body('event_reminder_disabled').optional().isBoolean(), + body('event_reminder_offset_days').optional({ nullable: true }) + .custom((v) => v === null || (Number.isInteger(Number(v)) && Number(v) >= 0)) + .withMessage('event_reminder_offset_days must be a non-negative integer or null'), + body('event_reminder_body_override').optional({ nullable: true, checkFalsy: true }) + .isString().isLength({ max: 10_000 }), + body('customer_name').optional({ nullable: true, checkFalsy: true }).trim(), + body('customer_email').optional().isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL), + body('customer_phone').optional({ nullable: true, checkFalsy: true }) + .isString().trim() + .isLength({ max: 32 }).withMessage('Phone number must be at most 32 characters'), + body('upload_category_id').optional().custom((value) => { // Accept null, undefined, or integer values - if (value === null || value === undefined) return true; - return Number.isInteger(Number(value)); - }).withMessage('upload_category_id must be an integer or null'), - body('hero_photo_id').optional().custom((value) => { + if (value === null || value === undefined) return true; + return Number.isInteger(Number(value)); + }).withMessage('upload_category_id must be an integer or null'), + body('hero_photo_id').optional().custom((value) => { // Accept null, undefined, or numeric values - if (value === null || value === undefined) return true; - // Check if it's a number or can be converted to a valid integer - const num = Number(value); - return !isNaN(num) && Number.isInteger(num); - }).withMessage('hero_photo_id must be an integer or null'), - body('allow_downloads').optional().isBoolean(), - body('disable_right_click').optional().isBoolean(), - body('watermark_downloads').optional().isBoolean(), - body('watermark_text').optional().trim(), - body('allow_presigned_download').optional().isBoolean(), - body('source_mode').optional().isIn(['managed', 'reference']), - body('external_path').optional({ nullable: true }).isString().trim(), - body('require_password').optional().isBoolean(), - // Download protection settings - body('protection_level').optional().isIn(['basic', 'standard', 'enhanced', 'maximum']), - body('enable_devtools_protection').optional().isBoolean(), - body('use_canvas_rendering').optional().isBoolean(), - body('overlay_protection').optional().isBoolean(), - body('image_quality').optional().isInt({ min: 1, max: 100 }), - body('fragmentation_level').optional().isInt({ min: 1, max: 10 }), - body('password').optional().isString().custom((value) => { - if (value === undefined || value === null || value === '') { + if (value === null || value === undefined) return true; + // Check if it's a number or can be converted to a valid integer + const num = Number(value); + return !isNaN(num) && Number.isInteger(num); + }).withMessage('hero_photo_id must be an integer or null'), + body('allow_downloads').optional().isBoolean(), + body('disable_right_click').optional().isBoolean(), + body('watermark_downloads').optional().isBoolean(), + body('watermark_text').optional().trim(), + body('allow_presigned_download').optional().isBoolean(), + body('source_mode').optional().isIn(['managed', 'reference']), + body('external_path').optional({ nullable: true }).isString().trim(), + body('require_password').optional().isBoolean(), + // Download protection settings + body('protection_level').optional().isIn(['basic', 'standard', 'enhanced', 'maximum']), + body('enable_devtools_protection').optional().isBoolean(), + body('use_canvas_rendering').optional().isBoolean(), + body('overlay_protection').optional().isBoolean(), + body('image_quality').optional().isInt({ min: 1, max: 100 }), + body('fragmentation_level').optional().isInt({ min: 1, max: 10 }), + body('password').optional().isString().custom((value) => { + if (value === undefined || value === null || value === '') { + return true; + } + if (typeof value !== 'string' || value.trim().length < 6) { + throw new Error('Password must be at least 6 characters long'); + } return true; - } - if (typeof value !== 'string' || value.trim().length < 6) { - throw new Error('Password must be at least 6 characters long'); - } - return true; - }), - body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(), - // Hero logo settings - body('hero_logo_visible').optional().isBoolean(), - body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']), - body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']), - // Header style settings (decoupled from layout) - body('header_style').optional().isIn(['hero', 'standard', 'banner', 'minimal', 'none']), - body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']), - // Hero image anchor position (#162) – accepts legacy keywords or "X% Y%" focal point - body('hero_image_anchor').optional().custom(validateHeroImageAnchor), - // Client access settings (#172) - body('client_access_enabled').optional().isBoolean(), - body('client_password').optional().isString(), - body('regenerate_client_token').optional().isBoolean(), - body('default_photo_sort').optional().isIn([ - 'upload_date_desc', 'upload_date_asc', - 'capture_date_desc', 'capture_date_asc', - 'filename_asc', 'filename_desc' - ]), - // Per-event promotional override (#440). Three-way mode: - // inherit → fall back to global branding_promo_markdown - // custom → render this event's promo_markdown verbatim - // off → suppress entirely for this event - body('promo_mode').optional().isIn(['inherit', 'custom', 'off']), - body('promo_markdown').optional({ nullable: true }).isString(), - // Per-event opt-in for using hero photo as the social-share preview - // image (#474). When false (default), galleryOgService falls back to - // the brand logo for og:image / Twitter Card. - body('og_image_share_enabled').optional().isBoolean(), - // Customer accounts assigned to this event (#354). Optional array of - // customer_accounts.id — many-to-many via event_customer_assignments. - body('customer_account_ids').optional().isArray(), - body('customer_account_ids.*').optional().isInt({ min: 1 }) -], async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - logger.debug('Update event validation errors', { errors: errors.array(), body: req.body }); - return res.status(400).json({ errors: errors.array() }); - } + }), + body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(), + // Hero logo settings + body('hero_logo_visible').optional().isBoolean(), + body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']), + body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']), + // Header style settings (decoupled from layout) + body('header_style').optional().isIn(['hero', 'standard', 'banner', 'minimal', 'none']), + body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']), + // Hero image anchor position (#162) – accepts legacy keywords or "X% Y%" focal point + body('hero_image_anchor').optional().custom(validateHeroImageAnchor), + // Client access settings (#172) + body('client_access_enabled').optional().isBoolean(), + body('client_password').optional().isString(), + body('regenerate_client_token').optional().isBoolean(), + body('default_photo_sort').optional().isIn([ + 'upload_date_desc', 'upload_date_asc', + 'capture_date_desc', 'capture_date_asc', + 'filename_asc', 'filename_desc' + ]), + // Per-event promotional override (#440). Three-way mode: + // inherit → fall back to global branding_promo_markdown + // custom → render this event's promo_markdown verbatim + // off → suppress entirely for this event + body('promo_mode').optional().isIn(['inherit', 'custom', 'off']), + body('promo_markdown').optional({ nullable: true }).isString(), + // Per-event opt-in for using hero photo as the social-share preview + // image (#474). When false (default), galleryOgService falls back to + // the brand logo for og:image / Twitter Card. + body('og_image_share_enabled').optional().isBoolean(), + // Customer accounts assigned to this event (#354). Optional array of + // customer_accounts.id — many-to-many via event_customer_assignments. + body('customer_account_ids').optional().isArray(), + body('customer_account_ids.*').optional().isInt({ min: 1 }) + ], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + logger.debug('Update event validation errors', { errors: errors.array(), body: req.body }); + return res.status(400).json({ errors: errors.array() }); + } - const { id } = req.params; - const updates = { ...req.body }; - const customerColumnsAvailable = await hasCustomerContactColumns(); + const { id } = req.params; + const updates = { ...req.body }; + const customerColumnsAvailable = await hasCustomerContactColumns(); - if (Object.prototype.hasOwnProperty.call(updates, 'host_name') || Object.prototype.hasOwnProperty.call(updates, 'host_email')) { - return res.status(400).json({ error: 'host_name and host_email are no longer supported. Use customer_name and customer_email instead.' }); - } + if (Object.prototype.hasOwnProperty.call(updates, 'host_name') || Object.prototype.hasOwnProperty.call(updates, 'host_email')) { + return res.status(400).json({ error: 'host_name and host_email are no longer supported. Use customer_name and customer_email instead.' }); + } - if (Object.prototype.hasOwnProperty.call(updates, 'customer_name')) { - const nextName = getCustomerNameFromPayload(updates); - if (nextName) { - if (customerColumnsAvailable) { - updates.customer_name = nextName; + if (Object.prototype.hasOwnProperty.call(updates, 'customer_name')) { + const nextName = getCustomerNameFromPayload(updates); + if (nextName) { + if (customerColumnsAvailable) { + updates.customer_name = nextName; + } else { + delete updates.customer_name; + } + updates.host_name = nextName; } else { delete updates.customer_name; } - updates.host_name = nextName; - } else { - delete updates.customer_name; } - } - if (Object.prototype.hasOwnProperty.call(updates, 'customer_email')) { - const nextEmail = getCustomerEmailFromPayload(updates); - if (nextEmail) { - if (customerColumnsAvailable) { - updates.customer_email = nextEmail; + if (Object.prototype.hasOwnProperty.call(updates, 'customer_email')) { + const nextEmail = getCustomerEmailFromPayload(updates); + if (nextEmail) { + if (customerColumnsAvailable) { + updates.customer_email = nextEmail; + } else { + delete updates.customer_email; + } + updates.host_email = nextEmail; } else { delete updates.customer_email; } - updates.host_email = nextEmail; - } else { - delete updates.customer_email; } - } - // Phone is gated on the global toggle (#322). Strip from the update - // unconditionally if disabled — even null/clear is rejected so an - // admin can't accidentally write to a field they've turned off. - if (Object.prototype.hasOwnProperty.call(updates, 'customer_phone')) { - const phoneEnabled = await isPhoneFieldEnabled(); - if (!phoneEnabled) { - delete updates.customer_phone; - } else { - const nextPhone = getCustomerPhoneFromPayload(updates); - updates.customer_phone = nextPhone || null; - } - } - - const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password'); - let requirePasswordUpdate; - if (hasRequirePasswordUpdate) { - requirePasswordUpdate = parseBooleanInput(updates.require_password, true); - updates.require_password = formatBoolean(requirePasswordUpdate); - } - - let newPasswordPlain; - if (Object.prototype.hasOwnProperty.call(updates, 'password')) { - if (updates.password === undefined || updates.password === null || updates.password === '') { - delete updates.password; - } else { - newPasswordPlain = updates.password; - delete updates.password; - } - } - - if (Object.prototype.hasOwnProperty.call(updates, 'source_mode')) { - updates.source_mode = updates.source_mode === 'reference' ? 'reference' : 'managed'; - } - - if (Object.prototype.hasOwnProperty.call(updates, 'external_path')) { - const trimmedPath = updates.external_path ? String(updates.external_path).trim() : ''; - updates.external_path = trimmedPath || null; - } - - if (updates.source_mode === 'managed') { - updates.external_path = null; - } - - if (updates.source_mode === 'reference' && (updates.external_path === null || updates.external_path === undefined)) { - return res.status(400).json({ error: 'external_path is required when source_mode is reference' }); - } - - if (Object.prototype.hasOwnProperty.call(updates, 'client_password') && updates.client_password) { - updates.client_password_hash = await bcrypt.hash(updates.client_password, getBcryptRounds()); - delete updates.client_password; - } else { - delete updates.client_password; - } - if (updates.regenerate_client_token) { - updates.client_share_token = crypto.randomBytes(32).toString('hex'); - } - delete updates.regenerate_client_token; - - // customer_account_ids (#354) is a body-only field consumed - // separately below by customerAccountsService.setAssignmentsForEvent - // — it isn't a column on the events table, so spreading it into - // the UPDATE statement throws "column does not exist" and crashes - // the entire edit with 500 Failed to update event. - delete updates.customer_account_ids; - - // Migration 137 — calendar time triple. Renormalise only when at - // least one of the three fields was supplied; otherwise leave the - // row's current values alone. is_full_day=true forces both times - // to null. Drop the fields silently on un-migrated installs. - const timeFieldsTouched = ( - Object.prototype.hasOwnProperty.call(updates, 'event_time_start') - || Object.prototype.hasOwnProperty.call(updates, 'event_time_end') - || Object.prototype.hasOwnProperty.call(updates, 'is_full_day') - ); - if (timeFieldsTouched) { - if (await hasColumnCached('events', 'is_full_day')) { - const triple = normaliseEventTimeTriple({ - event_time_start: updates.event_time_start, - event_time_end: updates.event_time_end, - is_full_day: updates.is_full_day, - }); - updates.event_time_start = triple.event_time_start; - updates.event_time_end = triple.event_time_end; - updates.is_full_day = formatBoolean(triple.is_full_day); - } else { - delete updates.event_time_start; - delete updates.event_time_end; - delete updates.is_full_day; - } - } - - // Log the update request for debugging - logger.debug('Update event request', { - id, - updates, - color_theme_length: updates.color_theme ? updates.color_theme.length : 0, - color_theme_type: typeof updates.color_theme, - hero_photo_id: updates.hero_photo_id, - hero_photo_id_type: typeof updates.hero_photo_id - }); - - // Check if event exists - let eventQuery = db('events').where('id', id); - // Editor role can only edit their own events - if (req.admin.roleName === 'editor') { - eventQuery = eventQuery.where('created_by', req.admin.id); - } - const event = await eventQuery.first(); - if (!event) { - return res.status(404).json({ error: 'Event not found' }); - } - - const currentRequirePassword = parseBooleanInput(event.require_password, true); - - if (hasRequirePasswordUpdate && requirePasswordUpdate === true && !currentRequirePassword && !newPasswordPlain) { - return res.status(400).json({ error: 'Password must be provided when enabling password requirement.' }); - } - - if (newPasswordPlain) { - updates.password_hash = await bcrypt.hash(newPasswordPlain, getBcryptRounds()); - } else if (hasRequirePasswordUpdate && requirePasswordUpdate === false && currentRequirePassword) { - updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds()); - } - - // Enforce expires_at requirement based on app settings - // Allow admins to clear `expires_at` on edit even when the global - // `event_require_expiration` setting is ON (#426). The setting now - // controls only the create-time default — once an event exists, an - // admin editing it can override and remove the expiration. Empty / - // null values normalize to NULL in the column ("never expires"). - if (Object.prototype.hasOwnProperty.call(updates, 'expires_at') && !updates.expires_at) { - updates.expires_at = null; - } - - // Format hero logo settings if provided - if (Object.prototype.hasOwnProperty.call(updates, 'hero_logo_visible')) { - updates.hero_logo_visible = formatBoolean(updates.hero_logo_visible); - } - - // Per-event opt-in for hero-photo OG share image (#474). Coerce so - // SQLite stores 0/1 and Postgres stores boolean true/false. - if (Object.prototype.hasOwnProperty.call(updates, 'og_image_share_enabled')) { - updates.og_image_share_enabled = formatBoolean(updates.og_image_share_enabled === true); - } - - // Per-event promotional override (#440). Normalize promo_markdown to - // NULL when mode is anything other than 'custom' so we don't carry - // stale text after the admin switches modes. Empty markdown also - // becomes NULL. - if (Object.prototype.hasOwnProperty.call(updates, 'promo_mode') - || Object.prototype.hasOwnProperty.call(updates, 'promo_markdown')) { - const mode = updates.promo_mode; - if (mode && mode !== 'custom') { - updates.promo_markdown = null; - } else if (Object.prototype.hasOwnProperty.call(updates, 'promo_markdown')) { - const md = typeof updates.promo_markdown === 'string' ? updates.promo_markdown.trim() : ''; - updates.promo_markdown = md || null; - } - } - - // Sync header_style / hero_divider_style from color_theme JSON when not - // explicitly provided in the request body (#158). This ensures the - // database columns stay in sync even if the frontend only sends the - // serialised theme object. - if (updates.color_theme && !Object.prototype.hasOwnProperty.call(updates, 'header_style')) { - try { - const themeStr = typeof updates.color_theme === 'string' ? updates.color_theme : ''; - if (themeStr.startsWith('{')) { - const parsed = JSON.parse(themeStr); - if (parsed.headerStyle) { - updates.header_style = parsed.headerStyle; - } - if (parsed.heroDividerStyle && !Object.prototype.hasOwnProperty.call(updates, 'hero_divider_style')) { - updates.hero_divider_style = parsed.heroDividerStyle; - } + // Phone is gated on the global toggle (#322). Strip from the update + // unconditionally if disabled — even null/clear is rejected so an + // admin can't accidentally write to a field they've turned off. + if (Object.prototype.hasOwnProperty.call(updates, 'customer_phone')) { + const phoneEnabled = await isPhoneFieldEnabled(); + if (!phoneEnabled) { + delete updates.customer_phone; + } else { + const nextPhone = getCustomerPhoneFromPayload(updates); + updates.customer_phone = nextPhone || null; } - } catch (_) { - // color_theme is not JSON (e.g. preset name) – nothing to extract } - } - // Handle client access fields (#172) - if (Object.prototype.hasOwnProperty.call(updates, 'client_access_enabled')) { - updates.client_access_enabled = formatBoolean(updates.client_access_enabled); - // Auto-generate client share token when first enabling - if (parseBooleanInput(updates.client_access_enabled, false) && !event.client_share_token && !updates.client_share_token) { + const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password'); + let requirePasswordUpdate; + if (hasRequirePasswordUpdate) { + requirePasswordUpdate = parseBooleanInput(updates.require_password, true); + updates.require_password = formatBoolean(requirePasswordUpdate); + } + + let newPasswordPlain; + if (Object.prototype.hasOwnProperty.call(updates, 'password')) { + if (updates.password === undefined || updates.password === null || updates.password === '') { + delete updates.password; + } else { + newPasswordPlain = updates.password; + delete updates.password; + } + } + + if (Object.prototype.hasOwnProperty.call(updates, 'source_mode')) { + updates.source_mode = updates.source_mode === 'reference' ? 'reference' : 'managed'; + } + + if (Object.prototype.hasOwnProperty.call(updates, 'external_path')) { + const trimmedPath = updates.external_path ? String(updates.external_path).trim() : ''; + updates.external_path = trimmedPath || null; + } + + if (updates.source_mode === 'managed') { + updates.external_path = null; + } + + if (updates.source_mode === 'reference' && (updates.external_path === null || updates.external_path === undefined)) { + return res.status(400).json({ error: 'external_path is required when source_mode is reference' }); + } + + if (Object.prototype.hasOwnProperty.call(updates, 'client_password') && updates.client_password) { + updates.client_password_hash = await bcrypt.hash(updates.client_password, getBcryptRounds()); + delete updates.client_password; + } else { + delete updates.client_password; + } + if (updates.regenerate_client_token) { updates.client_share_token = crypto.randomBytes(32).toString('hex'); } - } + delete updates.regenerate_client_token; - // Update event - await db('events') - .where('id', id) - .update(updates); + // customer_account_ids (#354) is a body-only field consumed + // separately below by customerAccountsService.setAssignmentsForEvent + // — it isn't a column on the events table, so spreading it into + // the UPDATE statement throws "column does not exist" and crashes + // the entire edit with 500 Failed to update event. + delete updates.customer_account_ids; - // Customer-account assignments (#354). Same skip semantics as POST: - // ignore when the customer portal flag is off so stale tabs don't - // 4xx the whole edit. - if (Array.isArray(req.body.customer_account_ids)) { - try { - const customerAccountsService = require('../../services/customerAccountsService'); - if (await customerAccountsService.isCustomerPortalEnabled()) { - await customerAccountsService.setAssignmentsForEvent( - parseInt(id, 10), - req.body.customer_account_ids, - req.admin.id - ); + // Migration 137 — calendar time triple. Renormalise only when at + // least one of the three fields was supplied; otherwise leave the + // row's current values alone. is_full_day=true forces both times + // to null. Drop the fields silently on un-migrated installs. + const timeFieldsTouched = ( + Object.prototype.hasOwnProperty.call(updates, 'event_time_start') + || Object.prototype.hasOwnProperty.call(updates, 'event_time_end') + || Object.prototype.hasOwnProperty.call(updates, 'is_full_day') + ); + if (timeFieldsTouched) { + if (await hasColumnCached('events', 'is_full_day')) { + const triple = normaliseEventTimeTriple({ + event_time_start: updates.event_time_start, + event_time_end: updates.event_time_end, + is_full_day: updates.is_full_day, + }); + updates.event_time_start = triple.event_time_start; + updates.event_time_end = triple.event_time_end; + updates.is_full_day = formatBoolean(triple.is_full_day); + } else { + delete updates.event_time_start; + delete updates.event_time_end; + delete updates.is_full_day; } - } catch (e) { - logger.error('Failed to set customer assignments on event update', { - eventId: id, error: e.message, + } + + // Log the update request for debugging + logger.debug('Update event request', { + id, + updates, + color_theme_length: updates.color_theme ? updates.color_theme.length : 0, + color_theme_type: typeof updates.color_theme, + hero_photo_id: updates.hero_photo_id, + hero_photo_id_type: typeof updates.hero_photo_id + }); + + // Check if event exists + let eventQuery = db('events').where('id', id); + // Editor role can only edit their own events + if (req.admin.roleName === 'editor') { + eventQuery = eventQuery.where('created_by', req.admin.id); + } + const event = await eventQuery.first(); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + const currentRequirePassword = parseBooleanInput(event.require_password, true); + + if (hasRequirePasswordUpdate && requirePasswordUpdate === true && !currentRequirePassword && !newPasswordPlain) { + return res.status(400).json({ error: 'Password must be provided when enabling password requirement.' }); + } + + if (newPasswordPlain) { + updates.password_hash = await bcrypt.hash(newPasswordPlain, getBcryptRounds()); + } else if (hasRequirePasswordUpdate && requirePasswordUpdate === false && currentRequirePassword) { + updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds()); + } + + // Enforce expires_at requirement based on app settings + // Allow admins to clear `expires_at` on edit even when the global + // `event_require_expiration` setting is ON (#426). The setting now + // controls only the create-time default — once an event exists, an + // admin editing it can override and remove the expiration. Empty / + // null values normalize to NULL in the column ("never expires"). + if (Object.prototype.hasOwnProperty.call(updates, 'expires_at') && !updates.expires_at) { + updates.expires_at = null; + } + + // Format hero logo settings if provided + if (Object.prototype.hasOwnProperty.call(updates, 'hero_logo_visible')) { + updates.hero_logo_visible = formatBoolean(updates.hero_logo_visible); + } + + // Per-event opt-in for hero-photo OG share image (#474). Coerce so + // SQLite stores 0/1 and Postgres stores boolean true/false. + if (Object.prototype.hasOwnProperty.call(updates, 'og_image_share_enabled')) { + updates.og_image_share_enabled = formatBoolean(updates.og_image_share_enabled === true); + } + + // Per-event promotional override (#440). Normalize promo_markdown to + // NULL when mode is anything other than 'custom' so we don't carry + // stale text after the admin switches modes. Empty markdown also + // becomes NULL. + if (Object.prototype.hasOwnProperty.call(updates, 'promo_mode') + || Object.prototype.hasOwnProperty.call(updates, 'promo_markdown')) { + const mode = updates.promo_mode; + if (mode && mode !== 'custom') { + updates.promo_markdown = null; + } else if (Object.prototype.hasOwnProperty.call(updates, 'promo_markdown')) { + const md = typeof updates.promo_markdown === 'string' ? updates.promo_markdown.trim() : ''; + updates.promo_markdown = md || null; + } + } + + // Sync header_style / hero_divider_style from color_theme JSON when not + // explicitly provided in the request body (#158). This ensures the + // database columns stay in sync even if the frontend only sends the + // serialised theme object. + if (updates.color_theme && !Object.prototype.hasOwnProperty.call(updates, 'header_style')) { + try { + const themeStr = typeof updates.color_theme === 'string' ? updates.color_theme : ''; + if (themeStr.startsWith('{')) { + const parsed = JSON.parse(themeStr); + if (parsed.headerStyle) { + updates.header_style = parsed.headerStyle; + } + if (parsed.heroDividerStyle && !Object.prototype.hasOwnProperty.call(updates, 'hero_divider_style')) { + updates.hero_divider_style = parsed.heroDividerStyle; + } + } + } catch (_) { + // color_theme is not JSON (e.g. preset name) – nothing to extract + } + } + + // Handle client access fields (#172) + if (Object.prototype.hasOwnProperty.call(updates, 'client_access_enabled')) { + updates.client_access_enabled = formatBoolean(updates.client_access_enabled); + // Auto-generate client share token when first enabling + if (parseBooleanInput(updates.client_access_enabled, false) && !event.client_share_token && !updates.client_share_token) { + updates.client_share_token = crypto.randomBytes(32).toString('hex'); + } + } + + // Update event + await db('events') + .where('id', id) + .update(updates); + + // Customer-account assignments (#354). Same skip semantics as POST: + // ignore when the customer portal flag is off so stale tabs don't + // 4xx the whole edit. + if (Array.isArray(req.body.customer_account_ids)) { + try { + const customerAccountsService = require('../../services/customerAccountsService'); + if (await customerAccountsService.isCustomerPortalEnabled()) { + await customerAccountsService.setAssignmentsForEvent( + parseInt(id, 10), + req.body.customer_account_ids, + req.admin.id + ); + } + } catch (e) { + logger.error('Failed to set customer assignments on event update', { + eventId: id, error: e.message, + }); + } + } + + // Log activity + await logActivity('event_updated', + { changes: Object.keys(updates), eventName: event.event_name }, + id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + // Invalidate download zip if watermark settings changed + const changeKeys = Object.keys(req.body); + if (changeKeys.includes('watermark_downloads') || changeKeys.includes('watermark_text')) { + downloadZipService.invalidate(parseInt(id)); + } + + res.json({ message: 'Event updated successfully' }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to update event'); + } + }); + + // Delete event + router.delete('/:id', adminAuth, requirePermission('events.delete'), requireEventOwnership, async (req, res) => { + try { + const { id } = req.params; + await deleteEventCascade(id, { id: req.admin.id, username: req.admin.username }); + res.json({ message: 'Event deleted successfully' }); + } catch (error) { + if (error.code === 'EVENT_NOT_FOUND') { + return res.status(404).json({ error: 'Event not found' }); + } + logger.error('Error deleting event', { eventId: req.params.id, error: error.message }); + if (error.message && error.message.includes('foreign key constraint')) { + return res.status(500).json({ + error: 'Cannot delete event due to existing references. Please contact support.' }); } + res.status(500).json({ error: 'Failed to delete event' }); } + }); - // Log activity - await logActivity('event_updated', - { changes: Object.keys(updates), eventName: event.event_name }, - id, - { type: 'admin', id: req.admin.id, name: req.admin.username } - ); + // Toggle event status + router.post('/:id/toggle-status', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => { + try { + const { id } = req.params; - // Invalidate download zip if watermark settings changed - const changeKeys = Object.keys(req.body); - if (changeKeys.includes('watermark_downloads') || changeKeys.includes('watermark_text')) { - downloadZipService.invalidate(parseInt(id)); - } + let eventQuery = db('events').where('id', id); + // Editor role can only edit their own events + if (req.admin.roleName === 'editor') { + eventQuery = eventQuery.where('created_by', req.admin.id); + } + const event = await eventQuery.first(); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } - res.json({ message: 'Event updated successfully' }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to update event'); - } -}); + const newStatus = !event.is_active; + await db('events') + .where('id', id) + .update({ + is_active: newStatus, + updated_at: new Date() + }); -// Delete event -router.delete('/:id', adminAuth, requirePermission('events.delete'), requireEventOwnership, async (req, res) => { - try { - const { id } = req.params; - await deleteEventCascade(id, { id: req.admin.id, username: req.admin.username }); - res.json({ message: 'Event deleted successfully' }); - } catch (error) { - if (error.code === 'EVENT_NOT_FOUND') { - return res.status(404).json({ error: 'Event not found' }); - } - logger.error('Error deleting event', { eventId: req.params.id, error: error.message }); - if (error.message && error.message.includes('foreign key constraint')) { - return res.status(500).json({ - error: 'Cannot delete event due to existing references. Please contact support.' + // Log activity + await logActivity(newStatus ? 'event_activated' : 'event_deactivated', + { eventName: event.event_name }, + id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ + message: `Event ${newStatus ? 'activated' : 'deactivated'} successfully`, + is_active: newStatus }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to toggle event status'); } - res.status(500).json({ error: 'Failed to delete event' }); - } -}); - -// Toggle event status -router.post('/:id/toggle-status', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => { - try { - const { id } = req.params; - - let eventQuery = db('events').where('id', id); - // Editor role can only edit their own events - if (req.admin.roleName === 'editor') { - eventQuery = eventQuery.where('created_by', req.admin.id); - } - const event = await eventQuery.first(); - if (!event) { - return res.status(404).json({ error: 'Event not found' }); - } - - const newStatus = !event.is_active; - await db('events') - .where('id', id) - .update({ - is_active: newStatus, - updated_at: new Date() - }); - - // Log activity - await logActivity(newStatus ? 'event_activated' : 'event_deactivated', - { eventName: event.event_name }, - id, - { type: 'admin', id: req.admin.id, name: req.admin.username } - ); - - res.json({ - message: `Event ${newStatus ? 'activated' : 'deactivated'} successfully`, - is_active: newStatus - }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to toggle event status'); - } -}); + }); }; diff --git a/backend/src/routes/adminEvents/logo.js b/backend/src/routes/adminEvents/logo.js index 86cf90bf..2e33fee6 100644 --- a/backend/src/routes/adminEvents/logo.js +++ b/backend/src/routes/adminEvents/logo.js @@ -44,102 +44,102 @@ const eventLogoUpload = multer({ module.exports = (router) => { -// Upload event custom logo -router.post('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, eventLogoUpload.single('logo'), async (req, res) => { - try { - const { id } = req.params; + // Upload event custom logo + router.post('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, eventLogoUpload.single('logo'), async (req, res) => { + try { + const { id } = req.params; - // Check if event exists - let eventQuery = db('events').where('id', id); - if (req.admin.roleName === 'editor') { - eventQuery = eventQuery.where('created_by', req.admin.id); - } - const event = await eventQuery.first(); - if (!event) { - return res.status(404).json({ error: 'Event not found' }); - } - - if (!req.file) { - return res.status(400).json({ error: 'No logo file provided' }); - } - - // Delete old logo file if exists - if (event.hero_logo_path) { - try { - await fs.unlink(event.hero_logo_path); - logger.debug('Deleted old event logo file', { path: event.hero_logo_path }); - } catch (err) { - logger.warn('Failed to delete old event logo file', { path: event.hero_logo_path, error: err.message }); + // Check if event exists + let eventQuery = db('events').where('id', id); + if (req.admin.roleName === 'editor') { + eventQuery = eventQuery.where('created_by', req.admin.id); } - } - - const logoUrl = `/uploads/logos/events/${req.file.filename}`; - const logoPath = req.file.path; - - await db('events') - .where('id', id) - .update({ - hero_logo_url: logoUrl, - hero_logo_path: logoPath - }); - - await logActivity('event_logo_uploaded', - { eventName: event.event_name, filename: req.file.filename }, - id, - { type: 'admin', id: req.admin.id, name: req.admin.username } - ); - - res.json({ - message: 'Event logo uploaded successfully', - hero_logo_url: logoUrl - }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to upload event logo'); - } -}); - -// Delete event custom logo -router.delete('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => { - try { - const { id } = req.params; - - let eventQuery = db('events').where('id', id); - if (req.admin.roleName === 'editor') { - eventQuery = eventQuery.where('created_by', req.admin.id); - } - const event = await eventQuery.first(); - if (!event) { - return res.status(404).json({ error: 'Event not found' }); - } - - // Delete logo file if exists - if (event.hero_logo_path) { - try { - await fs.unlink(event.hero_logo_path); - logger.debug('Deleted event logo file', { path: event.hero_logo_path }); - } catch (err) { - logger.warn('Failed to delete event logo file', { path: event.hero_logo_path, error: err.message }); + const event = await eventQuery.first(); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); } - } - await db('events') - .where('id', id) - .update({ - hero_logo_url: null, - hero_logo_path: null + if (!req.file) { + return res.status(400).json({ error: 'No logo file provided' }); + } + + // Delete old logo file if exists + if (event.hero_logo_path) { + try { + await fs.unlink(event.hero_logo_path); + logger.debug('Deleted old event logo file', { path: event.hero_logo_path }); + } catch (err) { + logger.warn('Failed to delete old event logo file', { path: event.hero_logo_path, error: err.message }); + } + } + + const logoUrl = `/uploads/logos/events/${req.file.filename}`; + const logoPath = req.file.path; + + await db('events') + .where('id', id) + .update({ + hero_logo_url: logoUrl, + hero_logo_path: logoPath + }); + + await logActivity('event_logo_uploaded', + { eventName: event.event_name, filename: req.file.filename }, + id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ + message: 'Event logo uploaded successfully', + hero_logo_url: logoUrl }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to upload event logo'); + } + }); - await logActivity('event_logo_removed', - { eventName: event.event_name }, - id, - { type: 'admin', id: req.admin.id, name: req.admin.username } - ); + // Delete event custom logo + router.delete('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => { + try { + const { id } = req.params; - res.json({ message: 'Event logo removed successfully' }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to delete event logo'); - } -}); + let eventQuery = db('events').where('id', id); + if (req.admin.roleName === 'editor') { + eventQuery = eventQuery.where('created_by', req.admin.id); + } + const event = await eventQuery.first(); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + // Delete logo file if exists + if (event.hero_logo_path) { + try { + await fs.unlink(event.hero_logo_path); + logger.debug('Deleted event logo file', { path: event.hero_logo_path }); + } catch (err) { + logger.warn('Failed to delete event logo file', { path: event.hero_logo_path, error: err.message }); + } + } + + await db('events') + .where('id', id) + .update({ + hero_logo_url: null, + hero_logo_path: null + }); + + await logActivity('event_logo_removed', + { eventName: event.event_name }, + id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ message: 'Event logo removed successfully' }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to delete event logo'); + } + }); }; diff --git a/backend/src/routes/adminEvents/resets.js b/backend/src/routes/adminEvents/resets.js index 56de957b..f1d97aeb 100644 --- a/backend/src/routes/adminEvents/resets.js +++ b/backend/src/routes/adminEvents/resets.js @@ -16,70 +16,137 @@ const { requireEventOwnership } = require('../../middleware/ownership'); module.exports = (router) => { -// Reset event password -router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => { - try { - const { id } = req.params; - const { sendEmail = true, password: clientPassword } = req.body; + // Reset event password + router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => { + try { + const { id } = req.params; + const { sendEmail = true, password: clientPassword } = req.body; - let eventQuery = db('events').where('id', id); - // Editor role can only edit their own events - if (req.admin.roleName === 'editor') { - eventQuery = eventQuery.where('created_by', req.admin.id); - } - const event = await eventQuery.first(); - if (!event) { - return res.status(404).json({ error: 'Event not found' }); - } + let eventQuery = db('events').where('id', id); + // Editor role can only edit their own events + if (req.admin.roleName === 'editor') { + eventQuery = eventQuery.where('created_by', req.admin.id); + } + const event = await eventQuery.first(); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } - if (event.is_archived) { - return res.status(400).json({ error: 'Cannot reset password for archived event' }); - } + if (event.is_archived) { + return res.status(400).json({ error: 'Cannot reset password for archived event' }); + } - // Use the admin-supplied password when provided; otherwise auto-generate - // (preserves the previous one-click behaviour for callers/cron that don't - // pass a body). Validation matches the create-event flow so the same - // strength rules apply both ways. - let newPassword; - if (typeof clientPassword === 'string' && clientPassword.length > 0) { - const passwordValidation = await validatePasswordInContext(clientPassword, 'gallery', { - eventName: event.event_name - }); - if (!passwordValidation.valid) { - return res.status(400).json({ - error: 'Password does not meet security requirements', - details: passwordValidation.errors, - score: passwordValidation.score, - feedback: passwordValidation.feedback + // Use the admin-supplied password when provided; otherwise auto-generate + // (preserves the previous one-click behaviour for callers/cron that don't + // pass a body). Validation matches the create-event flow so the same + // strength rules apply both ways. + let newPassword; + if (typeof clientPassword === 'string' && clientPassword.length > 0) { + const passwordValidation = await validatePasswordInContext(clientPassword, 'gallery', { + eventName: event.event_name + }); + if (!passwordValidation.valid) { + return res.status(400).json({ + error: 'Password does not meet security requirements', + details: passwordValidation.errors, + score: passwordValidation.score, + feedback: passwordValidation.feedback + }); + } + newPassword = clientPassword; + } else { + const { generateReadablePassword } = require('../../utils/passwordGenerator'); + newPassword = generateReadablePassword(); + } + const passwordHash = await bcrypt.hash(newPassword, getBcryptRounds()); + + // Update event with new password + await db('events') + .where('id', id) + .update({ + password_hash: passwordHash + }); + + // Log activity + await logActivity('password_reset', + { eventName: event.event_name, emailSent: sendEmail }, + id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + // Queue email notification if requested + if (sendEmail) { + const recipientEmail = event.customer_email || event.host_email; + const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null); + // event.share_link is the path-only form (`/gallery//`). + // Use the full URL so customers can click straight from the email. + const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token }); + + await queueEmail(id, recipientEmail, 'gallery_created', { + customer_name: recipientName, + customer_email: recipientEmail, + host_name: recipientName, + event_name: event.event_name, + event_date: event.event_date, // Pass raw date - will be formatted by email processor + gallery_link: shareUrl, + gallery_password: newPassword, + expiry_date: event.expires_at // Pass raw date - will be formatted by email processor }); } - newPassword = clientPassword; - } else { - const { generateReadablePassword } = require('../../utils/passwordGenerator'); - newPassword = generateReadablePassword(); - } - const passwordHash = await bcrypt.hash(newPassword, getBcryptRounds()); - // Update event with new password - await db('events') - .where('id', id) - .update({ - password_hash: passwordHash + res.json({ + message: 'Password reset successfully', + newPassword: newPassword, + emailSent: sendEmail }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to reset password'); + } + }); - // Log activity - await logActivity('password_reset', - { eventName: event.event_name, emailSent: sendEmail }, - id, - { type: 'admin', id: req.admin.id, name: req.admin.username } - ); + // Resend creation email + router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => { + try { + const { id } = req.params; - // Queue email notification if requested - if (sendEmail) { + // Get event details + let eventQuery = db('events').where('id', id); + // Editor role can only edit their own events + if (req.admin.roleName === 'editor') { + eventQuery = eventQuery.where('created_by', req.admin.id); + } + const event = await eventQuery.first(); + + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + // The email processor will determine the language based on: + // 1. Event language setting + // 2. App settings general_default_language + // 3. Email config default language + // 4. Domain-based detection + // So we don't need to determine it here + + // For resending creation email, we need the actual password + // First, try to get it from the request body if provided + // Use optional chaining to handle cases where req.body might be undefined + let galleryPassword = req.body?.password; + + // If no password provided, we can't decrypt the existing one + // So we'll show a security message + if (!galleryPassword) { + // We'll let the email processor determine the language for the security message + galleryPassword = '{{password_security_message}}'; + } + + // Dates will be formatted by the email processor based on recipient language + + // Queue the email const recipientEmail = event.customer_email || event.host_email; const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null); - // event.share_link is the path-only form (`/gallery//`). - // Use the full URL so customers can click straight from the email. + // event.share_link is the path-only form; use the full URL so the + // customer's mail client renders a clickable absolute link. const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token }); await queueEmail(id, recipientEmail, 'gallery_created', { @@ -89,105 +156,38 @@ router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'), event_name: event.event_name, event_date: event.event_date, // Pass raw date - will be formatted by email processor gallery_link: shareUrl, - gallery_password: newPassword, - expiry_date: event.expires_at // Pass raw date - will be formatted by email processor + gallery_password: galleryPassword, + expiry_date: event.expires_at, // Pass raw date - will be formatted by email processor + welcome_message: event.welcome_message || '', + eventId: id, + isResend: true // Flag to indicate this is a resend }); - } - - res.json({ - message: 'Password reset successfully', - newPassword: newPassword, - emailSent: sendEmail - }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to reset password'); - } -}); - -// Resend creation email -router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => { - try { - const { id } = req.params; - - // Get event details - let eventQuery = db('events').where('id', id); - // Editor role can only edit their own events - if (req.admin.roleName === 'editor') { - eventQuery = eventQuery.where('created_by', req.admin.id); - } - const event = await eventQuery.first(); - - if (!event) { - return res.status(404).json({ error: 'Event not found' }); - } - // The email processor will determine the language based on: - // 1. Event language setting - // 2. App settings general_default_language - // 3. Email config default language - // 4. Domain-based detection - // So we don't need to determine it here - - // For resending creation email, we need the actual password - // First, try to get it from the request body if provided - // Use optional chaining to handle cases where req.body might be undefined - let galleryPassword = req.body?.password; - - // If no password provided, we can't decrypt the existing one - // So we'll show a security message - if (!galleryPassword) { - // We'll let the email processor determine the language for the security message - galleryPassword = '{{password_security_message}}'; - } - - // Dates will be formatted by the email processor based on recipient language - - // Queue the email - const recipientEmail = event.customer_email || event.host_email; - const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null); - // event.share_link is the path-only form; use the full URL so the - // customer's mail client renders a clickable absolute link. - const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token }); - - await queueEmail(id, recipientEmail, 'gallery_created', { - customer_name: recipientName, - customer_email: recipientEmail, - host_name: recipientName, - event_name: event.event_name, - event_date: event.event_date, // Pass raw date - will be formatted by email processor - gallery_link: shareUrl, - gallery_password: galleryPassword, - expiry_date: event.expires_at, // Pass raw date - will be formatted by email processor - welcome_message: event.welcome_message || '', - eventId: id, - isResend: true // Flag to indicate this is a resend - }); - - // Log the activity using the proper schema - try { - await logActivity('email_resent', { - email_type: 'gallery_created', - recipient: recipientEmail, - ip_address: req.ip || '0.0.0.0', - user_agent: req.get('user-agent') || 'Unknown' - }, id, { - type: 'admin', - id: req.admin.id, - name: req.admin.username - }); - } catch (logError) { - logger.error('Warning: Failed to log activity:', logError); + // Log the activity using the proper schema + try { + await logActivity('email_resent', { + email_type: 'gallery_created', + recipient: recipientEmail, + ip_address: req.ip || '0.0.0.0', + user_agent: req.get('user-agent') || 'Unknown' + }, id, { + type: 'admin', + id: req.admin.id, + name: req.admin.username + }); + } catch (logError) { + logger.error('Warning: Failed to log activity:', logError); // Don't fail the request if activity logging fails - } + } - res.json({ - success: true, - message: 'Creation email has been queued for sending' - }); - } catch (error) { - logger.error('Error resending creation email:', error); - errorResponse(res, error, 500, 'Failed to resend creation email'); - } -}); + res.json({ + success: true, + message: 'Creation email has been queued for sending' + }); + } catch (error) { + logger.error('Error resending creation email:', error); + errorResponse(res, error, 500, 'Failed to resend creation email'); + } + }); }; diff --git a/backend/src/routes/adminEvents/slideshow.js b/backend/src/routes/adminEvents/slideshow.js index fef17f8e..c22ccb57 100644 --- a/backend/src/routes/adminEvents/slideshow.js +++ b/backend/src/routes/adminEvents/slideshow.js @@ -40,112 +40,112 @@ async function loadOwnedEvent(req) { module.exports = (router) => { -// Generate (or rotate) the slideshow share token. Idempotent in intent: each -// call mints a fresh token, which both "Generate" (first time) and "Regenerate" -// (rotate, kills the old link) use. -router.post('/:id/slideshow/generate', adminAuth, requirePermission('events.edit'), requireFeatureFlag('slideshow'), requireEventOwnership, async (req, res) => { - try { - const event = await loadOwnedEvent(req); - if (!event) { - return res.status(404).json({ error: 'Event not found' }); + // Generate (or rotate) the slideshow share token. Idempotent in intent: each + // call mints a fresh token, which both "Generate" (first time) and "Regenerate" + // (rotate, kills the old link) use. + router.post('/:id/slideshow/generate', adminAuth, requirePermission('events.edit'), requireFeatureFlag('slideshow'), requireEventOwnership, async (req, res) => { + try { + const event = await loadOwnedEvent(req); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + const token = crypto.randomBytes(32).toString('hex'); + // NB: the events table has no updated_at column (only created_at), so we + // must not set it here or the UPDATE throws. + await db('events').where('id', req.params.id).update({ + show_share_token: token + }); + + await logActivity('slideshow_link_generated', + { eventName: event.event_name, rotated: Boolean(event.show_share_token) }, + req.params.id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ + show_share_token: token, + slideshow_url: await buildSlideshowUrl(event.slug, token) + }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to generate slideshow link'); } + }); - const token = crypto.randomBytes(32).toString('hex'); - // NB: the events table has no updated_at column (only created_at), so we - // must not set it here or the UPDATE throws. - await db('events').where('id', req.params.id).update({ - show_share_token: token - }); + // Disable the slideshow link (null the token). The public /show/ route dies on + // its next poll, killing any projector currently pointed at the old link. + router.post('/:id/slideshow/disable', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => { + try { + const event = await loadOwnedEvent(req); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } - await logActivity('slideshow_link_generated', - { eventName: event.event_name, rotated: Boolean(event.show_share_token) }, - req.params.id, - { type: 'admin', id: req.admin.id, name: req.admin.username } - ); + await db('events').where('id', req.params.id).update({ + show_share_token: null + }); - res.json({ - show_share_token: token, - slideshow_url: await buildSlideshowUrl(event.slug, token) - }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to generate slideshow link'); - } -}); + await logActivity('slideshow_link_disabled', + { eventName: event.event_name }, + req.params.id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); -// Disable the slideshow link (null the token). The public /show/ route dies on -// its next poll, killing any projector currently pointed at the old link. -router.post('/:id/slideshow/disable', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => { - try { - const event = await loadOwnedEvent(req); - if (!event) { - return res.status(404).json({ error: 'Event not found' }); + res.json({ show_share_token: null }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to disable slideshow link'); } + }); - await db('events').where('id', req.params.id).update({ - show_share_token: null - }); + // Update the LIVE slideshow settings (display time / transition style / speed). + // A running projector picks these up via the show-page settings poll within a + // few seconds — no need to regenerate the link. + router.patch('/:id/slideshow', adminAuth, requirePermission('events.edit'), requireFeatureFlag('slideshow'), requireEventOwnership, [ + body('show_interval_ms').optional().isInt({ min: 1000, max: 120000 }), + body('show_transition').optional().isIn(SLIDESHOW_TRANSITIONS), + body('show_transition_ms').optional().isInt({ min: 100, max: 5000 }), + body('show_watermark').optional({ nullable: true }), + body('show_colorfilter').optional().isIn(SLIDESHOW_COLORFILTERS) + ], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ error: 'Invalid slideshow settings', details: errors.array() }); + } - await logActivity('slideshow_link_disabled', - { eventName: event.event_name }, - req.params.id, - { type: 'admin', id: req.admin.id, name: req.admin.username } - ); + const event = await loadOwnedEvent(req); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } - res.json({ show_share_token: null }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to disable slideshow link'); - } -}); + // events has no updated_at column — don't set it. + const updates = {}; + if (req.body.show_interval_ms !== undefined) updates.show_interval_ms = parseInt(req.body.show_interval_ms, 10); + if (req.body.show_transition !== undefined) updates.show_transition = req.body.show_transition; + if (req.body.show_transition_ms !== undefined) updates.show_transition_ms = parseInt(req.body.show_transition_ms, 10); + // Tri-state: explicit null = inherit the global default. + if (req.body.show_watermark !== undefined) { + updates.show_watermark = req.body.show_watermark === null + ? null + : formatBoolean(parseBooleanInput(req.body.show_watermark, false)); + } + if (req.body.show_colorfilter !== undefined) updates.show_colorfilter = req.body.show_colorfilter; -// Update the LIVE slideshow settings (display time / transition style / speed). -// A running projector picks these up via the show-page settings poll within a -// few seconds — no need to regenerate the link. -router.patch('/:id/slideshow', adminAuth, requirePermission('events.edit'), requireFeatureFlag('slideshow'), requireEventOwnership, [ - body('show_interval_ms').optional().isInt({ min: 1000, max: 120000 }), - body('show_transition').optional().isIn(SLIDESHOW_TRANSITIONS), - body('show_transition_ms').optional().isInt({ min: 100, max: 5000 }), - body('show_watermark').optional({ nullable: true }), - body('show_colorfilter').optional().isIn(SLIDESHOW_COLORFILTERS) -], async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ error: 'Invalid slideshow settings', details: errors.array() }); + // Knex throws on an empty update; only write if something changed. + if (Object.keys(updates).length > 0) { + await db('events').where('id', req.params.id).update(updates); + } + + res.json({ + show_interval_ms: updates.show_interval_ms ?? event.show_interval_ms ?? 5000, + show_transition: updates.show_transition ?? event.show_transition ?? 'crossfade', + show_transition_ms: updates.show_transition_ms ?? event.show_transition_ms ?? 800, + show_watermark: updates.show_watermark ?? event.show_watermark ?? null, + show_colorfilter: updates.show_colorfilter ?? event.show_colorfilter ?? 'none' + }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to update slideshow settings'); } - - const event = await loadOwnedEvent(req); - if (!event) { - return res.status(404).json({ error: 'Event not found' }); - } - - // events has no updated_at column — don't set it. - const updates = {}; - if (req.body.show_interval_ms !== undefined) updates.show_interval_ms = parseInt(req.body.show_interval_ms, 10); - if (req.body.show_transition !== undefined) updates.show_transition = req.body.show_transition; - if (req.body.show_transition_ms !== undefined) updates.show_transition_ms = parseInt(req.body.show_transition_ms, 10); - // Tri-state: explicit null = inherit the global default. - if (req.body.show_watermark !== undefined) { - updates.show_watermark = req.body.show_watermark === null - ? null - : formatBoolean(parseBooleanInput(req.body.show_watermark, false)); - } - if (req.body.show_colorfilter !== undefined) updates.show_colorfilter = req.body.show_colorfilter; - - // Knex throws on an empty update; only write if something changed. - if (Object.keys(updates).length > 0) { - await db('events').where('id', req.params.id).update(updates); - } - - res.json({ - show_interval_ms: updates.show_interval_ms ?? event.show_interval_ms ?? 5000, - show_transition: updates.show_transition ?? event.show_transition ?? 'crossfade', - show_transition_ms: updates.show_transition_ms ?? event.show_transition_ms ?? 800, - show_watermark: updates.show_watermark ?? event.show_watermark ?? null, - show_colorfilter: updates.show_colorfilter ?? event.show_colorfilter ?? 'none' - }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to update slideshow settings'); - } -}); + }); }; diff --git a/backend/src/routes/adminGuests.js b/backend/src/routes/adminGuests.js index fb7d6ddc..2f24b2f7 100644 --- a/backend/src/routes/adminGuests.js +++ b/backend/src/routes/adminGuests.js @@ -74,10 +74,10 @@ router.get( 'gallery_guests.created_at', 'gallery_guests.last_seen_at', 'gallery_guests.email_verified_at', - db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'like' THEN 1 END) AS likes"), - db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'favorite' THEN 1 END) AS favorites"), - db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'comment' THEN 1 END) AS comments"), - db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'rating' THEN 1 END) AS ratings"), + db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'like\' THEN 1 END) AS likes'), + db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'favorite\' THEN 1 END) AS favorites'), + db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'comment\' THEN 1 END) AS comments'), + db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'rating\' THEN 1 END) AS ratings'), db.raw('COUNT(DISTINCT photo_feedback.photo_id) AS distinct_photos') ) .orderBy('gallery_guests.created_at', 'desc'); @@ -117,7 +117,7 @@ router.get( const photos = await db('photos') .leftJoin('photo_feedback', function () { this.on('photo_feedback.photo_id', '=', 'photos.id') - .andOn(db.raw("photo_feedback.feedback_type IN ('like','favorite')")) + .andOn(db.raw('photo_feedback.feedback_type IN (\'like\',\'favorite\')')) .andOnNotNull('photo_feedback.guest_id'); }) .where('photos.event_id', eventId) diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index 9eeb022b..3dcd18f3 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -1251,7 +1251,7 @@ router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photo // Validate file size (max 10GB) const maxSize = 10 * 1024 * 1024 * 1024; if (fileSize > maxSize) { - return res.status(400).json({ error: `File too large. Maximum size is 10GB.` }); + return res.status(400).json({ error: 'File too large. Maximum size is 10GB.' }); } const result = await chunkedUpload.initializeUpload({ diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 159c8c8a..d2e25168 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -1299,23 +1299,23 @@ router.get('/storage/info', adminAuth, requirePermission('settings.view'), async } switch (setting.setting_key) { - case 'general_storage_soft_limit_bytes': - if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) { - configuredSoftLimit = parsedValue; - } - break; - case 'general_storage_capacity_override_bytes': - if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) { - capacityOverrideDb = parsedValue; - } - break; - case 'general_storage_available_override_bytes': - if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) { - availableOverrideDb = parsedValue; - } - break; - default: - break; + case 'general_storage_soft_limit_bytes': + if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) { + configuredSoftLimit = parsedValue; + } + break; + case 'general_storage_capacity_override_bytes': + if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) { + capacityOverrideDb = parsedValue; + } + break; + case 'general_storage_available_override_bytes': + if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) { + availableOverrideDb = parsedValue; + } + break; + default: + break; } }); } catch (error) { diff --git a/backend/src/services/backupService.js b/backend/src/services/backupService.js index 85899870..6d9580dc 100644 --- a/backend/src/services/backupService.js +++ b/backend/src/services/backupService.js @@ -1177,7 +1177,7 @@ async function runBackupInternal(isManual = false) { async function startBackupService() { try { - const config = await resolveConfigWithFallback(); + const config = await resolveConfigWithFallback(); if (!config || !normalizeBoolean(config.backup_enabled)) { if (backupJob) { backupJob.stop(); diff --git a/backend/src/services/contract/crud.js b/backend/src/services/contract/crud.js index 7a84c7b8..f5d834f5 100644 --- a/backend/src/services/contract/crud.js +++ b/backend/src/services/contract/crud.js @@ -51,29 +51,29 @@ async function listContracts({ filters = {}, sort = 'issue_desc', page = 1, page const total = ensureInt(totalRow?.total || 0); switch (sort) { - case 'oldest': - query = query.orderBy('contracts.created_at', 'asc').orderBy('contracts.id', 'asc'); - break; - case 'issue_asc': - query = query.orderBy('contracts.issue_date', 'asc').orderBy('contracts.id', 'asc'); - break; - case 'issue_desc': - query = query.orderBy('contracts.issue_date', 'desc').orderBy('contracts.id', 'desc'); - break; - case 'customer_asc': - query = query - .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc') - .orderBy('contracts.id', 'desc'); - break; - case 'customer_desc': - query = query - .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc') - .orderBy('contracts.id', 'desc'); - break; - case 'newest': - default: - query = query.orderBy('contracts.created_at', 'desc').orderBy('contracts.id', 'desc'); - break; + case 'oldest': + query = query.orderBy('contracts.created_at', 'asc').orderBy('contracts.id', 'asc'); + break; + case 'issue_asc': + query = query.orderBy('contracts.issue_date', 'asc').orderBy('contracts.id', 'asc'); + break; + case 'issue_desc': + query = query.orderBy('contracts.issue_date', 'desc').orderBy('contracts.id', 'desc'); + break; + case 'customer_asc': + query = query + .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc') + .orderBy('contracts.id', 'desc'); + break; + case 'customer_desc': + query = query + .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc') + .orderBy('contracts.id', 'desc'); + break; + case 'newest': + default: + query = query.orderBy('contracts.created_at', 'desc').orderBy('contracts.id', 'desc'); + break; } const offset = Math.max(0, (page - 1) * pageSize); diff --git a/backend/src/services/contract/renderContext.js b/backend/src/services/contract/renderContext.js index ddae552a..28f2ede4 100644 --- a/backend/src/services/contract/renderContext.js +++ b/backend/src/services/contract/renderContext.js @@ -78,8 +78,8 @@ async function buildPlaceholderContext(contract, customer) { : ''; const customerAddress = customer ? [customer.address_line1, customer.address_line2, customer.postal_code, customer.city] - .filter(Boolean) - .join(', ') + .filter(Boolean) + .join(', ') : ''; return { diff --git a/backend/src/services/contract/signatures.js b/backend/src/services/contract/signatures.js index 22c2c9ce..d40e9ff8 100644 --- a/backend/src/services/contract/signatures.js +++ b/backend/src/services/contract/signatures.js @@ -73,21 +73,21 @@ async function recordCustomerSignature({ token, name, ip, signatureDataUrl, acce // consistent and saves a redundant read. const persistedIp = await maybeStoreIp(ip); try { - await db.transaction(async (trx) => { - await trx('contracts').where({ id: contract.id }).update({ - status: 'signed_by_customer', - signed_by_customer_at: now, - signed_customer_name: String(name).trim(), - signed_customer_ip: persistedIp, - signed_customer_signature_path: signaturePath, - updated_at: now, + await db.transaction(async (trx) => { + await trx('contracts').where({ id: contract.id }).update({ + status: 'signed_by_customer', + signed_by_customer_at: now, + signed_customer_name: String(name).trim(), + signed_customer_ip: persistedIp, + signed_customer_signature_path: signaturePath, + updated_at: now, + }); + await trx('contract_action_tokens').where({ id: tokenRow.id }).update({ + used_at: now, + used_action: 'signed_by_customer', + used_ip: persistedIp, + }); }); - await trx('contract_action_tokens').where({ id: tokenRow.id }).update({ - used_at: now, - used_action: 'signed_by_customer', - used_ip: persistedIp, - }); - }); } catch (txErr) { // C.7 — clean up the orphan signature PNG we wrote before the // transaction. The DB rollback already undid the contract + @@ -230,14 +230,14 @@ async function recordAdminCountersignature(contractId, { name, ip, signatureData const newStatus = contract.status === 'signed_by_customer' ? 'fully_signed' : 'signed_by_admin'; const persistedAdminIp = await maybeStoreIp(ip); try { - await db('contracts').where({ id: contract.id }).update({ - status: newStatus, - signed_by_admin_at: now, - signed_admin_name: String(name).trim(), - signed_admin_ip: persistedAdminIp, - signed_admin_signature_path: signaturePath, - updated_at: now, - }); + await db('contracts').where({ id: contract.id }).update({ + status: newStatus, + signed_by_admin_at: now, + signed_admin_name: String(name).trim(), + signed_admin_ip: persistedAdminIp, + signed_admin_signature_path: signaturePath, + updated_at: now, + }); } catch (updateErr) { // C.7 — clean up the orphan signature PNG if the contract row // update threw. Best-effort; log on cleanup failure and re-throw diff --git a/backend/src/services/invoice/create.js b/backend/src/services/invoice/create.js index eb22be88..4ee8ac9f 100644 --- a/backend/src/services/invoice/create.js +++ b/backend/src/services/invoice/create.js @@ -329,11 +329,11 @@ async function createInvoice(payload, adminId, trx = db) { * code should reach for the clearer `spawnInstallmentInvoices`. */ async function spawnInstallmentInvoices({ trx, eventId, quoteId, customer, currency, language, - lineItems, totals, installments, eventDate, adminId, - ccPdfEmail, netDays, - eventName, eventTimeStart, eventTimeEnd, - paymentNetDaysTemplateId, paymentTimingTemplateId, - paymentTermSnapshot, dealUuid, hold = false }) { + lineItems, totals, installments, eventDate, adminId, + ccPdfEmail, netDays, + eventName, eventTimeStart, eventTimeEnd, + paymentNetDaysTemplateId, paymentTimingTemplateId, + paymentTermSnapshot, dealUuid, hold = false }) { // Monthly-billing intercept (migration 128). Quote → invoice // conversion for a monthly-mode customer doesn't fan out N // installment invoices — the customer pays one consolidated bill diff --git a/backend/src/services/invoice/helpers.js b/backend/src/services/invoice/helpers.js index 22fa656c..0b0c6ea9 100644 --- a/backend/src/services/invoice/helpers.js +++ b/backend/src/services/invoice/helpers.js @@ -45,20 +45,20 @@ function computeScheduledSendAt(trigger, offsetDays, eventDate, baseDate = new D const offset = ensureInt(offsetDays) * ms; const eventTs = eventDate ? new Date(eventDate).getTime() : null; switch (trigger) { - case 'quote_accepted': - return new Date(baseDate.getTime() + offset); - case 'before_event': - case 'after_event': - if (!eventTs) return new Date(baseDate.getTime() + offset); - return new Date(eventTs + offset); - case 'after_delivery': - // Treat as event_date + 14 days as a sensible default; admin can - // edit the scheduled_send_at on the invoice later. - if (!eventTs) return new Date(baseDate.getTime() + 14 * ms + offset); - return new Date(eventTs + 14 * ms + offset); - case 'fixed_date': - default: - return new Date(baseDate.getTime() + offset); + case 'quote_accepted': + return new Date(baseDate.getTime() + offset); + case 'before_event': + case 'after_event': + if (!eventTs) return new Date(baseDate.getTime() + offset); + return new Date(eventTs + offset); + case 'after_delivery': + // Treat as event_date + 14 days as a sensible default; admin can + // edit the scheduled_send_at on the invoice later. + if (!eventTs) return new Date(baseDate.getTime() + 14 * ms + offset); + return new Date(eventTs + 14 * ms + offset); + case 'fixed_date': + default: + return new Date(baseDate.getTime() + offset); } } diff --git a/backend/src/services/invoice/installmentPlan.js b/backend/src/services/invoice/installmentPlan.js index 65f4c771..fec5cf83 100644 --- a/backend/src/services/invoice/installmentPlan.js +++ b/backend/src/services/invoice/installmentPlan.js @@ -116,7 +116,7 @@ async function replaceReconciliationLine( const subtotal = topLineSubtotal != null ? topLineSubtotal : nonRecon.filter((x) => x.parent_position == null) - .reduce((s, x) => s + ensureInt(x.line_total_minor), 0); + .reduce((s, x) => s + ensureInt(x.line_total_minor), 0); const adjustment = netSlice - subtotal; if (adjustment === 0) return; diff --git a/backend/src/services/invoice/queries.js b/backend/src/services/invoice/queries.js index dafe13fb..e85fbbad 100644 --- a/backend/src/services/invoice/queries.js +++ b/backend/src/services/invoice/queries.js @@ -67,35 +67,35 @@ async function listInvoices({ filters = {}, sort = 'issue_desc', page = 1, pageS const total = ensureInt(countRow?.total || 0); switch (sort) { - // "Newest" / "Oldest" means newest/oldest by CREATION time, not - // by issue_date. Issue_date is admin-controlled (used for tax - // accruals, retro-dating, future-dating) so it can drift from - // actual chronology — sorting by it makes a just-created invoice - // disappear into the middle of the list whenever its issue_date - // is set to something other than today. created_at always - // reflects when the row landed in the DB. id is the tiebreaker - // for rows that share a created_at second. - case 'oldest': query = query.orderBy('invoices.created_at', 'asc').orderBy('invoices.id', 'asc'); break; - case 'issue_asc': query = query.orderBy('invoices.issue_date', 'asc').orderBy('invoices.id', 'asc'); break; - case 'issue_desc': query = query.orderBy('invoices.issue_date', 'desc').orderBy('invoices.id', 'desc'); break; - case 'due_asc': query = query.orderBy('invoices.due_date', 'asc'); break; - case 'due_desc': query = query.orderBy('invoices.due_date', 'desc'); break; - case 'value_asc': query = query.orderBy('invoices.total_amount_minor', 'asc'); break; - case 'value_desc': query = query.orderBy('invoices.total_amount_minor', 'desc'); break; - case 'customer_asc': - query = query - .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc') - .orderBy('invoices.id', 'desc'); - break; - case 'customer_desc': - query = query - .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc') - .orderBy('invoices.id', 'desc'); - break; - case 'newest': - default: - query = query.orderBy('invoices.created_at', 'desc').orderBy('invoices.id', 'desc'); - break; + // "Newest" / "Oldest" means newest/oldest by CREATION time, not + // by issue_date. Issue_date is admin-controlled (used for tax + // accruals, retro-dating, future-dating) so it can drift from + // actual chronology — sorting by it makes a just-created invoice + // disappear into the middle of the list whenever its issue_date + // is set to something other than today. created_at always + // reflects when the row landed in the DB. id is the tiebreaker + // for rows that share a created_at second. + case 'oldest': query = query.orderBy('invoices.created_at', 'asc').orderBy('invoices.id', 'asc'); break; + case 'issue_asc': query = query.orderBy('invoices.issue_date', 'asc').orderBy('invoices.id', 'asc'); break; + case 'issue_desc': query = query.orderBy('invoices.issue_date', 'desc').orderBy('invoices.id', 'desc'); break; + case 'due_asc': query = query.orderBy('invoices.due_date', 'asc'); break; + case 'due_desc': query = query.orderBy('invoices.due_date', 'desc'); break; + case 'value_asc': query = query.orderBy('invoices.total_amount_minor', 'asc'); break; + case 'value_desc': query = query.orderBy('invoices.total_amount_minor', 'desc'); break; + case 'customer_asc': + query = query + .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc') + .orderBy('invoices.id', 'desc'); + break; + case 'customer_desc': + query = query + .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc') + .orderBy('invoices.id', 'desc'); + break; + case 'newest': + default: + query = query.orderBy('invoices.created_at', 'desc').orderBy('invoices.id', 'desc'); + break; } const offset = Math.max(0, (page - 1) * pageSize); diff --git a/backend/src/services/invoice/sending.js b/backend/src/services/invoice/sending.js index 1645d4f1..62bc8f45 100644 --- a/backend/src/services/invoice/sending.js +++ b/backend/src/services/invoice/sending.js @@ -358,7 +358,7 @@ async function sendStorno(stornoId, adminId) { // email body — customers' bookkeepers expect to see the pair. const originalRow = storno.cancels_invoice_id ? await db('invoices').where({ id: storno.cancels_invoice_id }) - .select('invoice_number', 'issue_date').first() + .select('invoice_number', 'issue_date').first() : null; const { to: stornoTo, cc: stornoCc } = resolveBillingRecipients(customer, storno.cc_pdf_email); diff --git a/backend/src/services/quoteService.js b/backend/src/services/quoteService.js index f1e78284..d2fd5dc5 100644 --- a/backend/src/services/quoteService.js +++ b/backend/src/services/quoteService.js @@ -400,40 +400,40 @@ async function listQuotes({ filters = {}, sort = 'issue_desc', page = 1, pageSiz const total = ensureInt(totalRow?.total || 0); switch (sort) { - // "Newest" / "Oldest" sort by CREATION time, not issue_date — - // the latter is admin-controlled (retro-dated quotes, future- - // dated quotes for accruals) and drifts from actual chronology. - // Sorting by created_at always puts a just-saved quote at the - // top of the "Newest first" list. - case 'oldest': - query = query.orderBy('quotes.created_at', 'asc').orderBy('quotes.id', 'asc'); - break; - case 'issue_asc': - query = query.orderBy('quotes.issue_date', 'asc').orderBy('quotes.id', 'asc'); - break; - case 'issue_desc': - query = query.orderBy('quotes.issue_date', 'desc').orderBy('quotes.id', 'desc'); - break; - case 'customer_asc': - query = query - .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc') - .orderBy('quotes.id', 'desc'); - break; - case 'customer_desc': - query = query - .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc') - .orderBy('quotes.id', 'desc'); - break; - case 'value_asc': - query = query.orderBy('quotes.total_amount_minor', 'asc'); - break; - case 'value_desc': - query = query.orderBy('quotes.total_amount_minor', 'desc'); - break; - case 'newest': - default: - query = query.orderBy('quotes.created_at', 'desc').orderBy('quotes.id', 'desc'); - break; + // "Newest" / "Oldest" sort by CREATION time, not issue_date — + // the latter is admin-controlled (retro-dated quotes, future- + // dated quotes for accruals) and drifts from actual chronology. + // Sorting by created_at always puts a just-saved quote at the + // top of the "Newest first" list. + case 'oldest': + query = query.orderBy('quotes.created_at', 'asc').orderBy('quotes.id', 'asc'); + break; + case 'issue_asc': + query = query.orderBy('quotes.issue_date', 'asc').orderBy('quotes.id', 'asc'); + break; + case 'issue_desc': + query = query.orderBy('quotes.issue_date', 'desc').orderBy('quotes.id', 'desc'); + break; + case 'customer_asc': + query = query + .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc') + .orderBy('quotes.id', 'desc'); + break; + case 'customer_desc': + query = query + .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc') + .orderBy('quotes.id', 'desc'); + break; + case 'value_asc': + query = query.orderBy('quotes.total_amount_minor', 'asc'); + break; + case 'value_desc': + query = query.orderBy('quotes.total_amount_minor', 'desc'); + break; + case 'newest': + default: + query = query.orderBy('quotes.created_at', 'desc').orderBy('quotes.id', 'desc'); + break; } const offset = Math.max(0, (page - 1) * pageSize); @@ -1508,8 +1508,8 @@ async function convertToInvoiceOnly(quoteId, adminId, options = {}) { const paymentTermSnapshot = quote.payment_term_snapshot ? (typeof quote.payment_term_snapshot === 'string' - ? JSON.parse(quote.payment_term_snapshot) - : quote.payment_term_snapshot) + ? JSON.parse(quote.payment_term_snapshot) + : quote.payment_term_snapshot) : null; const invoiceService = require('./invoiceService'); @@ -1621,8 +1621,8 @@ async function convertToEvent(quoteId, adminId, options = {}) { const paymentTermSnapshot = quote.payment_term_snapshot ? (typeof quote.payment_term_snapshot === 'string' - ? JSON.parse(quote.payment_term_snapshot) - : quote.payment_term_snapshot) + ? JSON.parse(quote.payment_term_snapshot) + : quote.payment_term_snapshot) : null; // Lazy import to avoid the circular dep. diff --git a/backend/src/services/restoreService.js b/backend/src/services/restoreService.js index 319c46e9..1932537e 100644 --- a/backend/src/services/restoreService.js +++ b/backend/src/services/restoreService.js @@ -200,20 +200,20 @@ class RestoreService { // Step 6: Perform the actual restore based on type let restoreResult; switch (options.restoreType) { - case 'full': - restoreResult = await this.performFullRestore(localBackupPath, manifest, options); - break; - case 'database': - restoreResult = await this.performDatabaseRestore(localBackupPath, manifest, options); - break; - case 'files': - restoreResult = await this.performFilesRestore(localBackupPath, manifest, options); - break; - case 'selective': - restoreResult = await this.performSelectiveRestore(localBackupPath, manifest, options); - break; - default: - throw new Error(`Unknown restore type: ${options.restoreType}`); + case 'full': + restoreResult = await this.performFullRestore(localBackupPath, manifest, options); + break; + case 'database': + restoreResult = await this.performDatabaseRestore(localBackupPath, manifest, options); + break; + case 'files': + restoreResult = await this.performFilesRestore(localBackupPath, manifest, options); + break; + case 'selective': + restoreResult = await this.performSelectiveRestore(localBackupPath, manifest, options); + break; + default: + throw new Error(`Unknown restore type: ${options.restoreType}`); } // Step 7: Post-restore verification @@ -308,7 +308,7 @@ class RestoreService { this.log('info', 'Post-restore migrations applied'); } catch (migErr) { this.log('warn', - `Post-restore migrate:safe failed — restore data is in place but the schema may lag the running image. ` + + 'Post-restore migrate:safe failed — restore data is in place but the schema may lag the running image. ' + `A container restart will retry via wait-for-db.sh. Error: ${migErr.message}`); } @@ -409,8 +409,8 @@ class RestoreService { if (restoreRun) { const failureMessage = rollbackAttempted ? (rollbackSucceeded - ? `${error.message} (rolled back successfully to pre-restore state)` - : `${error.message} | ROLLBACK ALSO FAILED: ${rollbackError} — destination is in a partial state, inspect before retrying`) + ? `${error.message} (rolled back successfully to pre-restore state)` + : `${error.message} | ROLLBACK ALSO FAILED: ${rollbackError} — destination is in a partial state, inspect before retrying`) : `${error.message} (no pre-restore backup available — destination may be partial)`; await db('restore_runs').where('id', restoreRun.id).update({ completed_at: new Date(), @@ -888,9 +888,9 @@ class RestoreService { throw new Error( `Database backup file not found. Tried: ${candidates.join(', ')}. ` + `Manifest recorded path: ${dbBackupFile}. ` + - `Hint: this usually means the manifest's database.backup_file path no longer ` + - `exists on disk (deleted? moved? volume not mounted?). Check ` + - `~//backup/database/ on the host.` + 'Hint: this usually means the manifest\'s database.backup_file path no longer ' + + 'exists on disk (deleted? moved? volume not mounted?). Check ' + + '~//backup/database/ on the host.' ); } @@ -1043,8 +1043,8 @@ class RestoreService { await spawnAsync('psql', [ '-h', host, '-p', String(port), '-U', user, '-d', maintenanceDb, '-c', - `SELECT pg_terminate_backend(pid) FROM pg_stat_activity ` + - `WHERE datname = '${database.replace(/'/g, "''")}' AND pid <> pg_backend_pid()`, + 'SELECT pg_terminate_backend(pid) FROM pg_stat_activity ' + + `WHERE datname = '${database.replace(/'/g, '\'\'')}' AND pid <> pg_backend_pid()`, ], { env }); // Drop and recreate database (extremely dangerous!) diff --git a/backend/src/services/storage/s3Storage.js b/backend/src/services/storage/s3Storage.js index 9679aadb..b3fd8766 100644 --- a/backend/src/services/storage/s3Storage.js +++ b/backend/src/services/storage/s3Storage.js @@ -494,14 +494,14 @@ class S3StorageAdapter extends stream.EventEmitter { let command; switch (operation.toLowerCase()) { - case 'getobject': - command = new GetObjectCommand(params); - break; - case 'putobject': - command = new PutObjectCommand(params); - break; - default: - throw new Error(`Unsupported operation: ${operation}`); + case 'getobject': + command = new GetObjectCommand(params); + break; + case 'putobject': + command = new PutObjectCommand(params); + break; + default: + throw new Error(`Unsupported operation: ${operation}`); } return await getSignedUrl(this.s3Client, command, { @@ -638,7 +638,7 @@ class S3StorageAdapter extends stream.EventEmitter { UploadId: uploadId })); } catch (abortError) { - logger.error(`Failed to abort multipart upload:`, abortError); + logger.error('Failed to abort multipart upload:', abortError); } throw error; diff --git a/backend/src/services/watermarkGeneratorService.js b/backend/src/services/watermarkGeneratorService.js index b8177e04..1424de37 100644 --- a/backend/src/services/watermarkGeneratorService.js +++ b/backend/src/services/watermarkGeneratorService.js @@ -64,8 +64,8 @@ class WatermarkGeneratorService { const storageKey = resolvePhotoStorageKey(event, photo); const result = storageKey ? await withLocalCopy(storageKey, (lp) => - watermarkService.generateAndSaveWatermark(photo, lp, settings) - ) + watermarkService.generateAndSaveWatermark(photo, lp, settings) + ) : await watermarkService.generateAndSaveWatermark(photo, resolvePhotoFilePath(event, photo), settings); if (result.success) { @@ -170,8 +170,8 @@ class WatermarkGeneratorService { const storageKey = resolvePhotoStorageKey(event, photo); const result = storageKey ? await withLocalCopy(storageKey, (lp) => - watermarkService.generateAndSaveWatermark(photo, lp, settings) - ) + watermarkService.generateAndSaveWatermark(photo, lp, settings) + ) : await watermarkService.generateAndSaveWatermark(photo, resolvePhotoFilePath(event, photo), settings); if (result.success) { diff --git a/frontend/src/hooks/useMutationWithToast.ts b/frontend/src/hooks/useMutationWithToast.ts index 67bd9408..675018e9 100644 --- a/frontend/src/hooks/useMutationWithToast.ts +++ b/frontend/src/hooks/useMutationWithToast.ts @@ -47,7 +47,7 @@ export function useMutationWithToast< return useMutation({ ...mutationOptions, - onSuccess: (data, variables, context) => { + onSuccess: (data, variables, onMutateResult, context) => { invalidateKeys?.forEach((queryKey) => { queryClient.invalidateQueries({ queryKey }); }); @@ -56,9 +56,9 @@ export function useMutationWithToast< typeof successMessage === 'function' ? successMessage(data, variables) : successMessage ); } - onSuccess?.(data, variables, context); + onSuccess?.(data, variables, onMutateResult, context); }, - onError: (error, variables, context) => { + onError: (error, variables, onMutateResult, context) => { const message = typeof errorMessage === 'function' ? errorMessage(error) @@ -67,7 +67,7 @@ export function useMutationWithToast< (error instanceof Error ? error.message : undefined) || 'An unexpected error occurred'; toast.error(message); - onError?.(error, variables, context); + onError?.(error, variables, onMutateResult, context); }, }); }