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-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',
+1 -1
View File
@@ -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
View File
@@ -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');
+144 -144
View File
@@ -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');
}
});
});
};
File diff suppressed because it is too large Load Diff
+89 -89
View File
@@ -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');
}
});
};
+149 -149
View File
@@ -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');
}
});
};
+96 -96
View File
@@ -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');
}
});
});
};
+5 -5
View File
@@ -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)
+1 -1
View File
@@ -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({
+17 -17
View File
@@ -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) {
+1 -1
View File
@@ -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();
+23 -23
View File
@@ -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 {
+22 -22
View File
@@ -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
+5 -5
View File
@@ -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
+14 -14
View File
@@ -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;
+29 -29
View File
@@ -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);
+1 -1
View File
@@ -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);
+38 -38
View File
@@ -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.
+22 -22
View File
@@ -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!)
+9 -9
View File
@@ -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) {
+4 -4
View File
@@ -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);
},
});
}