fix: conform moved code to eslint indent/quotes, 4-arg mutation callbacks

- eslint --fix on branch-changed backend files (indent shift from the
  module-wrapper nesting in decomposed files); backend lint now 904
  errors vs 1,315 on main
- useMutationWithToast forwards all four TanStack v5 callback args
  (tsc -b strict build flagged the 3-arg passthrough)
This commit is contained in:
Paul Nothaft
2026-07-03 08:17:14 +02:00
parent b5eafc52bd
commit 2ea26a4962
25 changed files with 2272 additions and 2272 deletions
@@ -265,7 +265,7 @@ class SecureImageMiddleware {
'X-Frame-Options': 'DENY', 'X-Frame-Options': 'DENY',
'X-XSS-Protection': '1; mode=block', 'X-XSS-Protection': '1; mode=block',
'Referrer-Policy': 'strict-origin-when-cross-origin', '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 // Custom security headers
'X-Protected-Content': 'true', 'X-Protected-Content': 'true',
+1 -1
View File
@@ -44,7 +44,7 @@ function secureStatic(basePath, options = {}) {
// `default-src 'none'` already implies script-src 'none'; // `default-src 'none'` already implies script-src 'none';
// style-src + img-src(data:) keep normal SVG rendering working. // style-src + img-src(data:) keep normal SVG rendering working.
if (/\.svg$/i.test(filePath)) { 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'); resp.setHeader('X-Content-Type-Options', 'nosniff');
} }
} }
+171 -171
View File
@@ -44,22 +44,22 @@ router.put('/config', adminAuth, requirePermission('backup.create'), async (req,
// Validate required fields based on destination type // Validate required fields based on destination type
if (updates.backup_destination_type) { if (updates.backup_destination_type) {
switch (updates.backup_destination_type) { switch (updates.backup_destination_type) {
case 'local': case 'local':
if (!updates.backup_destination_path) { if (!updates.backup_destination_path) {
return res.status(400).json({ error: 'Local backup requires destination path' }); return res.status(400).json({ error: 'Local backup requires destination path' });
} }
break; break;
case 'rsync': case 'rsync':
if (!updates.backup_rsync_host || !updates.backup_rsync_path) { if (!updates.backup_rsync_host || !updates.backup_rsync_path) {
return res.status(400).json({ error: 'Rsync backup requires host and path' }); return res.status(400).json({ error: 'Rsync backup requires host and path' });
} }
break; break;
case 's3': case 's3':
if (!updates.backup_s3_endpoint || !updates.backup_s3_bucket || if (!updates.backup_s3_endpoint || !updates.backup_s3_bucket ||
!updates.backup_s3_access_key || !updates.backup_s3_secret_key) { !updates.backup_s3_access_key || !updates.backup_s3_secret_key) {
return res.status(400).json({ error: 'S3 backup requires endpoint, bucket, and credentials' }); return res.status(400).json({ error: 'S3 backup requires endpoint, bucket, and credentials' });
} }
break; break;
} }
} }
@@ -210,125 +210,125 @@ router.post('/test-connection', adminAuth, requirePermission('backup.create'), a
const { destination_type, ...config } = req.body; const { destination_type, ...config } = req.body;
switch (destination_type) { switch (destination_type) {
case 'local': case 'local':
// Test local path access // Test local path access
const fs = require('fs').promises; const fs = require('fs').promises;
try { try {
await fs.access(config.path, fs.constants.W_OK); await fs.access(config.path, fs.constants.W_OK);
res.json({ success: true, message: 'Local path is writable' }); res.json({ success: true, message: 'Local path is writable' });
} catch (error) { } catch (error) {
logger.warn('Local backup path not writable', { logger.warn('Local backup path not writable', {
path: config.path, path: config.path,
error: error.message error: error.message
}); });
res.json({ success: false, message: 'Cannot write to local path. Check server logs for details.' }); res.json({ success: false, message: 'Cannot write to local path. Check server logs for details.' });
} }
break; break;
case 'rsync': case 'rsync':
// Test rsync connection using spawn with argument arrays to prevent command injection // Test rsync connection using spawn with argument arrays to prevent command injection
const { spawn } = require('child_process'); const { spawn } = require('child_process');
// Validate and sanitize inputs to prevent command injection // Validate and sanitize inputs to prevent command injection
const sanitizeInput = (input) => { const sanitizeInput = (input) => {
if (!input || typeof input !== 'string') return null; if (!input || typeof input !== 'string') return null;
// Remove any shell metacharacters and limit length // Remove any shell metacharacters and limit length
return input.replace(/[;&|`$(){}[\]<>\\!#*?"'\n\r]/g, '').substring(0, 255); return input.replace(/[;&|`$(){}[\]<>\\!#*?"'\n\r]/g, '').substring(0, 255);
}; };
const host = sanitizeInput(config.host); const host = sanitizeInput(config.host);
const user = sanitizeInput(config.user); const user = sanitizeInput(config.user);
const sshKeyPath = sanitizeInput(config.ssh_key); const sshKeyPath = sanitizeInput(config.ssh_key);
if (!host) { if (!host) {
res.json({ success: false, message: 'Invalid host specified' }); 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; 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) // Add target (user@host or just host)
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 target = user ? `${user}@${host}` : host;
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/; sshArgs.push(target);
if (!hostRegex.test(host) && !ipRegex.test(host)) { sshArgs.push('echo', 'Connection successful');
res.json({ success: false, message: 'Invalid host format' });
break;
}
// SSRF protection: block connections to private/internal addresses try {
const { isPrivateIP } = require('../utils/networkValidation'); const result = await new Promise((resolve, reject) => {
if (isPrivateIP(host)) { const sshProcess = spawn('ssh', sshArgs, {
res.json({ success: false, message: 'Host cannot be a private or internal network address' }); timeout: 15000,
break; stdio: ['ignore', 'pipe', 'pipe']
}
// 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);
});
}); });
res.json({ success: true, message: 'Rsync connection successful' }); let stdout = '';
} catch (error) { let stderr = '';
logger.warn('Rsync connection test failed', {
destination: host, sshProcess.stdout.on('data', (data) => { stdout += data; });
error: error.message 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.' });
} sshProcess.on('error', (err) => {
break; 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': case 's3':
// Test S3 connection (would need AWS SDK) // Test S3 connection (would need AWS SDK)
res.json({ success: false, message: 'S3 testing not implemented yet' }); res.json({ success: false, message: 'S3 testing not implemented yet' });
break; break;
default: default:
res.status(400).json({ error: 'Invalid destination type' }); res.status(400).json({ error: 'Invalid destination type' });
} }
} catch (error) { } catch (error) {
errorResponse(res, error, 500, 'Failed to test connection'); 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 // Handle different backup types
switch (config.backup_destination_type) { switch (config.backup_destination_type) {
case 'local': case 'local':
// Stream local backup as zip // Stream local backup as zip
const backupPath = path.join(config.backup_destination_path, `backup-${backupRun.id}`); const backupPath = path.join(config.backup_destination_path, `backup-${backupRun.id}`);
const archive = archiver('zip', { zlib: { level: 9 } }); const archive = archiver('zip', { zlib: { level: 9 } });
res.attachment(`picpeak-backup-${backupRun.id}.zip`); res.attachment(`picpeak-backup-${backupRun.id}.zip`);
archive.pipe(res); archive.pipe(res);
// Add backup directory contents // Add backup directory contents
archive.directory(backupPath, false); archive.directory(backupPath, false);
// Add manifest if exists // Add manifest if exists
if (backupRun.manifest_path && await fs.access(backupRun.manifest_path).then(() => true).catch(() => false)) { if (backupRun.manifest_path && await fs.access(backupRun.manifest_path).then(() => true).catch(() => false)) {
archive.file(backupRun.manifest_path, { name: 'manifest.json' }); archive.file(backupRun.manifest_path, { name: 'manifest.json' });
} }
await archive.finalize(); await archive.finalize();
break; break;
case 's3': case 's3':
// For S3, provide pre-signed URLs or stream files // For S3, provide pre-signed URLs or stream files
const s3Adapter = new S3StorageAdapter({ const s3Adapter = new S3StorageAdapter({
endpoint: config.backup_s3_endpoint, endpoint: config.backup_s3_endpoint,
bucket: config.backup_s3_bucket, bucket: config.backup_s3_bucket,
accessKeyId: config.backup_s3_access_key, accessKeyId: config.backup_s3_access_key,
secretAccessKey: config.backup_s3_secret_key, secretAccessKey: config.backup_s3_secret_key,
region: config.backup_s3_region || 'us-east-1', region: config.backup_s3_region || 'us-east-1',
forcePathStyle: config.backup_s3_force_path_style || false 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 res.json({
const prefix = `backups/${backupRun.id}/`; backupId: backupRun.id,
const files = await s3Adapter.list(prefix, { maxKeys: 1000 }); type: 's3',
files: urls,
expiresIn: 3600,
message: 'Use the provided URLs to download individual files'
});
break;
// Generate pre-signed URLs case 'rsync':
const urls = []; return res.status(400).json({ error: 'Direct download not available for rsync backups' });
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
});
}
res.json({ default:
backupId: backupRun.id, return res.status(400).json({ error: 'Unknown backup type' });
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' });
} }
} catch (error) { } catch (error) {
errorResponse(res, error, 500, 'Failed to download backup'); errorResponse(res, error, 500, 'Failed to download backup');
+144 -144
View File
@@ -32,165 +32,165 @@ const BULK_DELETE_MAX = 100;
module.exports = (router) => { module.exports = (router) => {
// Archive event // Archive event
router.post('/:id/archive', adminAuth, requirePermission('events.archive'), requireEventOwnership, async (req, res) => { router.post('/:id/archive', adminAuth, requirePermission('events.archive'), requireEventOwnership, async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
const event = await db('events').where('id', id).first(); const event = await db('events').where('id', id).first();
if (!event) { if (!event) {
return res.status(404).json({ error: 'Event not found' }); 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) { // Bulk archive events
return res.status(400).json({ error: 'Event is already archived' }); 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 const { eventIds } = req.body;
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;
if (eventIds.length === 0) { if (eventIds.length === 0) {
return res.status(400).json({ error: 'No events selected for archiving' }); return res.status(400).json({ error: 'No events selected for archiving' });
} }
// Get all events to archive // Get all events to archive
const events = await db('events') const events = await db('events')
.whereIn('id', eventIds) .whereIn('id', eventIds)
.where('is_archived', formatBoolean(false)); .where('is_archived', formatBoolean(false));
if (events.length === 0) { if (events.length === 0) {
return res.status(400).json({ error: 'No valid events found to archive' }); return res.status(400).json({ error: 'No valid events found to archive' });
} }
const results = { const results = {
successful: [], successful: [],
failed: [] failed: []
}; };
// Process each event // Process each event
for (const event of events) { for (const event of events) {
try { try {
// Use the archive service to create ZIP archive // Use the archive service to create ZIP archive
await archiveEvent(event); await archiveEvent(event);
// Log activity // Log activity
await logActivity('event_archived', await logActivity('event_archived',
{ eventName: event.event_name, bulkOperation: true }, { eventName: event.event_name, bulkOperation: true },
event.id, event.id,
{ type: 'admin', id: req.admin.id, name: req.admin.username } { type: 'admin', id: req.admin.id, name: req.admin.username }
); );
results.successful.push({ results.successful.push({
id: event.id, id: event.id,
name: event.event_name name: event.event_name
}); });
} catch (error) { } catch (error) {
logger.error(`Failed to archive event ${event.id}:`, error); logger.error(`Failed to archive event ${event.id}:`, error);
results.failed.push({ results.failed.push({
id: event.id, id: event.id,
name: event.event_name, name: event.event_name,
error: 'Failed to archive event. Check server logs for details.' 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 router.post('/bulk-delete', adminAuth, requirePermission('events.delete'), [
await logActivity('bulk_archive_completed', 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')
totalEvents: eventIds.length, ], async (req, res) => {
successfulCount: results.successful.length, try {
failedCount: results.failed.length const errors = validationResult(req);
}, if (!errors.isEmpty()) {
null, return res.status(400).json({ errors: errors.array() });
{ 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 });
} }
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');
}
});
}; };
File diff suppressed because it is too large Load Diff
+89 -89
View File
@@ -44,102 +44,102 @@ const eventLogoUpload = multer({
module.exports = (router) => { module.exports = (router) => {
// Upload event custom logo // Upload event custom logo
router.post('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, eventLogoUpload.single('logo'), async (req, res) => { router.post('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, eventLogoUpload.single('logo'), async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
// Check if event exists // Check if event exists
let eventQuery = db('events').where('id', id); let eventQuery = db('events').where('id', id);
if (req.admin.roleName === 'editor') { if (req.admin.roleName === 'editor') {
eventQuery = eventQuery.where('created_by', req.admin.id); 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 });
} }
} const event = await eventQuery.first();
if (!event) {
const logoUrl = `/uploads/logos/events/${req.file.filename}`; return res.status(404).json({ error: 'Event not found' });
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 });
} }
}
await db('events') if (!req.file) {
.where('id', id) return res.status(400).json({ error: 'No logo file provided' });
.update({ }
hero_logo_url: null,
hero_logo_path: null // 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', // Delete event custom logo
{ eventName: event.event_name }, router.delete('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
id, try {
{ type: 'admin', id: req.admin.id, name: req.admin.username } const { id } = req.params;
);
res.json({ message: 'Event logo removed successfully' }); let eventQuery = db('events').where('id', id);
} catch (error) { if (req.admin.roleName === 'editor') {
errorResponse(res, error, 500, 'Failed to delete event logo'); 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');
}
});
}; };
+149 -149
View File
@@ -16,70 +16,137 @@ const { requireEventOwnership } = require('../../middleware/ownership');
module.exports = (router) => { module.exports = (router) => {
// Reset event password // Reset event password
router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => { router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { sendEmail = true, password: clientPassword } = req.body; const { sendEmail = true, password: clientPassword } = req.body;
let eventQuery = db('events').where('id', id); let eventQuery = db('events').where('id', id);
// Editor role can only edit their own events // Editor role can only edit their own events
if (req.admin.roleName === 'editor') { if (req.admin.roleName === 'editor') {
eventQuery = eventQuery.where('created_by', req.admin.id); eventQuery = eventQuery.where('created_by', req.admin.id);
} }
const event = await eventQuery.first(); const event = await eventQuery.first();
if (!event) { if (!event) {
return res.status(404).json({ error: 'Event not found' }); return res.status(404).json({ error: 'Event not found' });
} }
if (event.is_archived) { if (event.is_archived) {
return res.status(400).json({ error: 'Cannot reset password for archived event' }); return res.status(400).json({ error: 'Cannot reset password for archived event' });
} }
// Use the admin-supplied password when provided; otherwise auto-generate // Use the admin-supplied password when provided; otherwise auto-generate
// (preserves the previous one-click behaviour for callers/cron that don't // (preserves the previous one-click behaviour for callers/cron that don't
// pass a body). Validation matches the create-event flow so the same // pass a body). Validation matches the create-event flow so the same
// strength rules apply both ways. // strength rules apply both ways.
let newPassword; let newPassword;
if (typeof clientPassword === 'string' && clientPassword.length > 0) { if (typeof clientPassword === 'string' && clientPassword.length > 0) {
const passwordValidation = await validatePasswordInContext(clientPassword, 'gallery', { const passwordValidation = await validatePasswordInContext(clientPassword, 'gallery', {
eventName: event.event_name eventName: event.event_name
}); });
if (!passwordValidation.valid) { if (!passwordValidation.valid) {
return res.status(400).json({ return res.status(400).json({
error: 'Password does not meet security requirements', error: 'Password does not meet security requirements',
details: passwordValidation.errors, details: passwordValidation.errors,
score: passwordValidation.score, score: passwordValidation.score,
feedback: passwordValidation.feedback 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/<slug>/<token>`).
// 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 res.json({
await db('events') message: 'Password reset successfully',
.where('id', id) newPassword: newPassword,
.update({ emailSent: sendEmail
password_hash: passwordHash
}); });
} catch (error) {
errorResponse(res, error, 500, 'Failed to reset password');
}
});
// Log activity // Resend creation email
await logActivity('password_reset', router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
{ eventName: event.event_name, emailSent: sendEmail }, try {
id, const { id } = req.params;
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
// Queue email notification if requested // Get event details
if (sendEmail) { 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 recipientEmail = event.customer_email || event.host_email;
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null); const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
// event.share_link is the path-only form (`/gallery/<slug>/<token>`). // event.share_link is the path-only form; use the full URL so the
// Use the full URL so customers can click straight from the email. // customer's mail client renders a clickable absolute link.
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token }); const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
await queueEmail(id, recipientEmail, 'gallery_created', { 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_name: event.event_name,
event_date: event.event_date, // Pass raw date - will be formatted by email processor event_date: event.event_date, // Pass raw date - will be formatted by email processor
gallery_link: shareUrl, gallery_link: shareUrl,
gallery_password: newPassword, gallery_password: galleryPassword,
expiry_date: event.expires_at // Pass raw date - will be formatted by email processor 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: // Log the activity using the proper schema
// 1. Event language setting try {
// 2. App settings general_default_language await logActivity('email_resent', {
// 3. Email config default language email_type: 'gallery_created',
// 4. Domain-based detection recipient: recipientEmail,
// So we don't need to determine it here ip_address: req.ip || '0.0.0.0',
user_agent: req.get('user-agent') || 'Unknown'
// For resending creation email, we need the actual password }, id, {
// First, try to get it from the request body if provided type: 'admin',
// Use optional chaining to handle cases where req.body might be undefined id: req.admin.id,
let galleryPassword = req.body?.password; name: req.admin.username
});
// If no password provided, we can't decrypt the existing one } catch (logError) {
// So we'll show a security message logger.error('Warning: Failed to log activity:', logError);
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);
// Don't fail the request if activity logging fails // Don't fail the request if activity logging fails
} }
res.json({ res.json({
success: true, success: true,
message: 'Creation email has been queued for sending' message: 'Creation email has been queued for sending'
}); });
} catch (error) { } catch (error) {
logger.error('Error resending creation email:', error); logger.error('Error resending creation email:', error);
errorResponse(res, error, 500, 'Failed to resend creation email'); errorResponse(res, error, 500, 'Failed to resend creation email');
} }
}); });
}; };
+96 -96
View File
@@ -40,112 +40,112 @@ async function loadOwnedEvent(req) {
module.exports = (router) => { module.exports = (router) => {
// Generate (or rotate) the slideshow share token. Idempotent in intent: each // Generate (or rotate) the slideshow share token. Idempotent in intent: each
// call mints a fresh token, which both "Generate" (first time) and "Regenerate" // call mints a fresh token, which both "Generate" (first time) and "Regenerate"
// (rotate, kills the old link) use. // (rotate, kills the old link) use.
router.post('/:id/slideshow/generate', adminAuth, requirePermission('events.edit'), requireFeatureFlag('slideshow'), requireEventOwnership, async (req, res) => { router.post('/:id/slideshow/generate', adminAuth, requirePermission('events.edit'), requireFeatureFlag('slideshow'), requireEventOwnership, async (req, res) => {
try { try {
const event = await loadOwnedEvent(req); const event = await loadOwnedEvent(req);
if (!event) { if (!event) {
return res.status(404).json({ error: 'Event not found' }); 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'); // Disable the slideshow link (null the token). The public /show/ route dies on
// NB: the events table has no updated_at column (only created_at), so we // its next poll, killing any projector currently pointed at the old link.
// must not set it here or the UPDATE throws. router.post('/:id/slideshow/disable', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
await db('events').where('id', req.params.id).update({ try {
show_share_token: token const event = await loadOwnedEvent(req);
}); if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
await logActivity('slideshow_link_generated', await db('events').where('id', req.params.id).update({
{ eventName: event.event_name, rotated: Boolean(event.show_share_token) }, show_share_token: null
req.params.id, });
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ await logActivity('slideshow_link_disabled',
show_share_token: token, { eventName: event.event_name },
slideshow_url: await buildSlideshowUrl(event.slug, token) req.params.id,
}); { type: 'admin', id: req.admin.id, name: req.admin.username }
} catch (error) { );
errorResponse(res, error, 500, 'Failed to generate slideshow link');
}
});
// Disable the slideshow link (null the token). The public /show/ route dies on res.json({ show_share_token: null });
// its next poll, killing any projector currently pointed at the old link. } catch (error) {
router.post('/:id/slideshow/disable', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => { errorResponse(res, error, 500, 'Failed to disable slideshow link');
try {
const event = await loadOwnedEvent(req);
if (!event) {
return res.status(404).json({ error: 'Event not found' });
} }
});
await db('events').where('id', req.params.id).update({ // Update the LIVE slideshow settings (display time / transition style / speed).
show_share_token: null // 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', const event = await loadOwnedEvent(req);
{ eventName: event.event_name }, if (!event) {
req.params.id, return res.status(404).json({ error: 'Event not found' });
{ type: 'admin', id: req.admin.id, name: req.admin.username } }
);
res.json({ show_share_token: null }); // events has no updated_at column — don't set it.
} catch (error) { const updates = {};
errorResponse(res, error, 500, 'Failed to disable slideshow link'); 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). // Knex throws on an empty update; only write if something changed.
// A running projector picks these up via the show-page settings poll within a if (Object.keys(updates).length > 0) {
// few seconds — no need to regenerate the link. await db('events').where('id', req.params.id).update(updates);
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), res.json({
body('show_transition_ms').optional().isInt({ min: 100, max: 5000 }), show_interval_ms: updates.show_interval_ms ?? event.show_interval_ms ?? 5000,
body('show_watermark').optional({ nullable: true }), show_transition: updates.show_transition ?? event.show_transition ?? 'crossfade',
body('show_colorfilter').optional().isIn(SLIDESHOW_COLORFILTERS) show_transition_ms: updates.show_transition_ms ?? event.show_transition_ms ?? 800,
], async (req, res) => { show_watermark: updates.show_watermark ?? event.show_watermark ?? null,
try { show_colorfilter: updates.show_colorfilter ?? event.show_colorfilter ?? 'none'
const errors = validationResult(req); });
if (!errors.isEmpty()) { } catch (error) {
return res.status(400).json({ error: 'Invalid slideshow settings', details: errors.array() }); 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');
}
});
}; };
+5 -5
View File
@@ -74,10 +74,10 @@ router.get(
'gallery_guests.created_at', 'gallery_guests.created_at',
'gallery_guests.last_seen_at', 'gallery_guests.last_seen_at',
'gallery_guests.email_verified_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 = \'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 = \'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 = \'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 = \'rating\' THEN 1 END) AS ratings'),
db.raw('COUNT(DISTINCT photo_feedback.photo_id) AS distinct_photos') db.raw('COUNT(DISTINCT photo_feedback.photo_id) AS distinct_photos')
) )
.orderBy('gallery_guests.created_at', 'desc'); .orderBy('gallery_guests.created_at', 'desc');
@@ -117,7 +117,7 @@ router.get(
const photos = await db('photos') const photos = await db('photos')
.leftJoin('photo_feedback', function () { .leftJoin('photo_feedback', function () {
this.on('photo_feedback.photo_id', '=', 'photos.id') 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'); .andOnNotNull('photo_feedback.guest_id');
}) })
.where('photos.event_id', eventId) .where('photos.event_id', eventId)
+1 -1
View File
@@ -1251,7 +1251,7 @@ router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photo
// Validate file size (max 10GB) // Validate file size (max 10GB)
const maxSize = 10 * 1024 * 1024 * 1024; const maxSize = 10 * 1024 * 1024 * 1024;
if (fileSize > maxSize) { 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({ const result = await chunkedUpload.initializeUpload({
+17 -17
View File
@@ -1299,23 +1299,23 @@ router.get('/storage/info', adminAuth, requirePermission('settings.view'), async
} }
switch (setting.setting_key) { switch (setting.setting_key) {
case 'general_storage_soft_limit_bytes': case 'general_storage_soft_limit_bytes':
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) { if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
configuredSoftLimit = parsedValue; configuredSoftLimit = parsedValue;
} }
break; break;
case 'general_storage_capacity_override_bytes': case 'general_storage_capacity_override_bytes':
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) { if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
capacityOverrideDb = parsedValue; capacityOverrideDb = parsedValue;
} }
break; break;
case 'general_storage_available_override_bytes': case 'general_storage_available_override_bytes':
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) { if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
availableOverrideDb = parsedValue; availableOverrideDb = parsedValue;
} }
break; break;
default: default:
break; break;
} }
}); });
} catch (error) { } catch (error) {
+1 -1
View File
@@ -1177,7 +1177,7 @@ async function runBackupInternal(isManual = false) {
async function startBackupService() { async function startBackupService() {
try { try {
const config = await resolveConfigWithFallback(); const config = await resolveConfigWithFallback();
if (!config || !normalizeBoolean(config.backup_enabled)) { if (!config || !normalizeBoolean(config.backup_enabled)) {
if (backupJob) { if (backupJob) {
backupJob.stop(); backupJob.stop();
+23 -23
View File
@@ -51,29 +51,29 @@ async function listContracts({ filters = {}, sort = 'issue_desc', page = 1, page
const total = ensureInt(totalRow?.total || 0); const total = ensureInt(totalRow?.total || 0);
switch (sort) { switch (sort) {
case 'oldest': case 'oldest':
query = query.orderBy('contracts.created_at', 'asc').orderBy('contracts.id', 'asc'); query = query.orderBy('contracts.created_at', 'asc').orderBy('contracts.id', 'asc');
break; break;
case 'issue_asc': case 'issue_asc':
query = query.orderBy('contracts.issue_date', 'asc').orderBy('contracts.id', 'asc'); query = query.orderBy('contracts.issue_date', 'asc').orderBy('contracts.id', 'asc');
break; break;
case 'issue_desc': case 'issue_desc':
query = query.orderBy('contracts.issue_date', 'desc').orderBy('contracts.id', 'desc'); query = query.orderBy('contracts.issue_date', 'desc').orderBy('contracts.id', 'desc');
break; break;
case 'customer_asc': case 'customer_asc':
query = query query = query
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc') .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc')
.orderBy('contracts.id', 'desc'); .orderBy('contracts.id', 'desc');
break; break;
case 'customer_desc': case 'customer_desc':
query = query query = query
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc') .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc')
.orderBy('contracts.id', 'desc'); .orderBy('contracts.id', 'desc');
break; break;
case 'newest': case 'newest':
default: default:
query = query.orderBy('contracts.created_at', 'desc').orderBy('contracts.id', 'desc'); query = query.orderBy('contracts.created_at', 'desc').orderBy('contracts.id', 'desc');
break; break;
} }
const offset = Math.max(0, (page - 1) * pageSize); const offset = Math.max(0, (page - 1) * pageSize);
@@ -78,8 +78,8 @@ async function buildPlaceholderContext(contract, customer) {
: ''; : '';
const customerAddress = customer const customerAddress = customer
? [customer.address_line1, customer.address_line2, customer.postal_code, customer.city] ? [customer.address_line1, customer.address_line2, customer.postal_code, customer.city]
.filter(Boolean) .filter(Boolean)
.join(', ') .join(', ')
: ''; : '';
return { return {
+22 -22
View File
@@ -73,21 +73,21 @@ async function recordCustomerSignature({ token, name, ip, signatureDataUrl, acce
// consistent and saves a redundant read. // consistent and saves a redundant read.
const persistedIp = await maybeStoreIp(ip); const persistedIp = await maybeStoreIp(ip);
try { try {
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
await trx('contracts').where({ id: contract.id }).update({ await trx('contracts').where({ id: contract.id }).update({
status: 'signed_by_customer', status: 'signed_by_customer',
signed_by_customer_at: now, signed_by_customer_at: now,
signed_customer_name: String(name).trim(), signed_customer_name: String(name).trim(),
signed_customer_ip: persistedIp, signed_customer_ip: persistedIp,
signed_customer_signature_path: signaturePath, signed_customer_signature_path: signaturePath,
updated_at: now, 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) { } catch (txErr) {
// C.7 — clean up the orphan signature PNG we wrote before the // C.7 — clean up the orphan signature PNG we wrote before the
// transaction. The DB rollback already undid the contract + // 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 newStatus = contract.status === 'signed_by_customer' ? 'fully_signed' : 'signed_by_admin';
const persistedAdminIp = await maybeStoreIp(ip); const persistedAdminIp = await maybeStoreIp(ip);
try { try {
await db('contracts').where({ id: contract.id }).update({ await db('contracts').where({ id: contract.id }).update({
status: newStatus, status: newStatus,
signed_by_admin_at: now, signed_by_admin_at: now,
signed_admin_name: String(name).trim(), signed_admin_name: String(name).trim(),
signed_admin_ip: persistedAdminIp, signed_admin_ip: persistedAdminIp,
signed_admin_signature_path: signaturePath, signed_admin_signature_path: signaturePath,
updated_at: now, updated_at: now,
}); });
} catch (updateErr) { } catch (updateErr) {
// C.7 — clean up the orphan signature PNG if the contract row // C.7 — clean up the orphan signature PNG if the contract row
// update threw. Best-effort; log on cleanup failure and re-throw // update threw. Best-effort; log on cleanup failure and re-throw
+5 -5
View File
@@ -329,11 +329,11 @@ async function createInvoice(payload, adminId, trx = db) {
* code should reach for the clearer `spawnInstallmentInvoices`. * code should reach for the clearer `spawnInstallmentInvoices`.
*/ */
async function spawnInstallmentInvoices({ trx, eventId, quoteId, customer, currency, language, async function spawnInstallmentInvoices({ trx, eventId, quoteId, customer, currency, language,
lineItems, totals, installments, eventDate, adminId, lineItems, totals, installments, eventDate, adminId,
ccPdfEmail, netDays, ccPdfEmail, netDays,
eventName, eventTimeStart, eventTimeEnd, eventName, eventTimeStart, eventTimeEnd,
paymentNetDaysTemplateId, paymentTimingTemplateId, paymentNetDaysTemplateId, paymentTimingTemplateId,
paymentTermSnapshot, dealUuid, hold = false }) { paymentTermSnapshot, dealUuid, hold = false }) {
// Monthly-billing intercept (migration 128). Quote → invoice // Monthly-billing intercept (migration 128). Quote → invoice
// conversion for a monthly-mode customer doesn't fan out N // conversion for a monthly-mode customer doesn't fan out N
// installment invoices — the customer pays one consolidated bill // installment invoices — the customer pays one consolidated bill
+14 -14
View File
@@ -45,20 +45,20 @@ function computeScheduledSendAt(trigger, offsetDays, eventDate, baseDate = new D
const offset = ensureInt(offsetDays) * ms; const offset = ensureInt(offsetDays) * ms;
const eventTs = eventDate ? new Date(eventDate).getTime() : null; const eventTs = eventDate ? new Date(eventDate).getTime() : null;
switch (trigger) { switch (trigger) {
case 'quote_accepted': case 'quote_accepted':
return new Date(baseDate.getTime() + offset); return new Date(baseDate.getTime() + offset);
case 'before_event': case 'before_event':
case 'after_event': case 'after_event':
if (!eventTs) return new Date(baseDate.getTime() + offset); if (!eventTs) return new Date(baseDate.getTime() + offset);
return new Date(eventTs + offset); return new Date(eventTs + offset);
case 'after_delivery': case 'after_delivery':
// Treat as event_date + 14 days as a sensible default; admin can // Treat as event_date + 14 days as a sensible default; admin can
// edit the scheduled_send_at on the invoice later. // edit the scheduled_send_at on the invoice later.
if (!eventTs) return new Date(baseDate.getTime() + 14 * ms + offset); if (!eventTs) return new Date(baseDate.getTime() + 14 * ms + offset);
return new Date(eventTs + 14 * ms + offset); return new Date(eventTs + 14 * ms + offset);
case 'fixed_date': case 'fixed_date':
default: default:
return new Date(baseDate.getTime() + offset); return new Date(baseDate.getTime() + offset);
} }
} }
@@ -116,7 +116,7 @@ async function replaceReconciliationLine(
const subtotal = topLineSubtotal != null const subtotal = topLineSubtotal != null
? topLineSubtotal ? topLineSubtotal
: nonRecon.filter((x) => x.parent_position == null) : 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; const adjustment = netSlice - subtotal;
if (adjustment === 0) return; if (adjustment === 0) return;
+29 -29
View File
@@ -67,35 +67,35 @@ async function listInvoices({ filters = {}, sort = 'issue_desc', page = 1, pageS
const total = ensureInt(countRow?.total || 0); const total = ensureInt(countRow?.total || 0);
switch (sort) { switch (sort) {
// "Newest" / "Oldest" means newest/oldest by CREATION time, not // "Newest" / "Oldest" means newest/oldest by CREATION time, not
// by issue_date. Issue_date is admin-controlled (used for tax // by issue_date. Issue_date is admin-controlled (used for tax
// accruals, retro-dating, future-dating) so it can drift from // accruals, retro-dating, future-dating) so it can drift from
// actual chronology — sorting by it makes a just-created invoice // actual chronology — sorting by it makes a just-created invoice
// disappear into the middle of the list whenever its issue_date // disappear into the middle of the list whenever its issue_date
// is set to something other than today. created_at always // is set to something other than today. created_at always
// reflects when the row landed in the DB. id is the tiebreaker // reflects when the row landed in the DB. id is the tiebreaker
// for rows that share a created_at second. // for rows that share a created_at second.
case 'oldest': query = query.orderBy('invoices.created_at', 'asc').orderBy('invoices.id', 'asc'); break; 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_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 '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_asc': query = query.orderBy('invoices.due_date', 'asc'); break;
case 'due_desc': query = query.orderBy('invoices.due_date', 'desc'); 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_asc': query = query.orderBy('invoices.total_amount_minor', 'asc'); break;
case 'value_desc': query = query.orderBy('invoices.total_amount_minor', 'desc'); break; case 'value_desc': query = query.orderBy('invoices.total_amount_minor', 'desc'); break;
case 'customer_asc': case 'customer_asc':
query = query query = query
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc') .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc')
.orderBy('invoices.id', 'desc'); .orderBy('invoices.id', 'desc');
break; break;
case 'customer_desc': case 'customer_desc':
query = query query = query
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc') .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc')
.orderBy('invoices.id', 'desc'); .orderBy('invoices.id', 'desc');
break; break;
case 'newest': case 'newest':
default: default:
query = query.orderBy('invoices.created_at', 'desc').orderBy('invoices.id', 'desc'); query = query.orderBy('invoices.created_at', 'desc').orderBy('invoices.id', 'desc');
break; break;
} }
const offset = Math.max(0, (page - 1) * pageSize); const offset = Math.max(0, (page - 1) * pageSize);
+1 -1
View File
@@ -358,7 +358,7 @@ async function sendStorno(stornoId, adminId) {
// email body — customers' bookkeepers expect to see the pair. // email body — customers' bookkeepers expect to see the pair.
const originalRow = storno.cancels_invoice_id const originalRow = storno.cancels_invoice_id
? await db('invoices').where({ id: 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; : null;
const { to: stornoTo, cc: stornoCc } = resolveBillingRecipients(customer, storno.cc_pdf_email); const { to: stornoTo, cc: stornoCc } = resolveBillingRecipients(customer, storno.cc_pdf_email);
+38 -38
View File
@@ -400,40 +400,40 @@ async function listQuotes({ filters = {}, sort = 'issue_desc', page = 1, pageSiz
const total = ensureInt(totalRow?.total || 0); const total = ensureInt(totalRow?.total || 0);
switch (sort) { switch (sort) {
// "Newest" / "Oldest" sort by CREATION time, not issue_date — // "Newest" / "Oldest" sort by CREATION time, not issue_date —
// the latter is admin-controlled (retro-dated quotes, future- // the latter is admin-controlled (retro-dated quotes, future-
// dated quotes for accruals) and drifts from actual chronology. // dated quotes for accruals) and drifts from actual chronology.
// Sorting by created_at always puts a just-saved quote at the // Sorting by created_at always puts a just-saved quote at the
// top of the "Newest first" list. // top of the "Newest first" list.
case 'oldest': case 'oldest':
query = query.orderBy('quotes.created_at', 'asc').orderBy('quotes.id', 'asc'); query = query.orderBy('quotes.created_at', 'asc').orderBy('quotes.id', 'asc');
break; break;
case 'issue_asc': case 'issue_asc':
query = query.orderBy('quotes.issue_date', 'asc').orderBy('quotes.id', 'asc'); query = query.orderBy('quotes.issue_date', 'asc').orderBy('quotes.id', 'asc');
break; break;
case 'issue_desc': case 'issue_desc':
query = query.orderBy('quotes.issue_date', 'desc').orderBy('quotes.id', 'desc'); query = query.orderBy('quotes.issue_date', 'desc').orderBy('quotes.id', 'desc');
break; break;
case 'customer_asc': case 'customer_asc':
query = query query = query
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc') .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc')
.orderBy('quotes.id', 'desc'); .orderBy('quotes.id', 'desc');
break; break;
case 'customer_desc': case 'customer_desc':
query = query query = query
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc') .orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc')
.orderBy('quotes.id', 'desc'); .orderBy('quotes.id', 'desc');
break; break;
case 'value_asc': case 'value_asc':
query = query.orderBy('quotes.total_amount_minor', 'asc'); query = query.orderBy('quotes.total_amount_minor', 'asc');
break; break;
case 'value_desc': case 'value_desc':
query = query.orderBy('quotes.total_amount_minor', 'desc'); query = query.orderBy('quotes.total_amount_minor', 'desc');
break; break;
case 'newest': case 'newest':
default: default:
query = query.orderBy('quotes.created_at', 'desc').orderBy('quotes.id', 'desc'); query = query.orderBy('quotes.created_at', 'desc').orderBy('quotes.id', 'desc');
break; break;
} }
const offset = Math.max(0, (page - 1) * pageSize); const offset = Math.max(0, (page - 1) * pageSize);
@@ -1508,8 +1508,8 @@ async function convertToInvoiceOnly(quoteId, adminId, options = {}) {
const paymentTermSnapshot = quote.payment_term_snapshot const paymentTermSnapshot = quote.payment_term_snapshot
? (typeof quote.payment_term_snapshot === 'string' ? (typeof quote.payment_term_snapshot === 'string'
? JSON.parse(quote.payment_term_snapshot) ? JSON.parse(quote.payment_term_snapshot)
: quote.payment_term_snapshot) : quote.payment_term_snapshot)
: null; : null;
const invoiceService = require('./invoiceService'); const invoiceService = require('./invoiceService');
@@ -1621,8 +1621,8 @@ async function convertToEvent(quoteId, adminId, options = {}) {
const paymentTermSnapshot = quote.payment_term_snapshot const paymentTermSnapshot = quote.payment_term_snapshot
? (typeof quote.payment_term_snapshot === 'string' ? (typeof quote.payment_term_snapshot === 'string'
? JSON.parse(quote.payment_term_snapshot) ? JSON.parse(quote.payment_term_snapshot)
: quote.payment_term_snapshot) : quote.payment_term_snapshot)
: null; : null;
// Lazy import to avoid the circular dep. // Lazy import to avoid the circular dep.
+22 -22
View File
@@ -200,20 +200,20 @@ class RestoreService {
// Step 6: Perform the actual restore based on type // Step 6: Perform the actual restore based on type
let restoreResult; let restoreResult;
switch (options.restoreType) { switch (options.restoreType) {
case 'full': case 'full':
restoreResult = await this.performFullRestore(localBackupPath, manifest, options); restoreResult = await this.performFullRestore(localBackupPath, manifest, options);
break; break;
case 'database': case 'database':
restoreResult = await this.performDatabaseRestore(localBackupPath, manifest, options); restoreResult = await this.performDatabaseRestore(localBackupPath, manifest, options);
break; break;
case 'files': case 'files':
restoreResult = await this.performFilesRestore(localBackupPath, manifest, options); restoreResult = await this.performFilesRestore(localBackupPath, manifest, options);
break; break;
case 'selective': case 'selective':
restoreResult = await this.performSelectiveRestore(localBackupPath, manifest, options); restoreResult = await this.performSelectiveRestore(localBackupPath, manifest, options);
break; break;
default: default:
throw new Error(`Unknown restore type: ${options.restoreType}`); throw new Error(`Unknown restore type: ${options.restoreType}`);
} }
// Step 7: Post-restore verification // Step 7: Post-restore verification
@@ -308,7 +308,7 @@ class RestoreService {
this.log('info', 'Post-restore migrations applied'); this.log('info', 'Post-restore migrations applied');
} catch (migErr) { } catch (migErr) {
this.log('warn', 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}`); `A container restart will retry via wait-for-db.sh. Error: ${migErr.message}`);
} }
@@ -409,8 +409,8 @@ class RestoreService {
if (restoreRun) { if (restoreRun) {
const failureMessage = rollbackAttempted const failureMessage = rollbackAttempted
? (rollbackSucceeded ? (rollbackSucceeded
? `${error.message} (rolled back successfully to pre-restore state)` ? `${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} | ROLLBACK ALSO FAILED: ${rollbackError} — destination is in a partial state, inspect before retrying`)
: `${error.message} (no pre-restore backup available — destination may be partial)`; : `${error.message} (no pre-restore backup available — destination may be partial)`;
await db('restore_runs').where('id', restoreRun.id).update({ await db('restore_runs').where('id', restoreRun.id).update({
completed_at: new Date(), completed_at: new Date(),
@@ -888,9 +888,9 @@ class RestoreService {
throw new Error( throw new Error(
`Database backup file not found. Tried: ${candidates.join(', ')}. ` + `Database backup file not found. Tried: ${candidates.join(', ')}. ` +
`Manifest recorded path: ${dbBackupFile}. ` + `Manifest recorded path: ${dbBackupFile}. ` +
`Hint: this usually means the manifest's database.backup_file path no longer ` + 'Hint: this usually means the manifest\'s database.backup_file path no longer ' +
`exists on disk (deleted? moved? volume not mounted?). Check ` + 'exists on disk (deleted? moved? volume not mounted?). Check ' +
`~/<your-compose-dir>/backup/database/ on the host.` '~/<your-compose-dir>/backup/database/ on the host.'
); );
} }
@@ -1043,8 +1043,8 @@ class RestoreService {
await spawnAsync('psql', [ await spawnAsync('psql', [
'-h', host, '-p', String(port), '-U', user, '-d', maintenanceDb, '-h', host, '-p', String(port), '-U', user, '-d', maintenanceDb,
'-c', '-c',
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity ` + 'SELECT pg_terminate_backend(pid) FROM pg_stat_activity ' +
`WHERE datname = '${database.replace(/'/g, "''")}' AND pid <> pg_backend_pid()`, `WHERE datname = '${database.replace(/'/g, '\'\'')}' AND pid <> pg_backend_pid()`,
], { env }); ], { env });
// Drop and recreate database (extremely dangerous!) // Drop and recreate database (extremely dangerous!)
+9 -9
View File
@@ -494,14 +494,14 @@ class S3StorageAdapter extends stream.EventEmitter {
let command; let command;
switch (operation.toLowerCase()) { switch (operation.toLowerCase()) {
case 'getobject': case 'getobject':
command = new GetObjectCommand(params); command = new GetObjectCommand(params);
break; break;
case 'putobject': case 'putobject':
command = new PutObjectCommand(params); command = new PutObjectCommand(params);
break; break;
default: default:
throw new Error(`Unsupported operation: ${operation}`); throw new Error(`Unsupported operation: ${operation}`);
} }
return await getSignedUrl(this.s3Client, command, { return await getSignedUrl(this.s3Client, command, {
@@ -638,7 +638,7 @@ class S3StorageAdapter extends stream.EventEmitter {
UploadId: uploadId UploadId: uploadId
})); }));
} catch (abortError) { } catch (abortError) {
logger.error(`Failed to abort multipart upload:`, abortError); logger.error('Failed to abort multipart upload:', abortError);
} }
throw error; throw error;
@@ -64,8 +64,8 @@ class WatermarkGeneratorService {
const storageKey = resolvePhotoStorageKey(event, photo); const storageKey = resolvePhotoStorageKey(event, photo);
const result = storageKey const result = storageKey
? await withLocalCopy(storageKey, (lp) => ? await withLocalCopy(storageKey, (lp) =>
watermarkService.generateAndSaveWatermark(photo, lp, settings) watermarkService.generateAndSaveWatermark(photo, lp, settings)
) )
: await watermarkService.generateAndSaveWatermark(photo, resolvePhotoFilePath(event, photo), settings); : await watermarkService.generateAndSaveWatermark(photo, resolvePhotoFilePath(event, photo), settings);
if (result.success) { if (result.success) {
@@ -170,8 +170,8 @@ class WatermarkGeneratorService {
const storageKey = resolvePhotoStorageKey(event, photo); const storageKey = resolvePhotoStorageKey(event, photo);
const result = storageKey const result = storageKey
? await withLocalCopy(storageKey, (lp) => ? await withLocalCopy(storageKey, (lp) =>
watermarkService.generateAndSaveWatermark(photo, lp, settings) watermarkService.generateAndSaveWatermark(photo, lp, settings)
) )
: await watermarkService.generateAndSaveWatermark(photo, resolvePhotoFilePath(event, photo), settings); : await watermarkService.generateAndSaveWatermark(photo, resolvePhotoFilePath(event, photo), settings);
if (result.success) { if (result.success) {
+4 -4
View File
@@ -47,7 +47,7 @@ export function useMutationWithToast<
return useMutation<TData, TError, TVariables, TContext>({ return useMutation<TData, TError, TVariables, TContext>({
...mutationOptions, ...mutationOptions,
onSuccess: (data, variables, context) => { onSuccess: (data, variables, onMutateResult, context) => {
invalidateKeys?.forEach((queryKey) => { invalidateKeys?.forEach((queryKey) => {
queryClient.invalidateQueries({ queryKey }); queryClient.invalidateQueries({ queryKey });
}); });
@@ -56,9 +56,9 @@ export function useMutationWithToast<
typeof successMessage === 'function' ? successMessage(data, variables) : successMessage 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 = const message =
typeof errorMessage === 'function' typeof errorMessage === 'function'
? errorMessage(error) ? errorMessage(error)
@@ -67,7 +67,7 @@ export function useMutationWithToast<
(error instanceof Error ? error.message : undefined) || (error instanceof Error ? error.message : undefined) ||
'An unexpected error occurred'; 'An unexpected error occurred';
toast.error(message); toast.error(message);
onError?.(error, variables, context); onError?.(error, variables, onMutateResult, context);
}, },
}); });
} }