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:
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
+171
-171
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
+1423
-1423
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
};
|
||||
|
||||
@@ -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/<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
|
||||
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/<slug>/<token>`).
|
||||
// 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');
|
||||
}
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ` +
|
||||
`~/<your-compose-dir>/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 ' +
|
||||
'~/<your-compose-dir>/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!)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -47,7 +47,7 @@ export function useMutationWithToast<
|
||||
|
||||
return useMutation<TData, TError, TVariables, TContext>({
|
||||
...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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user