diff --git a/backend/src/__tests__/customerAuth.middleware.test.js b/backend/src/__tests__/customerAuth.middleware.test.js index 649a2cc4..9731d44a 100644 --- a/backend/src/__tests__/customerAuth.middleware.test.js +++ b/backend/src/__tests__/customerAuth.middleware.test.js @@ -65,7 +65,7 @@ function makeRes() { res.json = jest.fn().mockReturnValue(res); return res; } -function makeReq({ token = 'tkn', cookies = {}, headers = {}, originalUrl = '/api/customer/foo', ip = '1.2.3.4' } = {}) { +function makeReq({ cookies = {}, headers = {}, originalUrl = '/api/customer/foo', ip = '1.2.3.4' } = {}) { return { headers: { authorization: undefined, ...headers }, cookies, originalUrl, ip, connection: { remoteAddress: ip } }; } diff --git a/backend/src/__tests__/publicSiteService.test.js b/backend/src/__tests__/publicSiteService.test.js index 17c97b30..a4a2f01b 100644 --- a/backend/src/__tests__/publicSiteService.test.js +++ b/backend/src/__tests__/publicSiteService.test.js @@ -19,7 +19,7 @@ const { sanitizeCss } = require('../utils/cssSanitizer'); const buildPublicSiteRows = (overrides = {}) => ([ { setting_key: 'general_public_site_enabled', setting_value: JSON.stringify(overrides.enabled ?? true) }, { setting_key: 'general_public_site_html', setting_value: JSON.stringify(overrides.html ?? '

{{company_name}}

') }, - { setting_key: 'general_public_site_custom_css', setting_value: JSON.stringify(overrides.css ?? "body { color: red; }") } + { setting_key: 'general_public_site_custom_css', setting_value: JSON.stringify(overrides.css ?? 'body { color: red; }') } ]); const buildBrandingRows = (overrides = {}) => ([ @@ -64,7 +64,7 @@ describe('publicSiteService', () => { it('sanitizes custom CSS and removes dangerous patterns', async () => { const publicSiteRows = buildPublicSiteRows({ - css: "body { color: blue; } @import url('https://malicious.example/style.css'); div { background: url(\"javascript:alert(1)\"); }" + css: 'body { color: blue; } @import url(\'https://malicious.example/style.css\'); div { background: url("javascript:alert(1)"); }' }); const brandingRows = buildBrandingRows(); diff --git a/backend/src/database/db.js b/backend/src/database/db.js index 982ea4eb..11d0257c 100644 --- a/backend/src/database/db.js +++ b/backend/src/database/db.js @@ -21,7 +21,7 @@ try { } } catch (e) { // Non-fatal: log and continue; SQLite will fail later if still missing - try { logger.warn('SQLite directory ensure failed', { error: e.message }); } catch (_) {} + try { logger.warn('SQLite directory ensure failed', { error: e.message }); } catch (_) { /* non-fatal */ } } // Create database connection with built-in retry logic. @@ -205,28 +205,28 @@ async function initializeDatabase() { ) `); - const pragmaRows = await db.raw("PRAGMA table_info('events')"); + const pragmaRows = await db.raw('PRAGMA table_info(\'events\')'); const existingColumns = pragmaRows.map(row => row.name); const selectColumns = existingColumns.map((col) => { switch (col) { - case 'allow_user_uploads': - return "COALESCE(allow_user_uploads, 0) as allow_user_uploads"; - case 'upload_category_id': - return "upload_category_id"; - case 'allow_downloads': - return "COALESCE(allow_downloads, 1) as allow_downloads"; - case 'disable_right_click': - return "COALESCE(disable_right_click, 0) as disable_right_click"; - case 'watermark_downloads': - return "COALESCE(watermark_downloads, 0) as watermark_downloads"; - case 'watermark_text': - return 'watermark_text'; - case 'hero_photo_id': - return 'hero_photo_id'; - case 'require_password': - return 'COALESCE(require_password, 1) as require_password'; - default: - return col; + case 'allow_user_uploads': + return 'COALESCE(allow_user_uploads, 0) as allow_user_uploads'; + case 'upload_category_id': + return 'upload_category_id'; + case 'allow_downloads': + return 'COALESCE(allow_downloads, 1) as allow_downloads'; + case 'disable_right_click': + return 'COALESCE(disable_right_click, 0) as disable_right_click'; + case 'watermark_downloads': + return 'COALESCE(watermark_downloads, 0) as watermark_downloads'; + case 'watermark_text': + return 'watermark_text'; + case 'hero_photo_id': + return 'hero_photo_id'; + case 'require_password': + return 'COALESCE(require_password, 1) as require_password'; + default: + return col; } }); diff --git a/backend/src/middleware/feedbackRateLimit.js b/backend/src/middleware/feedbackRateLimit.js index 51692929..192e472b 100644 --- a/backend/src/middleware/feedbackRateLimit.js +++ b/backend/src/middleware/feedbackRateLimit.js @@ -176,7 +176,7 @@ function feedbackRateLimit(actionType) { return res.status(429).json({ error: 'Too many requests', - message: `Rate limit exceeded. Please try again later.`, + message: 'Rate limit exceeded. Please try again later.', retryAfter: rateLimitStatus.window }); } diff --git a/backend/src/middleware/ownership.js b/backend/src/middleware/ownership.js index 1a04e0b3..cb6914d5 100644 --- a/backend/src/middleware/ownership.js +++ b/backend/src/middleware/ownership.js @@ -27,7 +27,7 @@ function requireEventOwnership(req, res, next) { } next(); }) - .catch((err) => { + .catch((_err) => { res.status(500).json({ error: 'Failed to verify ownership' }); }); } diff --git a/backend/src/middleware/photoAuth.js b/backend/src/middleware/photoAuth.js index ae350f2d..faed44fc 100644 --- a/backend/src/middleware/photoAuth.js +++ b/backend/src/middleware/photoAuth.js @@ -120,9 +120,9 @@ async function photoAuth(req, res, next) { } return next(); } - } catch (err) { + } catch (err) { // Token invalid, fall through to password check - logger.warn('JWT verification failed in photoAuth', { error: err.message }); + logger.warn('JWT verification failed in photoAuth', { error: err.message }); } } diff --git a/backend/src/middleware/secureImageMiddleware.js b/backend/src/middleware/secureImageMiddleware.js index 7d86516e..5d963a53 100644 --- a/backend/src/middleware/secureImageMiddleware.js +++ b/backend/src/middleware/secureImageMiddleware.js @@ -1,7 +1,6 @@ const { db } = require('../database/db'); const secureImageService = require('../services/secureImageService'); const logger = require('../utils/logger'); -const { formatBoolean } = require('../utils/dbCompat'); /** * Enhanced secure image middleware with comprehensive protection @@ -69,7 +68,7 @@ class SecureImageMiddleware { /** * Perform comprehensive security checks */ - async performSecurityChecks(req, res) { + async performSecurityChecks(req, _res) { const { clientInfo } = req; const { photoId } = req.params; @@ -132,7 +131,6 @@ class SecureImageMiddleware { */ async checkRateLimit(req) { const { clientInfo } = req; - const now = Date.now(); // Get rate limit settings from database const settings = await this.getRateLimitSettings(); diff --git a/backend/src/middleware/secureStatic.js b/backend/src/middleware/secureStatic.js index f562eed5..5006f466 100644 --- a/backend/src/middleware/secureStatic.js +++ b/backend/src/middleware/secureStatic.js @@ -24,7 +24,8 @@ function secureStatic(basePath, options = {}) { try { // Validate the full path is within the base directory - const fullPath = safePathJoin(normalizedBase, requestedPath); + // Validates the path stays inside the base dir; throws on traversal. + safePathJoin(normalizedBase, requestedPath); // If validation passes, use express.static const staticMiddleware = express.static(normalizedBase, { diff --git a/backend/src/middleware/sessionTimeout.js b/backend/src/middleware/sessionTimeout.js index 1a37b96f..aecd6750 100644 --- a/backend/src/middleware/sessionTimeout.js +++ b/backend/src/middleware/sessionTimeout.js @@ -135,7 +135,7 @@ async function sessionTimeoutMiddleware(req, res, next) { // Clean up old token if user has a new one // This prevents memory leaks from token renewals const userId = decoded.id; - for (const [oldToken, _] of sessions.entries()) { + for (const oldToken of sessions.keys()) { if (oldToken !== token) { try { const oldDecoded = jwt.verify(oldToken, process.env.JWT_SECRET, { algorithms: ['HS256'] }); @@ -198,7 +198,7 @@ function getActiveSessions() { const now = Date.now(); let active = 0; - for (const [_, lastActivity] of sessions.entries()) { + for (const lastActivity of sessions.values()) { if (now - lastActivity <= DEFAULT_SESSION_TIMEOUT) { active++; } diff --git a/backend/src/middleware/uploadValidation.js b/backend/src/middleware/uploadValidation.js index 6b829a5f..afc618c5 100644 --- a/backend/src/middleware/uploadValidation.js +++ b/backend/src/middleware/uploadValidation.js @@ -46,8 +46,8 @@ async function validateUploadedFile(filePath) { failOn: 'none', limitInputPixels: 268402689 }) - .resize(10, 10) // Try to resize to very small size - .toBuffer(); + .resize(10, 10) // Try to resize to very small size + .toBuffer(); } catch (decodeError) { throw new Error(`Image decode failed - file may be corrupted: ${decodeError.message}`); } diff --git a/backend/src/routes/adminArchives.js b/backend/src/routes/adminArchives.js index 5a3ffdd1..0aa6a5b8 100644 --- a/backend/src/routes/adminArchives.js +++ b/backend/src/routes/adminArchives.js @@ -6,7 +6,6 @@ const { formatBoolean } = require('../utils/dbCompat'); const { slugify } = require('../utils/slug'); const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); -const archiver = require('archiver'); const StreamZip = require('node-stream-zip'); const { requireEventOwnership } = require('../middleware/ownership'); const { assertZipEntriesWithin } = require('../utils/safePath'); diff --git a/backend/src/routes/adminBackup.js b/backend/src/routes/adminBackup.js index e8ac7e42..4590b7d3 100644 --- a/backend/src/routes/adminBackup.js +++ b/backend/src/routes/adminBackup.js @@ -346,7 +346,7 @@ router.post('/test-connection', adminAuth, requirePermission('backup.create'), a const { destination_type, ...config } = req.body; switch (destination_type) { - case 'local': + case 'local': { // Test local path access const fs = require('fs').promises; try { @@ -360,8 +360,9 @@ router.post('/test-connection', adminAuth, requirePermission('backup.create'), a res.json({ success: false, message: 'Cannot write to local path. Check server logs for details.' }); } break; - - case 'rsync': + } + + case 'rsync': { // Test rsync connection using spawn with argument arrays to prevent command injection const { spawn } = require('child_process'); @@ -425,7 +426,7 @@ router.post('/test-connection', adminAuth, requirePermission('backup.create'), a sshArgs.push('echo', 'Connection successful'); try { - const result = await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { const sshProcess = spawn('ssh', sshArgs, { timeout: 15000, stdio: ['ignore', 'pipe', 'pipe'] @@ -459,7 +460,8 @@ router.post('/test-connection', adminAuth, requirePermission('backup.create'), a 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' }); @@ -829,7 +831,7 @@ router.get('/download/:backupId', adminAuth, requirePermission('backup.view'), a // Handle different backup types switch (config.backup_destination_type) { - case 'local': + 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 } }); @@ -847,8 +849,9 @@ router.get('/download/:backupId', adminAuth, requirePermission('backup.view'), a await archive.finalize(); break; - - case 's3': + } + + case 's3': { // For S3, provide pre-signed URLs or stream files const s3Adapter = new S3StorageAdapter({ endpoint: config.backup_s3_endpoint, @@ -882,7 +885,8 @@ router.get('/download/:backupId', adminAuth, requirePermission('backup.view'), a 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' }); @@ -909,7 +913,7 @@ router.get('/checksums', adminAuth, requirePermission('backup.view'), async (req } // Calculate checksums for files - async function calculateDirChecksums(dirPath, relative = '') { + const calculateDirChecksums = async (dirPath, relative = '') => { try { const entries = await fs.readdir(dirPath, { withFileTypes: true }); @@ -940,7 +944,7 @@ router.get('/checksums', adminAuth, requirePermission('backup.view'), async (req } catch (error) { logger.error(`Failed to calculate checksums for ${dirPath}:`, error); } - } + }; await calculateDirChecksums(basePath); @@ -978,7 +982,7 @@ router.post('/estimate', adminAuth, requirePermission('backup.view'), async (req const breakdown = {}; // Estimate size for each directory - async function estimateDir(dirPath, category) { + const estimateDir = async (dirPath, category) => { let dirSize = 0; let dirCount = 0; @@ -1005,7 +1009,7 @@ router.post('/estimate', adminAuth, requirePermission('backup.view'), async (req } return { size: dirSize, count: dirCount }; - } + }; // Estimate each category const categories = [ diff --git a/backend/src/routes/adminContracts.js b/backend/src/routes/adminContracts.js index d6c3564e..94579212 100644 --- a/backend/src/routes/adminContracts.js +++ b/backend/src/routes/adminContracts.js @@ -155,22 +155,22 @@ function transformContract(c, inclusions) { updatedAt: c.updated_at, inclusions: Array.isArray(inclusions) ? inclusions.map((inc) => ({ - id: inc.id, - blockId: inc.block_id, - section: inc.section, - position: inc.position, - included: inc.included === true || inc.included === 1 || inc.included === '1', - block: { - slug: inc.block_slug, - name: inc.block_name, - description: inc.block_description, - bodyText: inc.block_body_text, - bodyTextDe: inc.block_body_text_de, - isSystem: inc.block_is_system === true || inc.block_is_system === 1 || inc.block_is_system === '1', - }, - bodyTextSnapshot: inc.body_text_snapshot, - bodyTextDeSnapshot: inc.body_text_de_snapshot, - })) + id: inc.id, + blockId: inc.block_id, + section: inc.section, + position: inc.position, + included: inc.included === true || inc.included === 1 || inc.included === '1', + block: { + slug: inc.block_slug, + name: inc.block_name, + description: inc.block_description, + bodyText: inc.block_body_text, + bodyTextDe: inc.block_body_text_de, + isSystem: inc.block_is_system === true || inc.block_is_system === 1 || inc.block_is_system === '1', + }, + bodyTextSnapshot: inc.body_text_snapshot, + bodyTextDeSnapshot: inc.body_text_de_snapshot, + })) : undefined, }; } diff --git a/backend/src/routes/adminDashboard.js b/backend/src/routes/adminDashboard.js index 9202134b..8ebe6f89 100644 --- a/backend/src/routes/adminDashboard.js +++ b/backend/src/routes/adminDashboard.js @@ -2,7 +2,7 @@ const express = require('express'); const { db } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); -const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity'); +const { sanitizeDays } = require('../utils/sqlSecurity'); const { formatBoolean } = require('../utils/dbCompat'); const { resolveAdapter } = require('../services/trackers'); const logger = require('../utils/logger'); diff --git a/backend/src/routes/adminEvents/crud.js b/backend/src/routes/adminEvents/crud.js index 6186ea8a..bf1c818f 100644 --- a/backend/src/routes/adminEvents/crud.js +++ b/backend/src/routes/adminEvents/crud.js @@ -318,8 +318,6 @@ module.exports = (router) => { css_template_id = null, // Hero logo settings hero_logo_visible = true, - hero_logo_size = 'medium', - hero_logo_position = 'top', // Header style settings header_style = 'standard', hero_divider_style = 'wave', diff --git a/backend/src/routes/adminEvents/helpers.js b/backend/src/routes/adminEvents/helpers.js index b84d8d01..83d2df14 100644 --- a/backend/src/routes/adminEvents/helpers.js +++ b/backend/src/routes/adminEvents/helpers.js @@ -176,8 +176,9 @@ const mapEventForApi = (event) => { customer_name, customer_email, customer_phone, - password_hash: _ph, - client_password_hash: _cph, + // Bound only to exclude the secrets from `...rest` — never read. + // eslint-disable-next-line no-unused-vars -- rest-sibling omission + password_hash: _ph, client_password_hash: _cph, ...rest } = event; diff --git a/backend/src/routes/adminExpenses.js b/backend/src/routes/adminExpenses.js index 8fbb51af..6b89a48a 100644 --- a/backend/src/routes/adminExpenses.js +++ b/backend/src/routes/adminExpenses.js @@ -130,7 +130,7 @@ router.get('/inbound/:id/file', requireIncoming, requirePermission('accounting.v res.setHeader('Content-Type', row.mime_type || 'application/octet-stream'); res.setHeader('Content-Disposition', isPdf ? 'attachment' : 'inline'); res.setHeader('X-Content-Type-Options', 'nosniff'); - if (!isPdf) res.setHeader('Content-Security-Policy', "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'"); + if (!isPdf) res.setHeader('Content-Security-Policy', 'default-src \'none\'; img-src \'self\' data:; style-src \'unsafe-inline\''); createReadStream(safe).pipe(res); })); @@ -149,7 +149,7 @@ router.get('/inbound/:id/page/:n', requireIncoming, requirePermission('accountin res.setHeader('Content-Type', 'image/png'); res.setHeader('Content-Disposition', 'inline'); res.setHeader('X-Content-Type-Options', 'nosniff'); - res.setHeader('Content-Security-Policy', "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'"); + res.setHeader('Content-Security-Policy', 'default-src \'none\'; img-src \'self\' data:; style-src \'unsafe-inline\''); createReadStream(safePng).pipe(res); })); @@ -217,7 +217,7 @@ router.get('/:id/proof', requireExpenses, requirePermission('accounting.view'), res.setHeader('Content-Type', isPdf ? 'application/pdf' : 'application/octet-stream'); res.setHeader('Content-Disposition', isPdf ? 'attachment' : 'inline'); res.setHeader('X-Content-Type-Options', 'nosniff'); - if (!isPdf) res.setHeader('Content-Security-Policy', "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'"); + if (!isPdf) res.setHeader('Content-Security-Policy', 'default-src \'none\'; img-src \'self\' data:; style-src \'unsafe-inline\''); createReadStream(safe).pipe(res); })); diff --git a/backend/src/routes/adminExternalMedia.js b/backend/src/routes/adminExternalMedia.js index bb63f745..4bce828c 100644 --- a/backend/src/routes/adminExternalMedia.js +++ b/backend/src/routes/adminExternalMedia.js @@ -4,7 +4,7 @@ const fs = require('fs').promises; const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); const { requireEventOwnership } = require('../middleware/ownership'); -const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService'); +const { list, resolveExternalPath } = require('../services/externalMediaService'); const { db, logActivity } = require('../database/db'); const sharp = require('sharp'); const logger = require('../utils/logger'); diff --git a/backend/src/routes/adminFeedback.js b/backend/src/routes/adminFeedback.js index a1b76d59..ded0c1e7 100644 --- a/backend/src/routes/adminFeedback.js +++ b/backend/src/routes/adminFeedback.js @@ -107,7 +107,7 @@ router.get('/events/:eventId/feedback', if (status === 'pending') { query = query.where('photo_feedback.is_approved', false) - .where('photo_feedback.is_hidden', false); + .where('photo_feedback.is_hidden', false); } else if (status === 'approved') { query = query.where('photo_feedback.is_approved', true); } else if (status === 'hidden') { @@ -131,7 +131,7 @@ router.get('/events/:eventId/feedback', if (status === 'pending') { countQuery = countQuery.where('photo_feedback.is_approved', false) - .where('photo_feedback.is_hidden', false); + .where('photo_feedback.is_hidden', false); } else if (status === 'approved') { countQuery = countQuery.where('photo_feedback.is_approved', true); } else if (status === 'hidden') { diff --git a/backend/src/routes/adminImageSecurity.js b/backend/src/routes/adminImageSecurity.js index 35699dd6..877a5421 100644 --- a/backend/src/routes/adminImageSecurity.js +++ b/backend/src/routes/adminImageSecurity.js @@ -104,17 +104,17 @@ router.get('/dashboard', adminAuth, requirePermission(['settings.view', 'image_s let timeFilter; switch (timeframe) { - case '1h': - timeFilter = new Date(Date.now() - 3600000); - break; - case '24h': - timeFilter = new Date(Date.now() - 86400000); - break; - case '7d': - timeFilter = new Date(Date.now() - 604800000); - break; - default: - timeFilter = new Date(Date.now() - 86400000); + case '1h': + timeFilter = new Date(Date.now() - 3600000); + break; + case '24h': + timeFilter = new Date(Date.now() - 86400000); + break; + case '7d': + timeFilter = new Date(Date.now() - 604800000); + break; + default: + timeFilter = new Date(Date.now() - 86400000); } // Get image access statistics @@ -214,17 +214,17 @@ router.get('/logs', adminAuth, requirePermission(['settings.view', 'image_securi let timeFilter; switch (timeframe) { - case '1h': - timeFilter = new Date(Date.now() - 3600000); - break; - case '24h': - timeFilter = new Date(Date.now() - 86400000); - break; - case '7d': - timeFilter = new Date(Date.now() - 604800000); - break; - default: - timeFilter = new Date(Date.now() - 86400000); + case '1h': + timeFilter = new Date(Date.now() - 3600000); + break; + case '24h': + timeFilter = new Date(Date.now() - 86400000); + break; + case '7d': + timeFilter = new Date(Date.now() - 604800000); + break; + default: + timeFilter = new Date(Date.now() - 86400000); } let query = db('security_logs') @@ -374,17 +374,17 @@ router.delete('/logs/cleanup', adminAuth, requirePermission('image_security.mana let cutoffDate; switch (olderThan) { - case '7d': - cutoffDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); - break; - case '30d': - cutoffDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); - break; - case '90d': - cutoffDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); - break; - default: - cutoffDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + case '7d': + cutoffDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + break; + case '30d': + cutoffDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + break; + case '90d': + cutoffDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); + break; + default: + cutoffDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); } // Delete old security logs @@ -431,17 +431,17 @@ router.get('/export', adminAuth, requirePermission(['settings.view', 'image_secu let timeFilter; switch (timeframe) { - case '24h': - timeFilter = new Date(Date.now() - 86400000); - break; - case '7d': - timeFilter = new Date(Date.now() - 604800000); - break; - case '30d': - timeFilter = new Date(Date.now() - 2592000000); - break; - default: - timeFilter = new Date(Date.now() - 604800000); + case '24h': + timeFilter = new Date(Date.now() - 86400000); + break; + case '7d': + timeFilter = new Date(Date.now() - 604800000); + break; + case '30d': + timeFilter = new Date(Date.now() - 2592000000); + break; + default: + timeFilter = new Date(Date.now() - 604800000); } // Get security logs diff --git a/backend/src/routes/adminLedger.js b/backend/src/routes/adminLedger.js index 3a948edb..9d720972 100644 --- a/backend/src/routes/adminLedger.js +++ b/backend/src/routes/adminLedger.js @@ -15,7 +15,6 @@ const { body, param, query } = require('express-validator'); const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers'); -const { db } = require('../database/db'); const ledgerService = require('../services/ledgerService'); const router = express.Router(); diff --git a/backend/src/routes/adminNotifications.js b/backend/src/routes/adminNotifications.js index 42d6c9d2..ff6144d6 100644 --- a/backend/src/routes/adminNotifications.js +++ b/backend/src/routes/adminNotifications.js @@ -1,5 +1,5 @@ const express = require('express'); -const { db, logActivity } = require('../database/db'); +const { db } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); const logger = require('../utils/logger'); diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index 62a3e1f6..11d62298 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -37,7 +37,6 @@ const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '. const storage = multer.diskStorage({ destination: (req, file, cb) => { logger.info('Multer destination called for file:', file.originalname); - const { eventId } = req.params; // We'll validate the event exists in the route handler // For now, just create a temp destination diff --git a/backend/src/routes/adminRestore.js b/backend/src/routes/adminRestore.js index 5b69de34..66111b2d 100644 --- a/backend/src/routes/adminRestore.js +++ b/backend/src/routes/adminRestore.js @@ -3,7 +3,7 @@ const router = express.Router(); const { restoreService } = require('../services/restoreService'); const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); -const { body, query, validationResult } = require('express-validator'); +const { body, validationResult } = require('express-validator'); const logger = require('../utils/logger'); const { getPagination } = require('../utils/routeHelpers'); const { db } = require('../database/db'); diff --git a/backend/src/routes/adminSystem.js b/backend/src/routes/adminSystem.js index 68ee2130..a1f8e987 100644 --- a/backend/src/routes/adminSystem.js +++ b/backend/src/routes/adminSystem.js @@ -1,5 +1,5 @@ const express = require('express'); -const { db, withRetry } = require('../database/db'); +const { db } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); const fs = require('fs').promises; diff --git a/backend/src/routes/customer.js b/backend/src/routes/customer.js index 04ed26df..663a4377 100644 --- a/backend/src/routes/customer.js +++ b/backend/src/routes/customer.js @@ -16,7 +16,6 @@ const bcrypt = require('bcrypt'); const jwt = require('jsonwebtoken'); const { body, param, validationResult } = require('express-validator'); const { db, logActivity } = require('../database/db'); -const { formatBoolean } = require('../utils/dbCompat'); const { getBcryptRounds } = require('../utils/passwordValidation'); const logger = require('../utils/logger'); const { errorResponse } = require('../utils/routeHelpers'); diff --git a/backend/src/routes/customerAuth.js b/backend/src/routes/customerAuth.js index 3abe4642..eaaefb8c 100644 --- a/backend/src/routes/customerAuth.js +++ b/backend/src/routes/customerAuth.js @@ -17,7 +17,6 @@ const bcrypt = require('bcrypt'); const jwt = require('jsonwebtoken'); const { body, param, validationResult } = require('express-validator'); const { db, logActivity } = require('../database/db'); -const { formatBoolean } = require('../utils/dbCompat'); const { verifyRecaptcha } = require('../services/recaptcha'); const { trackFailedAttempt, diff --git a/backend/src/routes/galleryFeedback.js b/backend/src/routes/galleryFeedback.js index e1342783..d756e816 100644 --- a/backend/src/routes/galleryFeedback.js +++ b/backend/src/routes/galleryFeedback.js @@ -14,7 +14,6 @@ const { checkValidation, validateGuestRequirements } = require('../utils/feedbackValidation'); -const { escapeLikePattern } = require('../utils/sqlSecurity'); // Get feedback settings for a gallery router.get('/:slug/feedback-settings', diff --git a/backend/src/routes/galleryGuests.js b/backend/src/routes/galleryGuests.js index 2be4b5e8..567d281c 100644 --- a/backend/src/routes/galleryGuests.js +++ b/backend/src/routes/galleryGuests.js @@ -35,6 +35,7 @@ function sanitizeName(value) { // Strip HTML/control chars, collapse whitespace. const cleaned = value .replace(/[<>&"']/g, '') + // eslint-disable-next-line no-control-regex -- intentional: strips control chars from guest input .replace(/[\u0000-\u001F\u007F]/g, '') .replace(/\s+/g, ' ') .trim(); diff --git a/backend/src/routes/protectedImages.js b/backend/src/routes/protectedImages.js index 5f48aa59..8b078dd5 100644 --- a/backend/src/routes/protectedImages.js +++ b/backend/src/routes/protectedImages.js @@ -70,7 +70,7 @@ function verifyImageToken(token) { router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, blockHiddenGallery, async (req, res) => { try { const { photoId } = req.params; - const { protectionLevel = 'standard', token } = req.query; + const { protectionLevel = 'standard' } = req.query; // Create client fingerprint const clientFingerprint = secureImageService.createClientFingerprint(req); diff --git a/backend/src/routes/publicWorkflowApprovals.js b/backend/src/routes/publicWorkflowApprovals.js index 559619de..a97d5b90 100644 --- a/backend/src/routes/publicWorkflowApprovals.js +++ b/backend/src/routes/publicWorkflowApprovals.js @@ -16,32 +16,32 @@ const router = express.Router(); const { actByToken, peekApproval } = require('../services/workflows'); function page(title, body) { - return `` - + `` + return '' + + '' + `${title}` - + `` + + '' + `

${title}

${body}

`; } // Escape any prompt text we echo into the interstitial HTML. function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ( - { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] + { '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' }[c] )); } function decisionPage(token, emphasis, prompt) { const btn = (href, label, primary) => `
` - + `
`; const body = (prompt ? `${esc(prompt)}` : '') - + `
` - + btn(`confirm`, 'Confirm payment received', emphasis === 'confirm') - + btn(`deny`, 'No payment received', emphasis === 'deny') - + `
` - + `

Choosing is a single, final action.

`; + + '
' + + btn('confirm', 'Confirm payment received', emphasis === 'confirm') + + btn('deny', 'No payment received', emphasis === 'deny') + + '
' + + '

Choosing is a single, final action.

'; return page('Confirm your response', body); } diff --git a/backend/src/services/__tests__/databaseBackup.test.js b/backend/src/services/__tests__/databaseBackup.test.js index 0609659a..85388c6d 100644 --- a/backend/src/services/__tests__/databaseBackup.test.js +++ b/backend/src/services/__tests__/databaseBackup.test.js @@ -1,7 +1,6 @@ const { DatabaseBackupService } = require('../databaseBackup'); const { db } = require('../../database/db'); const fs = require('fs').promises; -const path = require('path'); const crypto = require('crypto'); // Mock dependencies @@ -12,11 +11,9 @@ jest.mock('child_process'); describe('DatabaseBackupService', () => { let service; - let mockExecAsync; beforeEach(() => { service = new DatabaseBackupService(); - mockExecAsync = jest.fn(); // Reset mocks jest.clearAllMocks(); diff --git a/backend/src/services/_installFromBackupBoot.js b/backend/src/services/_installFromBackupBoot.js index bbf5f45d..6c7d20f9 100644 --- a/backend/src/services/_installFromBackupBoot.js +++ b/backend/src/services/_installFromBackupBoot.js @@ -167,6 +167,7 @@ async function tryInstallFromBackup(db, logger) { // console.log as well so the docker-logs surface tells the story // without needing to exec into the container. const announce = (msg) => { + // eslint-disable-next-line no-console -- deliberate: mirrors boot progress to docker logs try { console.log(`[install-from-backup] ${msg}`); } catch (_) { /* defensive */ } }; diff --git a/backend/src/services/_workflowSeedBoot.js b/backend/src/services/_workflowSeedBoot.js index bd1970ed..897c5978 100644 --- a/backend/src/services/_workflowSeedBoot.js +++ b/backend/src/services/_workflowSeedBoot.js @@ -335,6 +335,10 @@ const BUILTINS = [ }, ]; +// NOTE: written at the end of seedBuiltinWorkflowsAtBoot but never read — the +// intended "seed only once per process" guard is missing its `if (booted) return;` +// check. Left in place so the gap stays visible rather than being silently dropped. +// eslint-disable-next-line no-unused-vars -- write-only boot guard, see note above let booted = false; function parseSeedConfig(raw) { diff --git a/backend/src/services/backupService.js b/backend/src/services/backupService.js index 1731a5ba..b41e2476 100644 --- a/backend/src/services/backupService.js +++ b/backend/src/services/backupService.js @@ -4,7 +4,6 @@ const fsSync = require('fs'); const crypto = require('crypto'); const childProcess = require('child_process'); const os = require('os'); -const { promisify } = require('util'); const cron = require('node-cron'); const cronParser = require('cron-parser'); @@ -69,7 +68,6 @@ function ensureMockableExec() { ensureMockableExec(); -const getExecAsync = () => promisify(childProcess.exec); async function resolveConfigWithFallback() { let config; @@ -763,7 +761,7 @@ async function performLocalBackup(config, files) { function validateRsyncParam(value, label) { if (!value || typeof value !== 'string') return null; - if (!/^[a-zA-Z0-9._\/@:-]+$/.test(value)) { + if (!/^[a-zA-Z0-9._/@:-]+$/.test(value)) { throw new Error(`Invalid ${label}: contains disallowed characters`); } if (value.length > 1024) { @@ -1532,7 +1530,7 @@ async function loadManifestFromAnywhere(manifestPath, config) { throw new Error('S3 credentials not configured for manifest retrieval'); } - const match = manifestPath.match(/^s3:\/\/([^\/]+)\/(.+)$/); + const match = manifestPath.match(/^s3:\/\/([^/]+)\/(.+)$/); if (!match) { throw new Error('Invalid S3 manifest path'); } @@ -1601,7 +1599,7 @@ async function getBackupManifest(backupRunId) { throw new Error('S3 credentials not configured for manifest retrieval'); } - const match = run.manifest_path.match(/^s3:\/\/([^\/]+)\/(.+)$/); + const match = run.manifest_path.match(/^s3:\/\/([^/]+)\/(.+)$/); if (!match) { throw new Error('Invalid S3 manifest path'); } @@ -1640,7 +1638,7 @@ async function validateBackupManifest(manifestPath) { let manifest; if (manifestPath.startsWith('s3://')) { - const match = manifestPath.match(/^s3:\/\/([^\/]+)\/(.+)$/); + const match = manifestPath.match(/^s3:\/\/([^/]+)\/(.+)$/); if (!match) { throw new Error('Invalid S3 manifest path'); } diff --git a/backend/src/services/contractEmailTemplates.js b/backend/src/services/contractEmailTemplates.js index cc118584..6754de7f 100644 --- a/backend/src/services/contractEmailTemplates.js +++ b/backend/src/services/contractEmailTemplates.js @@ -30,7 +30,7 @@ const CONTRACT_EMAIL_TEMPLATES = {

Or open the full contract:
{{response_url}}

{{#if valid_until}}

Please sign by {{valid_until}}.

{{/if}}`, - body_text: `Contract {{contract_number}}\n\nDear {{customer_name}},\n\nPlease review and sign the contract {{contract_number}}.\n\nOpen: {{response_url}}\n\n{{#if valid_until}}Please sign by {{valid_until}}.{{/if}}`, + body_text: 'Contract {{contract_number}}\n\nDear {{customer_name}},\n\nPlease review and sign the contract {{contract_number}}.\n\nOpen: {{response_url}}\n\n{{#if valid_until}}Please sign by {{valid_until}}.{{/if}}', }, de: { subject: 'Vertrag {{contract_number}} zur Unterzeichnung bereit', @@ -44,7 +44,7 @@ const CONTRACT_EMAIL_TEMPLATES = {

Oder öffnen Sie den vollständigen Vertrag im Browser:
{{response_url}}

{{#if valid_until}}

Bitte unterzeichnen Sie bis {{valid_until}}.

{{/if}}`, - body_text: `Vertrag {{contract_number}}\n\nSehr geehrte/r {{customer_name}},\n\nbitte prüfen und unterzeichnen Sie den Vertrag {{contract_number}}.\n\nÖffnen: {{response_url}}\n\n{{#if valid_until}}Bitte unterzeichnen bis {{valid_until}}.{{/if}}`, + body_text: 'Vertrag {{contract_number}}\n\nSehr geehrte/r {{customer_name}},\n\nbitte prüfen und unterzeichnen Sie den Vertrag {{contract_number}}.\n\nÖffnen: {{response_url}}\n\n{{#if valid_until}}Bitte unterzeichnen bis {{valid_until}}.{{/if}}', }, }, contract_fully_signed: { @@ -56,7 +56,7 @@ const CONTRACT_EMAIL_TEMPLATES = {

Dear {{customer_name}},

Both parties have now signed contract {{contract_number}}{{#if title}} — "{{title}}"{{/if}}. Please find the fully signed PDF attached for your records.

This is the authoritative signed copy. Keep it alongside the related quote and invoices.

`, - body_text: `Contract {{contract_number}} is now fully signed by both parties. The signed PDF is attached for your records.`, + body_text: 'Contract {{contract_number}} is now fully signed by both parties. The signed PDF is attached for your records.', }, de: { subject: 'Vertrag {{contract_number}} vollständig unterzeichnet', @@ -64,7 +64,7 @@ const CONTRACT_EMAIL_TEMPLATES = {

Sehr geehrte/r {{customer_name}},

der Vertrag {{contract_number}}{{#if title}} – „{{title}}"{{/if}} wurde nun von beiden Parteien unterzeichnet. Im Anhang finden Sie das beidseitig unterzeichnete PDF für Ihre Unterlagen.

Dies ist die massgebliche unterzeichnete Fassung. Bewahren Sie sie zusammen mit dem zugehörigen Angebot und den Rechnungen auf.

`, - body_text: `Vertrag {{contract_number}} ist nun beidseitig unterzeichnet. Das unterzeichnete PDF finden Sie im Anhang.`, + body_text: 'Vertrag {{contract_number}} ist nun beidseitig unterzeichnet. Das unterzeichnete PDF finden Sie im Anhang.', }, }, contract_signed_admin_notification: { @@ -75,14 +75,14 @@ const CONTRACT_EMAIL_TEMPLATES = { body_html: `

Contract signed

{{signed_customer_name}} ({{customer_email}}) has just signed contract {{contract_number}}.

Open in admin

The signed PDF and signature evidence (typed name, IP, timestamp, signature image if drawn) are available on the contract detail page. To make this fully binding, counter-sign the contract or upload a wet-signed copy.

`, - body_text: `Contract {{contract_number}} signed by {{signed_customer_name}} ({{customer_email}}). Open: {{admin_dashboard_url}}`, + body_text: 'Contract {{contract_number}} signed by {{signed_customer_name}} ({{customer_email}}). Open: {{admin_dashboard_url}}', }, de: { subject: 'Vertrag {{contract_number}} von {{customer_email}} unterzeichnet', body_html: `

Vertrag unterzeichnet

{{signed_customer_name}} ({{customer_email}}) hat soeben den Vertrag {{contract_number}} unterzeichnet.

Im Admin-Bereich öffnen

Das unterzeichnete PDF und die Signatur-Belege (Name, IP, Zeitstempel, Signaturbild falls gezeichnet) sind auf der Vertragsdetailseite einsehbar. Für vollständige Verbindlichkeit unterzeichnen Sie den Vertrag gegen oder laden Sie eine handunterschriebene Kopie hoch.

`, - body_text: `Vertrag {{contract_number}} von {{signed_customer_name}} ({{customer_email}}) unterzeichnet. Öffnen: {{admin_dashboard_url}}`, + body_text: 'Vertrag {{contract_number}} von {{signed_customer_name}} ({{customer_email}}) unterzeichnet. Öffnen: {{admin_dashboard_url}}', }, }, }; diff --git a/backend/src/services/crmEmailTemplates.js b/backend/src/services/crmEmailTemplates.js index 84572e24..dac3805b 100644 --- a/backend/src/services/crmEmailTemplates.js +++ b/backend/src/services/crmEmailTemplates.js @@ -22,10 +22,10 @@ */ const CRM_EMAIL_TEMPLATES = { -quote_sent: { + quote_sent: { category: 'quotes', feature_flag: 'quotes', variables: ['quote_number', 'customer_name', 'response_url', 'accept_url', 'decline_url', - 'valid_until', 'event_name', 'total_amount'], + 'valid_until', 'event_name', 'total_amount'], en: { subject: 'Your quote {{quote_number}} is ready', body_html: `

Quote {{quote_number}}

@@ -40,7 +40,7 @@ quote_sent: {

Or open the full quote in your browser:
{{response_url}}

{{#if valid_until}}

This quote is valid until {{valid_until}}.

{{/if}}`, - body_text: `Quote {{quote_number}}\n\nDear {{customer_name}},\n\nPlease find the attached quote {{quote_number}}. Total: {{total_amount}}.\n\nRespond: {{response_url}}\nAccept: {{accept_url}}\nDecline: {{decline_url}}\n\n{{#if valid_until}}Valid until {{valid_until}}.{{/if}}`, + body_text: 'Quote {{quote_number}}\n\nDear {{customer_name}},\n\nPlease find the attached quote {{quote_number}}. Total: {{total_amount}}.\n\nRespond: {{response_url}}\nAccept: {{accept_url}}\nDecline: {{decline_url}}\n\n{{#if valid_until}}Valid until {{valid_until}}.{{/if}}', }, de: { subject: 'Ihr Angebot {{quote_number}} ist bereit', @@ -56,7 +56,7 @@ quote_sent: {

Oder öffnen Sie das vollständige Angebot im Browser:
{{response_url}}

{{#if valid_until}}

Dieses Angebot ist gültig bis {{valid_until}}.

{{/if}}`, - body_text: `Angebot {{quote_number}}\n\nSehr geehrte/r {{customer_name}},\n\nim Anhang finden Sie das Angebot {{quote_number}}. Gesamtbetrag: {{total_amount}}.\n\nAnsehen: {{response_url}}\nAnnehmen: {{accept_url}}\nAblehnen: {{decline_url}}\n\n{{#if valid_until}}Gültig bis {{valid_until}}.{{/if}}`, + body_text: 'Angebot {{quote_number}}\n\nSehr geehrte/r {{customer_name}},\n\nim Anhang finden Sie das Angebot {{quote_number}}. Gesamtbetrag: {{total_amount}}.\n\nAnsehen: {{response_url}}\nAnnehmen: {{accept_url}}\nAblehnen: {{decline_url}}\n\n{{#if valid_until}}Gültig bis {{valid_until}}.{{/if}}', }, }, quote_accepted_admin: { @@ -66,13 +66,13 @@ quote_sent: { subject: 'Quote {{quote_number}} accepted by {{customer_email}}', body_html: `

Quote accepted

{{customer_email}} just accepted quote {{quote_number}}{{#if event_name}} for "{{event_name}}"{{/if}}. Total: {{total_amount}}.

Open in admin

`, - body_text: `Quote {{quote_number}} accepted by {{customer_email}}. Open: {{admin_dashboard_url}}`, + body_text: 'Quote {{quote_number}} accepted by {{customer_email}}. Open: {{admin_dashboard_url}}', }, de: { subject: 'Angebot {{quote_number}} von {{customer_email}} angenommen', body_html: `

Angebot angenommen

{{customer_email}} hat soeben das Angebot {{quote_number}}{{#if event_name}} für "{{event_name}}"{{/if}} angenommen. Gesamtbetrag: {{total_amount}}.

Im Admin-Bereich öffnen

`, - body_text: `Angebot {{quote_number}} von {{customer_email}} angenommen. Öffnen: {{admin_dashboard_url}}`, + body_text: 'Angebot {{quote_number}} von {{customer_email}} angenommen. Öffnen: {{admin_dashboard_url}}', }, }, quote_declined_admin: { @@ -82,26 +82,26 @@ quote_sent: { subject: 'Quote {{quote_number}} declined by {{customer_email}}', body_html: `

{{customer_email}} declined quote {{quote_number}}{{#if event_name}} for "{{event_name}}"{{/if}}.

Open quote in admin

`, - body_text: `Quote {{quote_number}} declined by {{customer_email}}. Open: {{admin_dashboard_url}}`, + body_text: 'Quote {{quote_number}} declined by {{customer_email}}. Open: {{admin_dashboard_url}}', }, de: { subject: 'Angebot {{quote_number}} von {{customer_email}} abgelehnt', body_html: `

{{customer_email}} hat das Angebot {{quote_number}}{{#if event_name}} für "{{event_name}}"{{/if}} abgelehnt.

Angebot im Admin-Bereich öffnen

`, - body_text: `Angebot {{quote_number}} von {{customer_email}} abgelehnt. Öffnen: {{admin_dashboard_url}}`, + body_text: 'Angebot {{quote_number}} von {{customer_email}} abgelehnt. Öffnen: {{admin_dashboard_url}}', }, }, invoice_sent: { category: 'billing', feature_flag: 'bills', variables: ['invoice_number', 'customer_name', 'event_name', 'total_amount', 'due_date', - 'installment_label', 'installment_index', 'installment_total'], + 'installment_label', 'installment_index', 'installment_total'], en: { subject: 'Invoice {{invoice_number}} — {{total_amount}}', body_html: `

Invoice {{invoice_number}}

Dear {{customer_name}},

Please find the attached invoice {{invoice_number}}{{#if event_name}} for "{{event_name}}"{{/if}}.

Amount: {{total_amount}}
Due: {{due_date}}{{#if installment_label}}
Installment: {{installment_label}} ({{installment_index}}/{{installment_total}}){{/if}}

The payment details and IBAN are on the attached PDF.

`, - body_text: `Invoice {{invoice_number}}: {{total_amount}}, due {{due_date}}.`, + body_text: 'Invoice {{invoice_number}}: {{total_amount}}, due {{due_date}}.', }, de: { subject: 'Rechnung {{invoice_number}} — {{total_amount}}', @@ -109,7 +109,7 @@ quote_sent: {

im Anhang finden Sie die Rechnung {{invoice_number}}{{#if event_name}} für "{{event_name}}"{{/if}}.

Betrag: {{total_amount}}
Fällig: {{due_date}}{{#if installment_label}}
Teilzahlung: {{installment_label}} ({{installment_index}}/{{installment_total}}){{/if}}

Die Zahlungsdetails und IBAN finden Sie auf dem beigefügten PDF.

`, - body_text: `Rechnung {{invoice_number}}: {{total_amount}}, fällig {{due_date}}.`, + body_text: 'Rechnung {{invoice_number}}: {{total_amount}}, fällig {{due_date}}.', }, }, invoice_reminder_first: { @@ -120,33 +120,33 @@ quote_sent: { body_html: `

Payment reminder

Dear {{customer_name}},

Our records show that invoice {{invoice_number}} (originally due {{due_date}}) is now {{days_overdue}} days overdue. The outstanding amount is {{total_amount}}.

If you have already paid, please ignore this reminder. Otherwise, please find a fresh copy attached.

`, - body_text: `Invoice {{invoice_number}} is {{days_overdue}} days overdue. Outstanding: {{total_amount}}.`, + body_text: 'Invoice {{invoice_number}} is {{days_overdue}} days overdue. Outstanding: {{total_amount}}.', }, de: { subject: 'Zahlungserinnerung: Rechnung {{invoice_number}}', body_html: `

Zahlungserinnerung

Sehr geehrte/r {{customer_name}},

laut unseren Unterlagen ist die Rechnung {{invoice_number}} (ursprünglich fällig am {{due_date}}) seit {{days_overdue}} Tagen überfällig. Der offene Betrag beträgt {{total_amount}}.

Sollten Sie die Zahlung bereits veranlasst haben, betrachten Sie diese Erinnerung als gegenstandslos. Im Anhang finden Sie eine aktuelle Kopie der Rechnung.

`, - body_text: `Rechnung {{invoice_number}} ist seit {{days_overdue}} Tagen überfällig. Offen: {{total_amount}}.`, + body_text: 'Rechnung {{invoice_number}} ist seit {{days_overdue}} Tagen überfällig. Offen: {{total_amount}}.', }, }, invoice_reminder_second: { category: 'billing', feature_flag: 'bills', variables: ['invoice_number', 'customer_name', 'total_amount', 'due_date', 'days_overdue', - 'late_fee_amount', 'new_total_amount'], + 'late_fee_amount', 'new_total_amount'], en: { subject: 'Second reminder: invoice {{invoice_number}}', body_html: `

Second payment reminder

Dear {{customer_name}},

Invoice {{invoice_number}} is now {{days_overdue}} days overdue. As advised in our payment terms, a late fee of {{late_fee_amount}} has been added. The new total is {{new_total_amount}}.

Please settle the outstanding amount as soon as possible. A revised invoice is attached.

`, - body_text: `Second reminder for {{invoice_number}}. Late fee {{late_fee_amount}} added. New total: {{new_total_amount}}.`, + body_text: 'Second reminder for {{invoice_number}}. Late fee {{late_fee_amount}} added. New total: {{new_total_amount}}.', }, de: { subject: 'Zweite Mahnung: Rechnung {{invoice_number}}', body_html: `

Zweite Zahlungserinnerung

Sehr geehrte/r {{customer_name}},

die Rechnung {{invoice_number}} ist nun seit {{days_overdue}} Tagen überfällig. Gemäss unseren Zahlungsbedingungen wurde eine Mahngebühr von {{late_fee_amount}} hinzugefügt. Der neue Gesamtbetrag beträgt {{new_total_amount}}.

Wir bitten Sie, den offenen Betrag umgehend zu begleichen. Eine aktualisierte Rechnung finden Sie im Anhang.

`, - body_text: `Zweite Mahnung für {{invoice_number}}. Mahngebühr {{late_fee_amount}} hinzugefügt. Neuer Gesamtbetrag: {{new_total_amount}}.`, + body_text: 'Zweite Mahnung für {{invoice_number}}. Mahngebühr {{late_fee_amount}} hinzugefügt. Neuer Gesamtbetrag: {{new_total_amount}}.', }, }, invoice_paid_receipt: { @@ -156,13 +156,13 @@ quote_sent: { subject: 'Receipt for invoice {{invoice_number}}', body_html: `

Payment received

Dear {{customer_name}},

We received your payment of {{paid_amount}} for invoice {{invoice_number}} on {{paid_at}}. Thank you!

`, - body_text: `Receipt: {{paid_amount}} received for {{invoice_number}} on {{paid_at}}.`, + body_text: 'Receipt: {{paid_amount}} received for {{invoice_number}} on {{paid_at}}.', }, de: { subject: 'Zahlungsbestätigung für Rechnung {{invoice_number}}', body_html: `

Zahlung erhalten

Sehr geehrte/r {{customer_name}},

vielen Dank für Ihre Zahlung in Höhe von {{paid_amount}} für die Rechnung {{invoice_number}} am {{paid_at}}.

`, - body_text: `Zahlungsbestätigung: {{paid_amount}} erhalten für {{invoice_number}} am {{paid_at}}.`, + body_text: 'Zahlungsbestätigung: {{paid_amount}} erhalten für {{invoice_number}} am {{paid_at}}.', }, }, invoice_cancelled: { @@ -170,56 +170,56 @@ quote_sent: { variables: ['invoice_number', 'customer_name'], en: { subject: 'Invoice {{invoice_number}} cancelled', - body_html: `

Dear {{customer_name}},

Invoice {{invoice_number}} has been cancelled. Please disregard any previous reminders for this invoice.

`, - body_text: `Invoice {{invoice_number}} has been cancelled.`, + body_html: '

Dear {{customer_name}},

Invoice {{invoice_number}} has been cancelled. Please disregard any previous reminders for this invoice.

', + body_text: 'Invoice {{invoice_number}} has been cancelled.', }, de: { subject: 'Rechnung {{invoice_number}} storniert', - body_html: `

Sehr geehrte/r {{customer_name}},

die Rechnung {{invoice_number}} wurde storniert. Bitte ignorieren Sie eventuelle frühere Erinnerungen zu dieser Rechnung.

`, - body_text: `Rechnung {{invoice_number}} wurde storniert.`, + body_html: '

Sehr geehrte/r {{customer_name}},

die Rechnung {{invoice_number}} wurde storniert. Bitte ignorieren Sie eventuelle frühere Erinnerungen zu dieser Rechnung.

', + body_text: 'Rechnung {{invoice_number}} wurde storniert.', }, }, quote_accepted_customer: { - category: 'quotes', - feature_flag: 'quotes', - variables: ['customer_name', 'quote_number', 'event_name', 'total_amount', 'accepted_on_behalf'], - en: { - subject: 'Quote {{quote_number}} accepted — thank you', - body_html: `

Thank you

+ category: 'quotes', + feature_flag: 'quotes', + variables: ['customer_name', 'quote_number', 'event_name', 'total_amount', 'accepted_on_behalf'], + en: { + subject: 'Quote {{quote_number}} accepted — thank you', + body_html: `

Thank you

Dear {{customer_name}},

This confirms that quote {{quote_number}}{{#if event_name}} for "{{event_name}}"{{/if}} has been accepted. Total: {{total_amount}}.

{{#if accepted_on_behalf}}

This acceptance was recorded on your behalf by your photographer.

{{/if}}

We'll be in touch with next steps shortly.

`, - body_text: `Dear {{customer_name}}, + body_text: `Dear {{customer_name}}, This confirms that quote {{quote_number}}{{#if event_name}} for "{{event_name}}"{{/if}} has been accepted. Total: {{total_amount}}. {{#if accepted_on_behalf}} This acceptance was recorded on your behalf by your photographer. {{/if}} We'll be in touch with next steps shortly.`, - }, - de: { - subject: 'Angebot {{quote_number}} angenommen — vielen Dank', - body_html: `

Vielen Dank

+ }, + de: { + subject: 'Angebot {{quote_number}} angenommen — vielen Dank', + body_html: `

Vielen Dank

Sehr geehrte/r {{customer_name}},

hiermit bestätigen wir, dass das Angebot {{quote_number}}{{#if event_name}} für „{{event_name}}"{{/if}} angenommen wurde. Gesamtbetrag: {{total_amount}}.

{{#if accepted_on_behalf}}

Diese Bestätigung wurde stellvertretend durch Ihren Fotografen erfasst.

{{/if}}

Wir melden uns in Kürze mit den nächsten Schritten.

`, - body_text: `Sehr geehrte/r {{customer_name}}, + body_text: `Sehr geehrte/r {{customer_name}}, hiermit bestätigen wir, dass das Angebot {{quote_number}}{{#if event_name}} für "{{event_name}}"{{/if}} angenommen wurde. Gesamtbetrag: {{total_amount}}. {{#if accepted_on_behalf}} Diese Bestätigung wurde stellvertretend durch Ihren Fotografen erfasst. {{/if}} Wir melden uns in Kürze mit den nächsten Schritten.`, + }, }, -}, invoice_payment_check: { category: 'billing', feature_flag: 'bills', variables: ['invoice_number', 'customer_name', 'event_name', 'due_date', 'total_amount', 'paid_url', 'partial_url', 'unpaid_url', 'skonto_url', 'has_skonto', 'skonto_amount', 'late_fee_due', 'late_fee_amount'], en: { - subject: 'Check payment for invoice {{invoice_number}}', - body_html: `

Time to check on a payment

+ subject: 'Check payment for invoice {{invoice_number}}', + body_html: `

Time to check on a payment

Invoice {{invoice_number}} for {{customer_name}}{{#if event_name}} ({{event_name}}){{/if}} was due on {{due_date}}. Total: {{total_amount}}.

Please check your bank to confirm what (if anything) has been received, then click the matching button below — no login required.

@@ -239,7 +239,7 @@ Wir melden uns in Kürze mit den nächsten Schritten.`,

If you select "Not paid yet" or "Partially paid", the system will queue the next reminder to the customer{{#if late_fee_due}} including a late fee of {{late_fee_amount}}{{/if}}.

`, - body_text: `Time to check on a payment + body_text: `Time to check on a payment Invoice {{invoice_number}} for {{customer_name}}{{#if event_name}} ({{event_name}}){{/if}} was due on {{due_date}}. Total: {{total_amount}}. @@ -250,10 +250,10 @@ Confirm what was received: Not paid yet: {{unpaid_url}} Selecting "Not paid yet" or "Partially paid" will queue the customer reminder{{#if late_fee_due}} including a late fee of {{late_fee_amount}}{{/if}}.`, -}, + }, de: { - subject: 'Zahlung prüfen für Rechnung {{invoice_number}}', - body_html: `

Zahlung prüfen

+ subject: 'Zahlung prüfen für Rechnung {{invoice_number}}', + body_html: `

Zahlung prüfen

Rechnung {{invoice_number}} für {{customer_name}}{{#if event_name}} ({{event_name}}){{/if}} war am {{due_date}} fällig. Gesamtbetrag: {{total_amount}}.

Bitte prüfen Sie auf Ihrem Konto, was eingegangen ist, und klicken Sie unten den passenden Button — kein Login nötig.

@@ -273,7 +273,7 @@ Selecting "Not paid yet" or "Partially paid" will queue the customer reminder{{#

Bei „Nicht bezahlt" oder „Teilweise bezahlt" wird automatisch die Zahlungserinnerung an den Kunden gesendet{{#if late_fee_due}} inklusive Mahngebühr von {{late_fee_amount}}{{/if}}.

`, - body_text: `Zahlung prüfen + body_text: `Zahlung prüfen Rechnung {{invoice_number}} für {{customer_name}}{{#if event_name}} ({{event_name}}){{/if}} war am {{due_date}} fällig. Gesamtbetrag: {{total_amount}}. @@ -284,32 +284,32 @@ Bitte bestätigen: Nicht bezahlt: {{unpaid_url}} Bei „Nicht bezahlt" oder „Teilweise bezahlt" wird automatisch die Zahlungserinnerung gesendet{{#if late_fee_due}} inklusive Mahngebühr von {{late_fee_amount}}{{/if}}.`, -}, + }, }, storno_issued: { category: 'billing', feature_flag: 'bills', variables: ['storno_number', 'original_invoice_number', 'original_issue_date', 'customer_name', 'total_amount'], en: { - subject: 'Cancellation invoice {{storno_number}} for invoice {{original_invoice_number}}', - body_html: `

Dear {{customer_name}},

+ subject: 'Cancellation invoice {{storno_number}} for invoice {{original_invoice_number}}', + body_html: `

Dear {{customer_name}},

Please find attached cancellation invoice {{storno_number}}, which formally reverses invoice {{original_invoice_number}} dated {{original_issue_date}} for {{total_amount}}.

The original invoice is no longer payable. Please retain the attached PDF for your records and disregard any prior reminders.

`, - body_text: `Cancellation invoice {{storno_number}} formally reverses invoice {{original_invoice_number}} dated {{original_issue_date}} for {{total_amount}}. The original invoice is no longer payable. PDF attached.`, -}, + body_text: 'Cancellation invoice {{storno_number}} formally reverses invoice {{original_invoice_number}} dated {{original_issue_date}} for {{total_amount}}. The original invoice is no longer payable. PDF attached.', + }, de: { - subject: 'Stornorechnung {{storno_number}} zu Rechnung {{original_invoice_number}}', - body_html: `

Sehr geehrte/r {{customer_name}},

+ subject: 'Stornorechnung {{storno_number}} zu Rechnung {{original_invoice_number}}', + body_html: `

Sehr geehrte/r {{customer_name}},

anbei erhalten Sie die Stornorechnung {{storno_number}}, mit der die Rechnung {{original_invoice_number}} vom {{original_issue_date}} über {{total_amount}} förmlich aufgehoben wird.

Die ursprüngliche Rechnung ist damit nicht mehr zu begleichen. Bitte bewahren Sie die beigefügte PDF für Ihre Unterlagen auf — etwaige vorherige Mahnungen sind hinfällig.

`, - body_text: `Stornorechnung {{storno_number}} hebt Rechnung {{original_invoice_number}} vom {{original_issue_date}} über {{total_amount}} förmlich auf. Die ursprüngliche Rechnung ist nicht mehr zu begleichen. PDF im Anhang.`, -}, + body_text: 'Stornorechnung {{storno_number}} hebt Rechnung {{original_invoice_number}} vom {{original_issue_date}} über {{total_amount}} förmlich auf. Die ursprüngliche Rechnung ist nicht mehr zu begleichen. PDF im Anhang.', + }, }, invoice_paid_admin_notification: { category: 'billing', feature_flag: 'bills', variables: ['invoice_number', 'customer_name', 'event_name', 'total_amount', 'paid_amount', 'paid_at', 'payment_method', 'payment_reference', 'skonto_applied', 'skonto_percent', 'skonto_discount_amount'], en: { - subject: 'Payment received: invoice {{invoice_number}}', - body_html: `

Payment recorded

+ subject: 'Payment received: invoice {{invoice_number}}', + body_html: `

Payment recorded

Invoice {{invoice_number}} for {{customer_name}}{{#if event_name}} ({{event_name}}){{/if}} has been marked as fully paid.

@@ -320,7 +320,7 @@ Bei „Nicht bezahlt" oder „Teilweise bezahlt" wird automatisch die Zahlungser
Total invoice amount{{total_amount}}
Recorded at{{paid_at}}

This is an automatic notification — no action required.

`, - body_text: `Payment recorded + body_text: `Payment recorded Invoice {{invoice_number}} for {{customer_name}}{{#if event_name}} ({{event_name}}){{/if}} has been marked as fully paid. @@ -332,10 +332,10 @@ Invoice {{invoice_number}} for {{customer_name}}{{#if event_name}} ({{event_name Recorded at: {{paid_at}} This is an automatic notification — no action required.`, -}, + }, de: { - subject: 'Zahlung erhalten: Rechnung {{invoice_number}}', - body_html: `

Zahlung erfasst

+ subject: 'Zahlung erhalten: Rechnung {{invoice_number}}', + body_html: `

Zahlung erfasst

Rechnung {{invoice_number}} für {{customer_name}}{{#if event_name}} ({{event_name}}){{/if}} wurde als vollständig bezahlt markiert.

@@ -346,7 +346,7 @@ This is an automatic notification — no action required.`,
Rechnungsbetrag{{total_amount}}
Erfasst am{{paid_at}}

Automatische Benachrichtigung — keine Aktion erforderlich.

`, - body_text: `Zahlung erfasst + body_text: `Zahlung erfasst Rechnung {{invoice_number}} für {{customer_name}}{{#if event_name}} ({{event_name}}){{/if}} wurde als vollständig bezahlt markiert. @@ -358,14 +358,14 @@ Rechnung {{invoice_number}} für {{customer_name}}{{#if event_name}} ({{event_na Erfasst am: {{paid_at}} Automatische Benachrichtigung — keine Aktion erforderlich.`, -}, + }, }, invoice_collections_handoff: { category: 'billing', feature_flag: 'bills', variables: ['invoice_number', 'customer_name', 'customer_email', 'customer_address', 'event_name', 'original_amount', 'late_fee_amount', 'paid_amount', 'outstanding_amount', 'due_date', 'reminder_level'], en: { - subject: 'Collections handoff: invoice {{invoice_number}} still unpaid after dunning', - body_html: `

Ready to hand to collections

+ subject: 'Collections handoff: invoice {{invoice_number}} still unpaid after dunning', + body_html: `

Ready to hand to collections

Invoice {{invoice_number}}{{#if event_name}} ({{event_name}}){{/if}} is still unpaid after {{reminder_level}} reminders. The invoice PDF is attached for forwarding.

@@ -378,7 +378,7 @@ Automatische Benachrichtigung — keine Aktion erforderlich.`,
Customer{{customer_name}}
Outstanding{{outstanding_amount}}

Forward to your collections agency / for Betreibung. Automatic notification.

`, - body_text: `Ready to hand to collections + body_text: `Ready to hand to collections Invoice {{invoice_number}}{{#if event_name}} ({{event_name}}){{/if}} is still unpaid after {{reminder_level}} reminders. The invoice PDF is attached. @@ -392,10 +392,10 @@ Invoice {{invoice_number}}{{#if event_name}} ({{event_name}}){{/if}} is still un Outstanding: {{outstanding_amount}} Forward to your collections agency / for Betreibung.`, -}, + }, de: { - subject: 'Inkasso-Übergabe: Rechnung {{invoice_number}} trotz Mahnungen offen', - body_html: `

Bereit zur Inkasso-Übergabe

+ subject: 'Inkasso-Übergabe: Rechnung {{invoice_number}} trotz Mahnungen offen', + body_html: `

Bereit zur Inkasso-Übergabe

Rechnung {{invoice_number}}{{#if event_name}} ({{event_name}}){{/if}} ist nach {{reminder_level}} Mahnungen weiterhin offen. Das Rechnungs-PDF ist zur Weiterleitung angehängt.

@@ -408,7 +408,7 @@ Forward to your collections agency / for Betreibung.`,
Kunde{{customer_name}}
Offen{{outstanding_amount}}

Zur Weiterleitung an das Inkasso / für die Betreibung. Automatische Benachrichtigung.

`, - body_text: `Bereit zur Inkasso-Übergabe + body_text: `Bereit zur Inkasso-Übergabe Rechnung {{invoice_number}}{{#if event_name}} ({{event_name}}){{/if}} ist nach {{reminder_level}} Mahnungen weiterhin offen. Das Rechnungs-PDF ist angehängt. @@ -422,7 +422,7 @@ Rechnung {{invoice_number}}{{#if event_name}} ({{event_name}}){{/if}} ist nach { Offen: {{outstanding_amount}} Zur Weiterleitung an das Inkasso / für die Betreibung.`, -}, + }, }, }; diff --git a/backend/src/services/customerHoursService.js b/backend/src/services/customerHoursService.js index ea9e4d4d..b3ed5c10 100644 --- a/backend/src/services/customerHoursService.js +++ b/backend/src/services/customerHoursService.js @@ -23,10 +23,8 @@ * — same legal-record discipline as line items today. */ const { db, logActivity } = require('../database/db'); -const { formatBoolean } = require('../utils/dbCompat'); const { AppError } = require('../utils/errors'); const { hasColumnCached } = require('../utils/schemaCache'); -const logger = require('../utils/logger'); const invoiceService = require('./invoiceService'); // --------------------------------------------------------------------- @@ -287,7 +285,7 @@ async function createEntry(customerId, payload, adminId) { logInfo = { type: 'hour_entry_logged', meta: { entryId, customerId: customer.id } }; return { id: entryId, status: 'unbilled' }; }); - if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) {} } + if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) { /* non-fatal */ } } return result; } @@ -385,7 +383,7 @@ async function updateEntry(entryId, payload, adminId) { logInfo = { type: 'hour_entry_updated', meta: { entryId, customerId: entry.customer_account_id } }; return { id: entryId }; }); - if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) {} } + if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) { /* non-fatal */ } } return result; } @@ -436,7 +434,7 @@ async function deleteEntry(entryId, adminId) { logInfo = { type: 'hour_entry_deleted', meta: { entryId, customerId: entry.customer_account_id, hadInvoice: !!entry.invoice_id } }; return { deleted: true }; }); - if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) {} } + if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) { /* non-fatal */ } } return result; } @@ -521,7 +519,7 @@ async function billUnbilledEntries(customerId, adminId) { logInfo = { type: 'hour_entries_billed', meta: { customerId: customer.id, invoiceId, entryCount: unbilled.length } }; return { invoiceId, entriesBilled: unbilled.length }; }); - if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) {} } + if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) { /* non-fatal */ } } return result; } diff --git a/backend/src/services/databaseBackup.js b/backend/src/services/databaseBackup.js index 99f34dfe..a5f57624 100644 --- a/backend/src/services/databaseBackup.js +++ b/backend/src/services/databaseBackup.js @@ -13,8 +13,6 @@ const { formatBoolean } = require('../utils/dbCompat'); const packageJson = require('../../package.json'); // Constants -const CHUNK_SIZE = 1024 * 1024; // 1MB chunks for streaming -const PROGRESS_INTERVAL = 100; // Report progress every 100 rows // Face recognition tables (#1074). Their SCHEMA is backed up, their CONTENTS // are not: embeddings are biometric data (GDPR Art. 9) and fully derived from @@ -183,7 +181,7 @@ class DatabaseBackupService { /** * Create SQLite backup */ - async createSQLiteBackup(outputPath, options = {}) { + async createSQLiteBackup(outputPath, _options = {}) { const dbPath = knexConfig.connection.filename; const tempPath = `${outputPath}.tmp`; @@ -214,7 +212,7 @@ class DatabaseBackupService { // works out that a manual re-scan is needed. Requeue instead. await spawnAsync('sqlite3', [ tempPath, - "UPDATE photos SET face_status = CASE WHEN face_status IS NULL THEN NULL ELSE 'pending' END, " + 'UPDATE photos SET face_status = CASE WHEN face_status IS NULL THEN NULL ELSE \'pending\' END, ' + 'face_count = NULL, face_started_at = NULL, face_error = NULL;', ]).catch(() => {}); // FATAL, not a warning. Deleting rows leaves their pages in the file @@ -337,7 +335,7 @@ class DatabaseBackupService { /** * Validate backup integrity */ - async validateBackup(backupPath, originalChecksums) { + async validateBackup(backupPath, _originalChecksums) { const tempDbPath = `${backupPath}.validate`; try { @@ -747,7 +745,7 @@ class DatabaseBackupService { /** * Restore from backup (with version checking) */ - async restore(backupPath, options = {}) { + async restore(backupPath, _options = {}) { // This is a dangerous operation and should be used with extreme caution throw new Error('Restore functionality not implemented for safety. Please use restore service or restore manually.'); } diff --git a/backend/src/services/emailProcessor.js b/backend/src/services/emailProcessor.js index 8082bf06..7b1b9bdf 100644 --- a/backend/src/services/emailProcessor.js +++ b/backend/src/services/emailProcessor.js @@ -183,7 +183,7 @@ async function getRecipientLanguage(email, eventId = null) { .first(); if (langSetting && langSetting.setting_value) { let lang = langSetting.setting_value; - try { lang = JSON.parse(lang); } catch (_) {} + try { lang = JSON.parse(lang); } catch (_) { /* non-fatal */ } if (typeof lang === 'string' && lang.trim()) return lang.trim(); } } catch (error) { @@ -789,13 +789,13 @@ async function sendTemplateEmail(to, templateKey, variables) { : undefined; const attachments = Array.isArray(variables.attachments) ? variables.attachments - .filter((a) => a && (a.contentPath || a.path || a.content)) - .map((a) => ({ - filename: a.filename, - path: a.contentPath || a.path, - content: a.content, - contentType: a.contentType, - })) + .filter((a) => a && (a.contentPath || a.path || a.content)) + .map((a) => ({ + filename: a.filename, + path: a.contentPath || a.path, + content: a.content, + contentType: a.contentType, + })) : undefined; // Send email @@ -872,7 +872,7 @@ async function sendRawEmail({ to, cc, subject, html, text, attachments, accountK const ccList = Array.isArray(cc) ? cc.filter(Boolean) : (cc ? [cc] : undefined); const atts = Array.isArray(attachments) ? attachments.filter((a) => a && (a.contentPath || a.path || a.content)) - .map((a) => ({ filename: a.filename, path: a.contentPath || a.path, content: a.content, contentType: a.contentType })) + .map((a) => ({ filename: a.filename, path: a.contentPath || a.path, content: a.content, contentType: a.contentType })) : undefined; const mail = { from: `${fromName || 'picpeak'} <${fromEmail}>`, diff --git a/backend/src/services/eventReminderService.js b/backend/src/services/eventReminderService.js index ed6c7891..418dcac8 100644 --- a/backend/src/services/eventReminderService.js +++ b/backend/src/services/eventReminderService.js @@ -63,8 +63,6 @@ const { ensureEventReminderTemplatesSeeded } = require('./eventReminderTemplates const DEFAULT_DAYS_BEFORE = 2; const DEFAULT_TEMPLATE_GROUP = 'event_reminder'; -const TEMPLATE_KEY_DEFAULT = 'event_reminder_default'; -const TEMPLATE_KEY_PREFIX = 'event_reminder_'; // One-shot guard: the "schema not migrated" warn would otherwise fire // once per cron tick (≈ hourly) on installs that haven't applied diff --git a/backend/src/services/eventReminderTemplates.js b/backend/src/services/eventReminderTemplates.js index 12f9db52..11b89d8d 100644 --- a/backend/src/services/eventReminderTemplates.js +++ b/backend/src/services/eventReminderTemplates.js @@ -37,8 +37,8 @@ const VARIABLES = [ // Tiny HTML signature line shared across templates so the maintainer // only has to brand once. Variables substitute at render time. -const SIGNATURE_EN = `

See you soon,
{{business_name}}

`; -const SIGNATURE_DE = `

Bis bald,
{{business_name}}

`; +const SIGNATURE_EN = '

See you soon,
{{business_name}}

'; +const SIGNATURE_DE = '

Bis bald,
{{business_name}}

'; const EVENT_REMINDER_TEMPLATES = { event_reminder_default: { @@ -54,7 +54,7 @@ const EVENT_REMINDER_TEMPLATES = {

If anything has changed since we last spoke, just hit reply.

${SIGNATURE_EN}`, - body_text: `Hi {{customer_name}},\n\nJust a quick reminder that {{event_name}} is coming up on {{event_date}} — about {{days_before}} day(s) from now.\n\nA few things that help us hit the ground running on the day:\n- Confirm the exact start time and address.\n- Let us know if there is anything we should keep an eye on (VIPs, surprise moments, restricted areas).\n- Indoor venues: a small corner for equipment setup is a huge help.\n\nIf anything has changed since we last spoke, just hit reply.\n\nSee you soon,\n{{business_name}}`, + body_text: 'Hi {{customer_name}},\n\nJust a quick reminder that {{event_name}} is coming up on {{event_date}} — about {{days_before}} day(s) from now.\n\nA few things that help us hit the ground running on the day:\n- Confirm the exact start time and address.\n- Let us know if there is anything we should keep an eye on (VIPs, surprise moments, restricted areas).\n- Indoor venues: a small corner for equipment setup is a huge help.\n\nIf anything has changed since we last spoke, just hit reply.\n\nSee you soon,\n{{business_name}}', }, de: { subject: 'Erinnerung: {{event_name}} in {{days_before}} Tag(en)', @@ -68,7 +68,7 @@ ${SIGNATURE_EN}`,

Hat sich seit unserem letzten Austausch etwas geändert? Einfach kurz auf diese Mail antworten.

${SIGNATURE_DE}`, - body_text: `Hallo {{customer_name}},\n\nkurze Erinnerung: {{event_name}} findet am {{event_date}} statt — in etwa {{days_before}} Tag(en).\n\nDamit wir am Tag selbst sofort loslegen können, helfen uns folgende Punkte sehr:\n- Genaue Startzeit und Adresse bestätigen.\n- Kurz Bescheid geben, falls etwas besonders zu beachten ist (VIPs, Überraschungsmomente, abgesperrte Bereiche).\n- Bei Innen-Locations: eine kleine Ecke für den Equipment-Aufbau ist Gold wert.\n\nHat sich seit unserem letzten Austausch etwas geändert? Einfach kurz auf diese Mail antworten.\n\nBis bald,\n{{business_name}}`, + body_text: 'Hallo {{customer_name}},\n\nkurze Erinnerung: {{event_name}} findet am {{event_date}} statt — in etwa {{days_before}} Tag(en).\n\nDamit wir am Tag selbst sofort loslegen können, helfen uns folgende Punkte sehr:\n- Genaue Startzeit und Adresse bestätigen.\n- Kurz Bescheid geben, falls etwas besonders zu beachten ist (VIPs, Überraschungsmomente, abgesperrte Bereiche).\n- Bei Innen-Locations: eine kleine Ecke für den Equipment-Aufbau ist Gold wert.\n\nHat sich seit unserem letzten Austausch etwas geändert? Einfach kurz auf diese Mail antworten.\n\nBis bald,\n{{business_name}}', }, }, @@ -87,7 +87,7 @@ ${SIGNATURE_DE}`,

If anything has shifted since we last spoke — even small things — just hit reply.

${SIGNATURE_EN}`, - body_text: `Dear {{customer_name}},\n\nYour wedding day is almost here — {{event_date}}, in about {{days_before}} day(s). We are very much looking forward to it.\n\nA short pre-day checklist so the photo coverage flows smoothly:\n- Timeline: a rough hour-by-hour run-of-day helps us anticipate every moment.\n- Family shots: a short list of must-have group photos (with names) keeps the formals quick.\n- Getting-ready space: a room with natural light makes a real difference.\n- Surprises: let us know so we are in the right place — and won't spoil them.\n- Logistics: ceremony start time, venue address, parking notes, coordinator contact.\n\nIf anything has shifted since we last spoke, just hit reply.\n\nSee you soon,\n{{business_name}}`, + body_text: 'Dear {{customer_name}},\n\nYour wedding day is almost here — {{event_date}}, in about {{days_before}} day(s). We are very much looking forward to it.\n\nA short pre-day checklist so the photo coverage flows smoothly:\n- Timeline: a rough hour-by-hour run-of-day helps us anticipate every moment.\n- Family shots: a short list of must-have group photos (with names) keeps the formals quick.\n- Getting-ready space: a room with natural light makes a real difference.\n- Surprises: let us know so we are in the right place — and won\'t spoil them.\n- Logistics: ceremony start time, venue address, parking notes, coordinator contact.\n\nIf anything has shifted since we last spoke, just hit reply.\n\nSee you soon,\n{{business_name}}', }, de: { subject: 'Eure Hochzeit am {{event_date}} — letzte Details', @@ -103,7 +103,7 @@ ${SIGNATURE_EN}`,

Hat sich seit unserem letzten Gespräch etwas verschoben — auch Kleinigkeiten? Einfach kurz antworten.

${SIGNATURE_DE}`, - body_text: `Liebe/r {{customer_name}},\n\neuer grosser Tag steht fast vor der Tür — {{event_date}}, in etwa {{days_before}} Tag(en). Wir freuen uns sehr darauf.\n\nEine kurze Checkliste vor dem Tag:\n- Ablauf: ein grober Stunden-Ablauf hilft uns enorm.\n- Familienbilder: kurze Liste der Wunsch-Gruppenbilder (mit Namen).\n- Getting-Ready-Raum: ein Zimmer mit Tageslicht macht einen riesigen Unterschied.\n- Überraschungen: kurz Bescheid geben, damit wir zur richtigen Zeit am richtigen Ort sind.\n- Logistik: Beginn der Trauung, Adresse, Parkhinweise, Telefonnummer der Tages-Koordination.\n\nHat sich etwas verschoben? Einfach kurz antworten.\n\nBis bald,\n{{business_name}}`, + body_text: 'Liebe/r {{customer_name}},\n\neuer grosser Tag steht fast vor der Tür — {{event_date}}, in etwa {{days_before}} Tag(en). Wir freuen uns sehr darauf.\n\nEine kurze Checkliste vor dem Tag:\n- Ablauf: ein grober Stunden-Ablauf hilft uns enorm.\n- Familienbilder: kurze Liste der Wunsch-Gruppenbilder (mit Namen).\n- Getting-Ready-Raum: ein Zimmer mit Tageslicht macht einen riesigen Unterschied.\n- Überraschungen: kurz Bescheid geben, damit wir zur richtigen Zeit am richtigen Ort sind.\n- Logistik: Beginn der Trauung, Adresse, Parkhinweise, Telefonnummer der Tages-Koordination.\n\nHat sich etwas verschoben? Einfach kurz antworten.\n\nBis bald,\n{{business_name}}', }, }, @@ -120,7 +120,7 @@ ${SIGNATURE_DE}`,

Looking forward to celebrating — let us know if anything has changed.

${SIGNATURE_EN}`, - body_text: `Hi {{customer_name}},\n\n{{event_name}} is coming up on {{event_date}} — about {{days_before}} day(s) away. Quick check-in:\n- Headcount: roughly how many guests?\n- Schedule: when is the cake/song moment?\n- Theme or dress code, if any.\n- Surprises we should keep quiet about?\n\nLooking forward to celebrating — let us know if anything has changed.\n\nSee you soon,\n{{business_name}}`, + body_text: 'Hi {{customer_name}},\n\n{{event_name}} is coming up on {{event_date}} — about {{days_before}} day(s) away. Quick check-in:\n- Headcount: roughly how many guests?\n- Schedule: when is the cake/song moment?\n- Theme or dress code, if any.\n- Surprises we should keep quiet about?\n\nLooking forward to celebrating — let us know if anything has changed.\n\nSee you soon,\n{{business_name}}', }, de: { subject: '{{event_name}} am {{event_date}} — kurze Rückfrage', @@ -134,7 +134,7 @@ ${SIGNATURE_EN}`,

Wir freuen uns auf das Fest — kurz Bescheid geben, falls sich etwas geändert hat.

${SIGNATURE_DE}`, - body_text: `Hallo {{customer_name}},\n\n{{event_name}} steht am {{event_date}} an — in etwa {{days_before}} Tag(en). Kurze Rückfrage:\n- Personenzahl: wie viele Gäste werden in etwa kommen?\n- Ablauf: wann ist der Torten-/Ständchen-Moment?\n- Motto oder Dresscode, falls vorhanden.\n- Überraschungen, über die wir nicht reden sollten?\n\nKurz Bescheid geben, falls sich etwas geändert hat.\n\nBis bald,\n{{business_name}}`, + body_text: 'Hallo {{customer_name}},\n\n{{event_name}} steht am {{event_date}} an — in etwa {{days_before}} Tag(en). Kurze Rückfrage:\n- Personenzahl: wie viele Gäste werden in etwa kommen?\n- Ablauf: wann ist der Torten-/Ständchen-Moment?\n- Motto oder Dresscode, falls vorhanden.\n- Überraschungen, über die wir nicht reden sollten?\n\nKurz Bescheid geben, falls sich etwas geändert hat.\n\nBis bald,\n{{business_name}}', }, }, @@ -153,7 +153,7 @@ ${SIGNATURE_DE}`,

Happy to jump on a 10-min call beforehand if it is easier than email.

${SIGNATURE_EN}`, - body_text: `Dear {{customer_name}},\n\n{{event_name}} is on {{event_date}} — about {{days_before}} day(s) away. To make sure the coverage matches your goals, a few items to confirm:\n- Shot brief: internal comms, press kit, social, website?\n- Agenda / run-of-show: speakers, awards, panels, Q&A.\n- VIPs & brand: names to prioritise, plus logo/colour direction.\n- Access: entrance, loading dock, on-site contact. Photo ID needed?\n- Confidentiality: any no-photo sessions?\n- Delivery: rough turnaround (24h press selects, full gallery later)?\n\nHappy to jump on a 10-min call beforehand if it is easier than email.\n\nSee you soon,\n{{business_name}}`, + body_text: 'Dear {{customer_name}},\n\n{{event_name}} is on {{event_date}} — about {{days_before}} day(s) away. To make sure the coverage matches your goals, a few items to confirm:\n- Shot brief: internal comms, press kit, social, website?\n- Agenda / run-of-show: speakers, awards, panels, Q&A.\n- VIPs & brand: names to prioritise, plus logo/colour direction.\n- Access: entrance, loading dock, on-site contact. Photo ID needed?\n- Confidentiality: any no-photo sessions?\n- Delivery: rough turnaround (24h press selects, full gallery later)?\n\nHappy to jump on a 10-min call beforehand if it is easier than email.\n\nSee you soon,\n{{business_name}}', }, de: { subject: 'Vorbereitung Bildbegleitung: {{event_name}} am {{event_date}}', @@ -169,7 +169,7 @@ ${SIGNATURE_EN}`,

Falls eine kurze 10-Min-Abstimmung einfacher ist als E-Mail, gerne jederzeit melden.

${SIGNATURE_DE}`, - body_text: `Sehr geehrte/r {{customer_name}},\n\n{{event_name}} findet am {{event_date}} statt — in etwa {{days_before}} Tag(en). Damit die Bildstrecke euren Zielen entspricht, kurz folgende Punkte abstimmen:\n- Briefing: interne Kommunikation, Pressekit, Social, Website?\n- Agenda / Ablauf: Speaker, Awards, Panels, Q&A.\n- VIPs & Brand: zu priorisierende Personen, Logo-/Farbvorgaben.\n- Zugang: Eingang, Anlieferung, Ansprechperson am Morgen. Lichtbildausweis nötig?\n- Vertraulichkeit: rein interne Sessions / kein Foto?\n- Lieferung: Turnaround-Zeit (24h Press-Selects, vollständige Galerie später)?\n\nFalls eine 10-Min-Abstimmung einfacher ist, gerne melden.\n\nBis bald,\n{{business_name}}`, + body_text: 'Sehr geehrte/r {{customer_name}},\n\n{{event_name}} findet am {{event_date}} statt — in etwa {{days_before}} Tag(en). Damit die Bildstrecke euren Zielen entspricht, kurz folgende Punkte abstimmen:\n- Briefing: interne Kommunikation, Pressekit, Social, Website?\n- Agenda / Ablauf: Speaker, Awards, Panels, Q&A.\n- VIPs & Brand: zu priorisierende Personen, Logo-/Farbvorgaben.\n- Zugang: Eingang, Anlieferung, Ansprechperson am Morgen. Lichtbildausweis nötig?\n- Vertraulichkeit: rein interne Sessions / kein Foto?\n- Lieferung: Turnaround-Zeit (24h Press-Selects, vollständige Galerie später)?\n\nFalls eine 10-Min-Abstimmung einfacher ist, gerne melden.\n\nBis bald,\n{{business_name}}', }, }, @@ -186,7 +186,7 @@ ${SIGNATURE_DE}`,

If anything has changed since we last spoke, hit reply.

${SIGNATURE_EN}`, - body_text: `Hi {{customer_name}},\n\n{{event_name}} is on {{event_date}} — about {{days_before}} day(s) away. A short prep note:\n- Start time & address: please confirm both.\n- Run-of-day: a rough timeline of the key moments.\n- Setup space: a small corner for gear if indoors.\n- Anything specific: people to prioritise, things to avoid, dress code, surprises.\n\nIf anything has changed, just hit reply.\n\nSee you soon,\n{{business_name}}`, + body_text: 'Hi {{customer_name}},\n\n{{event_name}} is on {{event_date}} — about {{days_before}} day(s) away. A short prep note:\n- Start time & address: please confirm both.\n- Run-of-day: a rough timeline of the key moments.\n- Setup space: a small corner for gear if indoors.\n- Anything specific: people to prioritise, things to avoid, dress code, surprises.\n\nIf anything has changed, just hit reply.\n\nSee you soon,\n{{business_name}}', }, de: { subject: '{{event_name}} am {{event_date}} — Vorbereitungs-Hinweise', @@ -200,7 +200,7 @@ ${SIGNATURE_EN}`,

Hat sich seit dem letzten Austausch etwas geändert? Einfach kurz antworten.

${SIGNATURE_DE}`, - body_text: `Hallo {{customer_name}},\n\n{{event_name}} findet am {{event_date}} statt — in etwa {{days_before}} Tag(en). Kurz zur Vorbereitung:\n- Startzeit & Adresse: bitte beides kurz bestätigen.\n- Ablauf: ein grober Zeitplan der Schlüsselmomente.\n- Aufbauplatz: bei Innen-Locations eine kleine Ecke fürs Equipment.\n- Besonderheiten: Personen im Fokus, Dinge zu vermeiden, Dresscode, Überraschungen.\n\nKurz antworten, falls sich etwas geändert hat.\n\nBis bald,\n{{business_name}}`, + body_text: 'Hallo {{customer_name}},\n\n{{event_name}} findet am {{event_date}} statt — in etwa {{days_before}} Tag(en). Kurz zur Vorbereitung:\n- Startzeit & Adresse: bitte beides kurz bestätigen.\n- Ablauf: ein grober Zeitplan der Schlüsselmomente.\n- Aufbauplatz: bei Innen-Locations eine kleine Ecke fürs Equipment.\n- Besonderheiten: Personen im Fokus, Dinge zu vermeiden, Dresscode, Überraschungen.\n\nKurz antworten, falls sich etwas geändert hat.\n\nBis bald,\n{{business_name}}', }, }, }; diff --git a/backend/src/services/eventRenameService.js b/backend/src/services/eventRenameService.js index 9488aa9f..3444edb2 100644 --- a/backend/src/services/eventRenameService.js +++ b/backend/src/services/eventRenameService.js @@ -3,7 +3,7 @@ * Handles renaming events including slug updates, file system changes, and database updates */ -const { db, logActivity } = require('../database/db'); +const { db } = require('../database/db'); const fs = require('fs').promises; const path = require('path'); const logger = require('../utils/logger'); @@ -228,7 +228,7 @@ class EventRenameService { const event = await trx('events').where({ id: eventId }).first(); // Generate new share link - const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ + const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug: newSlug, shareToken: event.share_token }); diff --git a/backend/src/services/expenseCategoriesService.js b/backend/src/services/expenseCategoriesService.js index 262de3a9..cf94f0be 100644 --- a/backend/src/services/expenseCategoriesService.js +++ b/backend/src/services/expenseCategoriesService.js @@ -20,7 +20,7 @@ async function getById(id) { return row; } -async function create({ name, color, displayOrder }, adminId) { +async function create({ name, color, displayOrder }, _adminId) { if (!name || !String(name).trim()) { throw new AppError('Category name is required', 400, 'NAME_REQUIRED'); } diff --git a/backend/src/services/externalMediaService.js b/backend/src/services/externalMediaService.js index 0ad9048f..4b60d2ea 100644 --- a/backend/src/services/externalMediaService.js +++ b/backend/src/services/externalMediaService.js @@ -61,27 +61,23 @@ async function list(relativePath = '') { const targetDir = safePathJoin(root, relativePath || '.'); const entries = []; - try { - const dirents = await fs.readdir(targetDir, { withFileTypes: true }); - for (const d of dirents) { - // Skip hidden files and directories - if (d.name.startsWith('.')) continue; - const full = path.join(targetDir, d.name); - const stat = await fs.stat(full).catch(() => null); - if (!stat) continue; + // Errors propagate to the caller to handle (e.g. invalid path). + const dirents = await fs.readdir(targetDir, { withFileTypes: true }); + for (const d of dirents) { + // Skip hidden files and directories + if (d.name.startsWith('.')) continue; + const full = path.join(targetDir, d.name); + const stat = await fs.stat(full).catch(() => null); + if (!stat) continue; - if (d.isDirectory()) { - entries.push({ name: d.name, type: 'dir' }); - } else if (d.isFile()) { - const ext = path.extname(d.name).toLowerCase(); - if (['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) { - entries.push({ name: d.name, type: 'file', size: stat.size, mtime: stat.mtime }); - } + if (d.isDirectory()) { + entries.push({ name: d.name, type: 'dir' }); + } else if (d.isFile()) { + const ext = path.extname(d.name).toLowerCase(); + if (['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) { + entries.push({ name: d.name, type: 'file', size: stat.size, mtime: stat.mtime }); } } - } catch (e) { - // Propagate errors for caller to handle (e.g., invalid path) - throw e; } const rootResolved = path.resolve(root); diff --git a/backend/src/services/feedbackService.js b/backend/src/services/feedbackService.js index d4629c8a..09859a94 100644 --- a/backend/src/services/feedbackService.js +++ b/backend/src/services/feedbackService.js @@ -1172,32 +1172,32 @@ class FeedbackService { if (!entry.guest_email && row.guest_email) entry.guest_email = row.guest_email; switch (row.feedback_type) { - case 'favorite': - entry.is_favorited = true; - break; - case 'like': - entry.is_liked = true; - break; - case 'rating': - if (row.rating != null) entry.star_rating = row.rating; - break; - case 'comment': - if (row.comment_text) { - // Most recent comment wins. Older comments from the same guest - // on the same photo are dropped — the export is "current state", - // not the comment history. - entry.comment = row.comment_text; - } - break; - case 'reaction': - if (row.reaction) entry.reaction = row.reaction; - break; - case 'color_label': - if (row.color_label) entry.color_label = row.color_label; - break; - default: - // Unknown feedback type — ignore so a future type doesn't break the export. - break; + case 'favorite': + entry.is_favorited = true; + break; + case 'like': + entry.is_liked = true; + break; + case 'rating': + if (row.rating != null) entry.star_rating = row.rating; + break; + case 'comment': + if (row.comment_text) { + // Most recent comment wins. Older comments from the same guest + // on the same photo are dropped — the export is "current state", + // not the comment history. + entry.comment = row.comment_text; + } + break; + case 'reaction': + if (row.reaction) entry.reaction = row.reaction; + break; + case 'color_label': + if (row.color_label) entry.color_label = row.color_label; + break; + default: + // Unknown feedback type — ignore so a future type doesn't break the export. + break; } // Track the latest action timestamp across all feedback types. if (row.created_at && entry.latest_at && row.created_at > entry.latest_at) { diff --git a/backend/src/services/fileWatcher.js b/backend/src/services/fileWatcher.js index 203c2a9c..a8c0633d 100644 --- a/backend/src/services/fileWatcher.js +++ b/backend/src/services/fileWatcher.js @@ -40,7 +40,7 @@ function startFileWatcher() { } const watcher = chokidar.watch(WATCH_PATH(), { - ignored: /(^|[\/\\])\../, // ignore dotfiles + ignored: /(^|[/\\])\../, // ignore dotfiles persistent: true, awaitWriteFinish: { stabilityThreshold: 2000, diff --git a/backend/src/services/fontsService.js b/backend/src/services/fontsService.js index 5d658adf..b90c55e1 100644 --- a/backend/src/services/fontsService.js +++ b/backend/src/services/fontsService.js @@ -166,7 +166,7 @@ async function scanRoot(rootAbs) { if (result.has(lc)) { logger.warn( `[fonts] Duplicate family ${family.family} within ${rootAbs}; ` + - `keeping the first encountered folder` + 'keeping the first encountered folder' ); continue; } diff --git a/backend/src/services/invoice/create.js b/backend/src/services/invoice/create.js index 735a7817..2e055cc1 100644 --- a/backend/src/services/invoice/create.js +++ b/backend/src/services/invoice/create.js @@ -306,7 +306,7 @@ async function createInvoice(payload, adminId, trx = db) { await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', invoiceId, items); } - try { await logActivity('invoice_created', { invoiceId, invoiceNumber }, payload.eventId || null, `admin:${adminId}`, trx); } catch (_) {} + try { await logActivity('invoice_created', { invoiceId, invoiceNumber }, payload.eventId || null, `admin:${adminId}`, trx); } catch (_) { /* non-fatal */ } return { invoiceIds: [invoiceId] }; } @@ -568,7 +568,7 @@ async function spawnInstallmentInvoices({ trx, eventId, quoteId, customer, curre // pool (this runs unattended from the booking flow's prepare_invoice). await logActivity('invoice_scheduled', { invoiceId, invoiceNumber, eventId, quoteId, scheduledSendAt }, eventId, `admin:${adminId}`, trx); - } catch (_) {} + } catch (_) { /* non-fatal */ } invoiceIds.push(invoiceId); } return { invoiceIds }; diff --git a/backend/src/services/invoice/drafts.js b/backend/src/services/invoice/drafts.js index 7bd4bd5c..f41587a3 100644 --- a/backend/src/services/invoice/drafts.js +++ b/backend/src/services/invoice/drafts.js @@ -221,7 +221,7 @@ async function appendToMonthlyDraft(payload, customer, adminId, trx) { await logActivity('monthly_billing_items_queued', { invoiceId: draft.id, customerId: customer.id, itemsAdded: newItems.length }, null, `admin:${adminId}`); - } catch (_) {} + } catch (_) { /* non-fatal */ } return draft.id; } diff --git a/backend/src/services/invoice/installmentPlan.js b/backend/src/services/invoice/installmentPlan.js index fec5cf83..925f7c2c 100644 --- a/backend/src/services/invoice/installmentPlan.js +++ b/backend/src/services/invoice/installmentPlan.js @@ -347,7 +347,7 @@ async function updateInstallmentPlan({ trx, dealUuid, installments, adminId }) { await logActivity('invoice_scheduled', { invoiceId: newId, invoiceNumber, eventId: sample.event_id, source: 'plan_reshape', }, sample.event_id, `admin:${adminId}`); - } catch (_) {} + } catch (_) { /* non-fatal */ } created.push(newId); } @@ -365,7 +365,7 @@ async function updateInstallmentPlan({ trx, dealUuid, installments, adminId }) { dealUuid, newCount, kept: kept.length, created: created.length, deleted: deleted.length, }, sample.event_id, `admin:${adminId}`); - } catch (_) {} + } catch (_) { /* non-fatal */ } return { invoiceIds: [...kept, ...created], diff --git a/backend/src/services/invoice/payments.js b/backend/src/services/invoice/payments.js index 81cf9d3b..4e9e35e0 100644 --- a/backend/src/services/invoice/payments.js +++ b/backend/src/services/invoice/payments.js @@ -88,7 +88,7 @@ async function markPaid(id, { amountMinor, paidAt, paymentMethod, reference, not try { await logActivity(isFull ? 'invoice_paid' : 'invoice_partial_payment', { invoiceId: id, amountMinor: amount, totalPaidMinor: total }, - invoice.event_id || null, `admin:${adminId}`); } catch (_) {} + invoice.event_id || null, `admin:${adminId}`); } catch (_) { /* non-fatal */ } // Migration 127 — admin payment-received notification. Fires only // on the transition into 'paid' so admins don't get duplicate @@ -134,7 +134,7 @@ async function markPaid(id, { amountMinor, paidAt, paymentMethod, reference, not paidTotalMinor: markResult.paidTotalMinor, }, }); - } catch (_) {} + } catch (_) { /* non-fatal */ } } return markResult; } @@ -203,7 +203,7 @@ async function queueInvoicePaidAdminNotification({ try { await logActivity('invoice_paid_admin_notified', { invoiceId: invoice.id }, invoice.event_id || null, 'system'); - } catch (_) {} + } catch (_) { /* non-fatal */ } } async function queuePaymentCheckEmail(invoiceId, { skipThrottle = false } = {}) { @@ -318,7 +318,7 @@ async function queuePaymentCheckEmail(invoiceId, { skipThrottle = false } = {}) try { await logActivity('invoice_payment_check_sent', { invoiceId, token: token.slice(0, 8) }, invoice.event_id || null, 'scheduler'); - } catch (_) {} + } catch (_) { /* non-fatal */ } return { token, sent: true }; } @@ -449,7 +449,7 @@ async function recordPaymentCheckAction({ token, action, amountMinor, ip, adminI { invoiceId: invoice.id, action, amountMinor: amountMinor || null }, invoice.event_id || null, adminId ? `admin:${adminId}` : 'public:payment-check'); - } catch (_) {} + } catch (_) { /* non-fatal */ } // --- Apply the action ----------------------------------------- if (action === 'paid_full') { diff --git a/backend/src/services/invoice/reminders.js b/backend/src/services/invoice/reminders.js index 0f4042f6..c7f5ec61 100644 --- a/backend/src/services/invoice/reminders.js +++ b/backend/src/services/invoice/reminders.js @@ -117,7 +117,7 @@ async function applyReminder(invoice, lineItems, level, adminId) { currency: invoice.currency, }, }); - } catch (_) {} + } catch (_) { /* non-fatal */ } // Render the MAHNUNG (reminder letter). The original invoice PDF is left // UNTOUCHED (immutable). The Mahnung reuses the invoice layout via a @@ -180,7 +180,7 @@ async function applyReminder(invoice, lineItems, level, adminId) { try { await logActivity('invoice_reminder_sent', { invoiceId: invoice.id, level, lateFeeMinor: lateFeeGross }, invoice.event_id || null, `admin:${adminId || 'system'}`); - } catch (_) {} + } catch (_) { /* non-fatal */ } return { level, lateFeeMinor: lateFeeGross }; } diff --git a/backend/src/services/invoice/scheduler.js b/backend/src/services/invoice/scheduler.js index 581c08a9..8c16206c 100644 --- a/backend/src/services/invoice/scheduler.js +++ b/backend/src/services/invoice/scheduler.js @@ -76,7 +76,7 @@ async function runScheduledTasks() { await logActivity('monthly_bill_skipped_empty', { invoiceId: draft.id, customerId: draft.customer_account_id }, null, 'scheduler'); - } catch (_) {} + } catch (_) { /* non-fatal */ } continue; } // Arm for the flush pass: clear the draft flag, set the send @@ -95,7 +95,7 @@ async function runScheduledTasks() { { invoiceId: draft.id, customerId: draft.customer_account_id, periodEnd: draft.monthly_period_end }, null, 'scheduler'); - } catch (_) {} + } catch (_) { /* non-fatal */ } } catch (err) { logger.error('Monthly bill issuance failed', { invoiceId: draft.id, err: err.message }); } diff --git a/backend/src/services/invoice/sending.js b/backend/src/services/invoice/sending.js index 05ef0ace..d2b01446 100644 --- a/backend/src/services/invoice/sending.js +++ b/backend/src/services/invoice/sending.js @@ -159,7 +159,7 @@ async function sendInvoice(id, adminId, options = {}) { attachments: invoiceAttachments, }); - try { await logActivity('invoice_sent', { invoiceId: id }, invoice.event_id || null, `admin:${adminId}`); } catch (_) {} + try { await logActivity('invoice_sent', { invoiceId: id }, invoice.event_id || null, `admin:${adminId}`); } catch (_) { /* non-fatal */ } // Fire the workflow engine's invoice.sent trigger (after the row is updated + // the email queued). Idempotent per invoice id; no-op when the workflows flag @@ -180,7 +180,7 @@ async function sendInvoice(id, adminId, options = {}) { currency: invoice.currency, }, }); - } catch (_) {} + } catch (_) { /* non-fatal */ } return { sent: true, pdfPath }; } @@ -355,7 +355,7 @@ async function createStorno(originalId, adminId, trx = db) { await logActivity('invoice_cancelled_via_storno', { invoiceId: originalId, stornoId, stornoNumber }, original.event_id || null, `admin:${adminId}`, trx); - } catch (_) {} + } catch (_) { /* non-fatal */ } return stornoId; } @@ -430,7 +430,7 @@ async function sendStorno(stornoId, adminId) { await logActivity('storno_sent', { stornoId, stornoNumber: storno.invoice_number, originalInvoiceId: storno.cancels_invoice_id || null }, storno.event_id || null, `admin:${adminId || 'system'}`); - } catch (_) {} + } catch (_) { /* non-fatal */ } return { status: 'sent', stornoId }; } @@ -558,7 +558,7 @@ async function reissueInvoice(id, adminId) { await logActivity('invoice_reissued', { originalInvoiceId: id, newInvoiceId: newId, stornoId }, original.event_id || null, `admin:${adminId}`, trx); - } catch (_) {} + } catch (_) { /* non-fatal */ } return { id: newId, replaces: id, stornoId }; }); @@ -592,7 +592,7 @@ async function releaseForDelivery(id, adminId) { }); try { await logActivity('invoice_released_for_delivery', { invoiceId: id }, invoice.event_id || null, `admin:${adminId}`); - } catch (_) {} + } catch (_) { /* non-fatal */ } // Fire immediately rather than waiting for the next scheduler // tick — admin clicked the button because they want it out now. return await sendInvoice(id, adminId); @@ -646,7 +646,7 @@ async function cancelInvoice(id, adminId) { await logActivity('invoice_cancelled', { invoiceId: id, viaStorno: false }, invoice.event_id || null, `admin:${adminId}`); - } catch (_) {} + } catch (_) { /* non-fatal */ } return { cancelled: true, stornoId: null }; } @@ -690,7 +690,7 @@ async function triggerMonthlyBillNow(customerId, adminId) { await logActivity('monthly_bill_triggered_manually', { invoiceId: draft.id, customerId, periodEnd: draft.monthly_period_end }, null, `admin:${adminId}`); - } catch (_) {} + } catch (_) { /* non-fatal */ } // Inline send so admin gets immediate feedback (PDF stored, status // flipped to 'sent', email queued). A failure here doesn't roll diff --git a/backend/src/services/pdfService.js b/backend/src/services/pdfService.js index f6d6bce9..c9d5823e 100644 --- a/backend/src/services/pdfService.js +++ b/backend/src/services/pdfService.js @@ -527,15 +527,6 @@ function drawTitle(doc, title, x, y) { return doc.y + 8; } -function drawDate(doc, label, value, x, y, width) { - doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#000'); - const right = x + width; - const labelWidth = 80; - doc.text(`${label}:`, right - labelWidth - 80, y, { width: 80, align: 'right' }); - doc.text(value, right - 80, y, { width: 80, align: 'right' }); - return doc.y + 10; -} - /** * Render the line-items table via swissqrbill's Table helper. We supply * widths in points; the helper draws the borderless layout the @@ -667,20 +658,20 @@ function drawLineItems(doc, ctx) { borderWidth: [0, 0, 0, 0], columns: showDiscount ? [ - { text: posLabel, width: widths[0], align: 'left' }, - { text: descText, width: widths[1], align: 'left', color: numericColor }, - { text: stripTrailingZeros(li.quantity), width: widths[2], align: 'right', color: numericColor }, - { text: subItemPriceless ? '' : `${stripTrailingZeros(li.discountPercent)}%`, width: widths[3], align: 'right', color: numericColor }, - { text: unitText, width: widths[4], align: 'right', color: numericColor }, - { text: lineTotalText, width: widths[5], align: 'right', color: numericColor }, - ] + { text: posLabel, width: widths[0], align: 'left' }, + { text: descText, width: widths[1], align: 'left', color: numericColor }, + { text: stripTrailingZeros(li.quantity), width: widths[2], align: 'right', color: numericColor }, + { text: subItemPriceless ? '' : `${stripTrailingZeros(li.discountPercent)}%`, width: widths[3], align: 'right', color: numericColor }, + { text: unitText, width: widths[4], align: 'right', color: numericColor }, + { text: lineTotalText, width: widths[5], align: 'right', color: numericColor }, + ] : [ - { text: posLabel, width: widths[0], align: 'left' }, - { text: descText, width: widths[1], align: 'left', color: numericColor }, - { text: stripTrailingZeros(li.quantity), width: widths[2], align: 'right', color: numericColor }, - { text: unitText, width: widths[3], align: 'right', color: numericColor }, - { text: lineTotalText, width: widths[4], align: 'right', color: numericColor }, - ], + { text: posLabel, width: widths[0], align: 'left' }, + { text: descText, width: widths[1], align: 'left', color: numericColor }, + { text: stripTrailingZeros(li.quantity), width: widths[2], align: 'right', color: numericColor }, + { text: unitText, width: widths[3], align: 'right', color: numericColor }, + { text: lineTotalText, width: widths[4], align: 'right', color: numericColor }, + ], }; }; @@ -697,20 +688,20 @@ function drawLineItems(doc, ctx) { borderWidth: [0, 0, 0, 0], columns: showDiscount ? [ - { text: '', width: widths[0], align: 'left' }, - { text, width: widths[1], align: 'left', color: '#666', fontName: 'Helvetica-Oblique' }, - { text: '', width: widths[2], align: 'right' }, - { text: '', width: widths[3], align: 'right' }, - { text: '', width: widths[4], align: 'right' }, - { text: '', width: widths[5], align: 'right' }, - ] + { text: '', width: widths[0], align: 'left' }, + { text, width: widths[1], align: 'left', color: '#666', fontName: 'Helvetica-Oblique' }, + { text: '', width: widths[2], align: 'right' }, + { text: '', width: widths[3], align: 'right' }, + { text: '', width: widths[4], align: 'right' }, + { text: '', width: widths[5], align: 'right' }, + ] : [ - { text: '', width: widths[0], align: 'left' }, - { text, width: widths[1], align: 'left', color: '#666', fontName: 'Helvetica-Oblique' }, - { text: '', width: widths[2], align: 'right' }, - { text: '', width: widths[3], align: 'right' }, - { text: '', width: widths[4], align: 'right' }, - ], + { text: '', width: widths[0], align: 'left' }, + { text, width: widths[1], align: 'left', color: '#666', fontName: 'Helvetica-Oblique' }, + { text: '', width: widths[2], align: 'right' }, + { text: '', width: widths[3], align: 'right' }, + { text: '', width: widths[4], align: 'right' }, + ], }); const headerRow = { @@ -724,20 +715,20 @@ function drawLineItems(doc, ctx) { header: true, columns: showDiscount ? [ - { text: labels.pos, width: widths[0], align: 'left' }, - { text: labels.desc, width: widths[1], align: 'left' }, - { text: labels.qty, width: widths[2], align: 'right' }, - { text: labels.disc, width: widths[3], align: 'right' }, - { text: labels.unit, width: widths[4], align: 'right' }, - { text: labels.total, width: widths[5], align: 'right' }, - ] + { text: labels.pos, width: widths[0], align: 'left' }, + { text: labels.desc, width: widths[1], align: 'left' }, + { text: labels.qty, width: widths[2], align: 'right' }, + { text: labels.disc, width: widths[3], align: 'right' }, + { text: labels.unit, width: widths[4], align: 'right' }, + { text: labels.total, width: widths[5], align: 'right' }, + ] : [ - { text: labels.pos, width: widths[0], align: 'left' }, - { text: labels.desc, width: widths[1], align: 'left' }, - { text: labels.qty, width: widths[2], align: 'right' }, - { text: labels.unit, width: widths[3], align: 'right' }, - { text: labels.total, width: widths[4], align: 'right' }, - ], + { text: labels.pos, width: widths[0], align: 'left' }, + { text: labels.desc, width: widths[1], align: 'left' }, + { text: labels.qty, width: widths[2], align: 'right' }, + { text: labels.unit, width: widths[3], align: 'right' }, + { text: labels.total, width: widths[4], align: 'right' }, + ], }; // Group rows so a parent + its sub-items + every involved details_text @@ -1427,388 +1418,388 @@ function renderDocument(type, context) { // Errors from the IIFE bubble up via reject(); the doc 'end' // event still resolves the outer Promise once writes flush. (async () => { - try { - const ctx = normaliseContext(type, context); - const doc = new PDFDocument({ - size: 'A4', - // bufferPages: true keeps every page open in memory after - // they're emitted so we can switch back and stamp the page - // numbers ("Page 1 of N" / "Seite 1 von N") once we know how - // many pages the document ended up with. Without buffering, - // PDFKit flushes each page as soon as the next one starts, - // so we couldn't know N until it was too late. - bufferPages: true, - margins: { - top: PAGE.marginTop, bottom: PAGE.marginBottom, - left: PAGE.marginLeft, right: PAGE.marginRight, - }, - info: { + try { + const ctx = normaliseContext(type, context); + const doc = new PDFDocument({ + size: 'A4', + // bufferPages: true keeps every page open in memory after + // they're emitted so we can switch back and stamp the page + // numbers ("Page 1 of N" / "Seite 1 von N") once we know how + // many pages the document ended up with. Without buffering, + // PDFKit flushes each page as soon as the next one starts, + // so we couldn't know N until it was too late. + bufferPages: true, + margins: { + top: PAGE.marginTop, bottom: PAGE.marginBottom, + left: PAGE.marginLeft, right: PAGE.marginRight, + }, + info: { // Chrome's built-in PDF viewer uses this Title metadata // as the default save name when the PDF is served from a // blob URL (where the original HTTP Content-Disposition // header can't propagate). Format mirrors the filename // we set on the HTTP response: "_" // so saved files have a meaningful name in either path. - Title: (() => { - const docNumber = ctx.doc.invoiceNumber || ctx.doc.quoteNumber + Title: (() => { + const docNumber = ctx.doc.invoiceNumber || ctx.doc.quoteNumber || (type === 'quote' ? 'Quote' : 'Invoice'); - // Prefer the recipient (customer) for the label — - // matches how admins typically file invoices. - const recipient = ctx.recipient?.companyName || ''; - return recipient ? `${docNumber}_${recipient}` : String(docNumber); - })(), - Author: ctx.issuer.companyName || 'picpeak', - }, - }); + // Prefer the recipient (customer) for the label — + // matches how admins typically file invoices. + const recipient = ctx.recipient?.companyName || ''; + return recipient ? `${docNumber}_${recipient}` : String(docNumber); + })(), + Author: ctx.issuer.companyName || 'picpeak', + }, + }); - const chunks = []; - doc.on('data', (c) => chunks.push(c)); - doc.on('end', () => resolve(Buffer.concat(chunks))); - doc.on('error', reject); + const chunks = []; + doc.on('data', (c) => chunks.push(c)); + doc.on('end', () => resolve(Buffer.concat(chunks))); + doc.on('error', reject); - // Font registration. Same resolution priority as - // createBaseDocument: pdfFontTtfPath (legacy override) → - // pdfFontFamily (bundled dropdown) → Helvetica. Helpers below - // read `doc._fonts` (one extra word per doc) so we don't have - // to thread the font names through every drawing function or - // fork the helpers per branding. - doc._fonts = { body: FONT_BODY, bold: FONT_BOLD }; - ctx.fonts = doc._fonts; - const registered = registerCustomFonts(doc, ctx.issuer); - if (registered) { - doc._fonts = registered; - ctx.fonts = registered; - } + // Font registration. Same resolution priority as + // createBaseDocument: pdfFontTtfPath (legacy override) → + // pdfFontFamily (bundled dropdown) → Helvetica. Helpers below + // read `doc._fonts` (one extra word per doc) so we don't have + // to thread the font names through every drawing function or + // fork the helpers per branding. + doc._fonts = { body: FONT_BODY, bold: FONT_BOLD }; + ctx.fonts = doc._fonts; + const registered = registerCustomFonts(doc, ctx.issuer); + if (registered) { + doc._fonts = registered; + ctx.fonts = registered; + } - // ---- header layout (DIN 5008 Form B) ------------------------- - // - recipient block in the address window (top-left, - // 45mm from top, 20mm from left, 85×45mm) - // - issuer block top-right (logo + company + address + - // contact) sized to NOT overlap the address window - // - // The two blocks are positioned absolutely; we keep a `y` - // cursor for the body content that starts BELOW both blocks. - const leftX = PAGE.marginLeft; - // Sender block: narrower (180pt vs 220pt), further right, and - // nudged down by 16pt so it doesn't crowd the very top of the - // page. Leaves more breathing room for the logo + name banner. - const issuerWidth = 180; - const issuerX = PAGE.width - PAGE.marginRight - issuerWidth; - const issuerY = PAGE.marginTop + 16; + // ---- header layout (DIN 5008 Form B) ------------------------- + // - recipient block in the address window (top-left, + // 45mm from top, 20mm from left, 85×45mm) + // - issuer block top-right (logo + company + address + + // contact) sized to NOT overlap the address window + // + // The two blocks are positioned absolutely; we keep a `y` + // cursor for the body content that starts BELOW both blocks. + const leftX = PAGE.marginLeft; + // Sender block: narrower (180pt vs 220pt), further right, and + // nudged down by 16pt so it doesn't crowd the very top of the + // page. Leaves more breathing room for the logo + name banner. + const issuerWidth = 180; + const issuerX = PAGE.width - PAGE.marginRight - issuerWidth; + const issuerY = PAGE.marginTop + 16; - const issuerEndY = drawIssuerBlock(doc, ctx.issuer, issuerX, issuerY, issuerWidth, ctx.locale); - const recipientEndY = drawRecipientBlock(doc, ctx.recipient, ctx.locale); - // Start the body content below the header blocks AND the - // address-window bottom edge — never let the date/title row - // cut through the window region. The title position isn't - // dictated by DIN 5008 (the spec only fixes the address window - // position), so we pull it tight against the window's bottom - // edge to give the body more vertical room. - let y = Math.max(issuerEndY, recipientEndY, ADDR_WINDOW.top + ADDR_WINDOW.height) + 6; + const issuerEndY = drawIssuerBlock(doc, ctx.issuer, issuerX, issuerY, issuerWidth, ctx.locale); + const recipientEndY = drawRecipientBlock(doc, ctx.recipient, ctx.locale); + // Start the body content below the header blocks AND the + // address-window bottom edge — never let the date/title row + // cut through the window region. The title position isn't + // dictated by DIN 5008 (the spec only fixes the address window + // position), so we pull it tight against the window's bottom + // edge to give the body more vertical room. + let y = Math.max(issuerEndY, recipientEndY, ADDR_WINDOW.top + ADDR_WINDOW.height) + 6; - // Storno discriminator. Drives: - // - page title swap ("Stornorechnung" instead of "Rechnung") - // - mandatory reference line under the title - // - sign flip on line totals (row-level totals are already - // stored negative in the DB, so drawTotals renders them - // naturally — see drawLineItems for the per-item flip) - // - suppression of payment terms / IBAN / QR-bill blocks - // `type === 'invoice'` is preserved as the outer document - // family — Storni share the invoice renderer surface, only - // the cosmetic + accounting-sign branches differ. - const isStorno = type === 'invoice' && ctx.doc.kind === 'storno'; - // Mahnung (reminder letter) reuses the invoice surface: same line items + - // a Mahngebühr row + the new grand total, but a "Mahnung" title and NO - // QR (the QR would encode the original amount, not the new total). - const isMahnung = type === 'invoice' && ctx.doc.kind === 'mahnung'; + // Storno discriminator. Drives: + // - page title swap ("Stornorechnung" instead of "Rechnung") + // - mandatory reference line under the title + // - sign flip on line totals (row-level totals are already + // stored negative in the DB, so drawTotals renders them + // naturally — see drawLineItems for the per-item flip) + // - suppression of payment terms / IBAN / QR-bill blocks + // `type === 'invoice'` is preserved as the outer document + // family — Storni share the invoice renderer surface, only + // the cosmetic + accounting-sign branches differ. + const isStorno = type === 'invoice' && ctx.doc.kind === 'storno'; + // Mahnung (reminder letter) reuses the invoice surface: same line items + + // a Mahngebühr row + the new grand total, but a "Mahnung" title and NO + // QR (the QR would encode the original amount, not the new total). + const isMahnung = type === 'invoice' && ctx.doc.kind === 'mahnung'; - // ---- document number (above) + date (below), both right-aligned - // The number sits directly under the sender address block so the - // customer + accountant find the invoice/quote/Storno reference - // exactly where DACH letter convention puts it. The date follows - // on its own row with the same right-anchored column structure so - // both label-and-value pairs align to the same right edge. - const docNumberForDisplay = ctx.doc.invoiceNumber || ctx.doc.quoteNumber || ''; - const numberLabelKey = type === 'quote' ? 'quote_number_label' : 'invoice_number_label'; - const metaRight = leftX + PAGE.contentWidth; - const metaLabelW = 110; // wider than the date label so "Rechnungsnummer" fits without wrap - const metaValueW = 110; - if (docNumberForDisplay) { + // ---- document number (above) + date (below), both right-aligned + // The number sits directly under the sender address block so the + // customer + accountant find the invoice/quote/Storno reference + // exactly where DACH letter convention puts it. The date follows + // on its own row with the same right-anchored column structure so + // both label-and-value pairs align to the same right edge. + const docNumberForDisplay = ctx.doc.invoiceNumber || ctx.doc.quoteNumber || ''; + const numberLabelKey = type === 'quote' ? 'quote_number_label' : 'invoice_number_label'; + const metaRight = leftX + PAGE.contentWidth; + const metaLabelW = 110; // wider than the date label so "Rechnungsnummer" fits without wrap + const metaValueW = 110; + if (docNumberForDisplay) { + doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#000'); + doc.text(`${t(ctx.locale, numberLabelKey)}:`, + metaRight - metaValueW - metaLabelW, y, + { width: metaLabelW, align: 'right', lineBreak: false }); + doc.text(docNumberForDisplay, metaRight - metaValueW, y, + { width: metaValueW, align: 'right', lineBreak: false }); + y += 14; + } + // Date row — same right-anchored layout so the two values stack + // visually as a single meta block. Replaces the previous + // drawDate() call, which lived below the title and used a + // tighter column spec. doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#000'); - doc.text(`${t(ctx.locale, numberLabelKey)}:`, + doc.text(`${t(ctx.locale, 'date')}:`, metaRight - metaValueW - metaLabelW, y, { width: metaLabelW, align: 'right', lineBreak: false }); - doc.text(docNumberForDisplay, metaRight - metaValueW, y, + doc.text(formatDate(ctx.doc.issueDate, ctx.dateFormat), + metaRight - metaValueW, y, { width: metaValueW, align: 'right', lineBreak: false }); - y += 14; - } - // Date row — same right-anchored layout so the two values stack - // visually as a single meta block. Replaces the previous - // drawDate() call, which lived below the title and used a - // tighter column spec. - doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#000'); - doc.text(`${t(ctx.locale, 'date')}:`, - metaRight - metaValueW - metaLabelW, y, - { width: metaLabelW, align: 'right', lineBreak: false }); - doc.text(formatDate(ctx.doc.issueDate, ctx.dateFormat), - metaRight - metaValueW, y, - { width: metaValueW, align: 'right', lineBreak: false }); - y += 18; // line height + cushion before the title + y += 18; // line height + cushion before the title - // ---- title ---------------------------------------------------- - const title = type === 'quote' - ? t(ctx.locale, 'quote_title') - : isStorno - ? t(ctx.locale, 'storno_title') - : isMahnung - ? t(ctx.locale, 'mahnung_title') - : t(ctx.locale, 'invoice_title'); - y = drawTitle(doc, title, leftX, y + 2); + // ---- title ---------------------------------------------------- + const title = type === 'quote' + ? t(ctx.locale, 'quote_title') + : isStorno + ? t(ctx.locale, 'storno_title') + : isMahnung + ? t(ctx.locale, 'mahnung_title') + : t(ctx.locale, 'invoice_title'); + y = drawTitle(doc, title, leftX, y + 2); - // Mandatory Storno reference line — "Bezug: Storno zu Rechnung - // R-XXXX vom DATE". This is the §14c-defensible link from the - // cancellation document to the invoice it reverses; readers - // and Finanzamt auditors need both numbers + the original - // issue date to reconstruct the chain from the documents - // alone. Stamped FIRST (before sourceQuote / replaces) so - // it's the prominent reference on a Storno. - if (isStorno && ctx.doc.cancelsInvoice) { - const { number, issueDate } = ctx.doc.cancelsInvoice; - doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#666'); - const datePart = issueDate ? ` ${t(ctx.locale, 'reference_dated', { date: formatDate(issueDate, ctx.dateFormat) })}` : ''; - doc.text( - `${t(ctx.locale, 'reference_label')}: ${t(ctx.locale, 'reference_cancels')} ${t(ctx.locale, 'invoice_title')} ${number}${datePart}`, - leftX, y, { width: PAGE.contentWidth } - ); - y = doc.y + 6; - doc.fillColor('#000'); - } - - // Invoice → source quote cross-reference. We deliberately keep - // invoice numbers on a strict monotonic sequence (R-YYYY-NNNN) - // for tax-compliance reasons (CH/LI/DE/AT require - // "lückenlose Rechnungsnummern") — instead of mirroring the - // quote number on the invoice, we surface the link as a small - // "Bezug: Angebot Q-…" line under the title. Readers see the - // provenance without breaking the numbering scheme. Only - // rendered for invoices that came from a quote; no-op for - // standalone invoices and Storni (which don't reference quotes). - if (type === 'invoice' && !isStorno && ctx.doc.sourceQuoteNumber) { - doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#666'); - doc.text( - `${t(ctx.locale, 'reference_label')}: ${t(ctx.locale, 'quote_title')} ${ctx.doc.sourceQuoteNumber}`, - leftX, y, { width: PAGE.contentWidth } - ); - y = doc.y + 6; - doc.fillColor('#000'); - } - // Cancel + reissue trail (migration 114) — when this invoice - // replaces an earlier (cancelled) one, surface "Bezug: Ersetzt - // Rechnung R-XXXX vom DATE" so the customer (and auditors) can - // trace the chain. Rendered in the same grey-666 small-print - // style as the quote-source reference above. Suppressed on - // Storni (which carry their own cancelsInvoice reference). - if (type === 'invoice' && !isStorno && ctx.doc.replacesInvoice) { - const { number, issueDate } = ctx.doc.replacesInvoice; - doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#666'); - const datePart = issueDate ? ` ${t(ctx.locale, 'reference_dated', { date: formatDate(issueDate, ctx.dateFormat) })}` : ''; - doc.text( - `${t(ctx.locale, 'reference_label')}: ${t(ctx.locale, 'reference_replaces')} ${t(ctx.locale, 'invoice_title')} ${number}${datePart}`, - leftX, y, { width: PAGE.contentWidth } - ); - y = doc.y + 6; - doc.fillColor('#000'); - } - - // ---- salutation + lead-in ------------------------------------ - // Personalised greeting when the customer record has an - // honorific + last name on file ("Sehr geehrter Herr Bresch,"), - // otherwise the generic locale-specific opening from the i18n - // dictionary ("Sehr geehrte Damen und Herren,"). - const greeting = personalSalutation(ctx.locale, ctx.recipient?.salutation, ctx.recipient?.lastName) - || t(ctx.locale, 'salutation'); - doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).fontSize(10).fillColor('#000'); - doc.text(greeting, leftX, y, { width: PAGE.contentWidth }); - y = doc.y + 4; - doc.font(doc._fonts ? doc._fonts.body : FONT_BODY); - const leadIn = type === 'quote' - ? t(ctx.locale, 'lead_in_quote') - : t(ctx.locale, 'lead_in_invoice'); - doc.text(leadIn, leftX, y, { width: PAGE.contentWidth }); - y = doc.y + 16; - - // ---- intro text override (admin-customisable) ----------------- - if (ctx.doc.introText) { - doc.text(ctx.doc.introText, leftX, y, { width: PAGE.contentWidth }); - y = doc.y + 12; - } - - // ---- line items table ---------------------------------------- - // Small top padding — tight against the lead-in text since the - // maintainer wants the items right under the greeting/intro. - y += 8; - doc.y = y; - doc.x = leftX; - - // Let the items table paginate with the document's NORMAL - // margins so each page fills to the bottom. The header row is - // marked `header: true` so it auto-repeats on every - // continuation page. Totals/payment placement is handled below: - // they're pinned to a fixed anchor near the page bottom, and if - // the last item row spilled past that anchor we advance to a - // fresh page before drawing them (see the desiredTotalsY check). - // - // We deliberately do NOT inflate the bottom margin here to - // "reserve" the totals zone on every page. That older approach - // shortened the usable area on EVERY page (not just the last), - // so a long invoice broke far too early — only a handful of - // line items rendered on page 1 with a large blank gap beneath. - // Worse, the inflated margin was set on the page active when the - // table started but restored on whichever page the table ended, - // leaving page 1 permanently short: the page-number stamp later - // landed below that page's phantom bottom margin and spawned a - // stray blank trailing page (which then desynced "Seite X von Y"). - drawLineItems(doc, ctx); - // y after the table — used only to detect whether the items - // overflowed past the totals anchor below. We don't use it as - // the totals position directly because the totals block is - // pinned to a fixed offset from the page bottom regardless of - // how many items rendered. - y = doc.y; - - // ---- pin totals + payment block to footer --------------------- - // The totals box + payment block ALWAYS render at the same - // distance from the page bottom regardless of how many line - // items rendered. Reserves below are conservative-but-tight: - // they reflect the actual measured block heights, with just - // enough breathing room that a wrapped line or extra Skonto - // row doesn't crash into the footer. - // FOOTER_RESERVE = 30 (one footer line ~12pt + ~18pt gap) - // PAYMENT_BLOCK_HEIGHT = 80 with paymentTerm, 50 without - // (header + 3-4 rows including the - // skonto + skonto_amount lines) - // TOTALS_BLOCK_HEIGHT = 90 (top divider + Net + Shipping + - // VAT + middle divider + Total) - const FOOTER_RESERVE = 30; - const PAYMENT_BLOCK_HEIGHT = ctx.paymentTerm ? 80 : 50; - let TOTALS_BLOCK_HEIGHT = 90; - // A free-text VAT note (#794) adds a wrapped row under the MwSt. line — - // grow the reserved totals height by its measured height so a long note - // can't push the grand total / payment block into the footer. - if (ctx.vatNote) { - doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8); - const noteWidth = PAGE.contentWidth - ((PAGE.contentWidth - 20) / 2 + 20); - TOTALS_BLOCK_HEIGHT += doc.heightOfString(ctx.vatNote, { width: noteWidth }) + 4; - doc.fontSize(10); - } - const desiredPaymentY = PAGE.height - PAGE.marginBottom - FOOTER_RESERVE - PAYMENT_BLOCK_HEIGHT; - const desiredTotalsY = desiredPaymentY - 12 - TOTALS_BLOCK_HEIGHT; - - // If line items used more space than the totals anchor allows, - // advance to a new page before drawing totals — keeps the - // bottom block at a CONSTANT position from the footer on - // whatever page it lands on. - if (y > desiredTotalsY) { - doc.addPage(); - } - // Always reset to the fixed anchor — independent of where the - // table ended on the page. - y = desiredTotalsY; - - // ---- totals box (right-aligned) ------------------------------- - y = drawTotals(doc, ctx, leftX, y, PAGE.contentWidth); - - // ---- outro text ----------------------------------------------- - if (ctx.doc.outroText) { - doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#000'); - doc.text(ctx.doc.outroText, leftX, y, { width: PAGE.contentWidth }); - y = doc.y + 12; - } - - // ---- payment conditions + IBAN block -------------------------- - // Pin the payment block to the fixed anchor too — the totals - // box can end short of it (e.g. when only Net + Total render - // with no shipping/VAT), so we snap back unconditionally. - // Suppressed on Stornorechnungen: a cancellation document is - // not a payment instrument — no Zahlungsbedingungen, no IBAN, - // no Skonto. Customers reading a Storno expect total clarity - // that this is the REVERSAL of an obligation, not a new one. - if (!isStorno) { - y = desiredPaymentY; - y = drawPaymentBlock(doc, ctx, leftX, y, PAGE.contentWidth); - } - - // ---- folding marks (left edge) -------------------------------- - drawFoldingMarks(doc, ctx.issuer?.foldingMarks); - - // ---- footer --------------------------------------------------- - drawFooter(doc, ctx.issuer, ctx.locale); - - // ---- payment QR on fresh page (invoices only) ----------------- - // Two paths, mutually exclusive: - // - 'swiss' → SwissQRBill payment slip (CHF / EUR within CH/LI) - // - 'epc' → SEPA EPC069-12 QR code (EUR-only, every SEPA bank) - // Both append a fresh page; 'none' is a no-op. - // Suppressed on Stornorechnungen — negative-amount QR codes - // aren't a defined construct in either spec. - if (type === 'invoice' && !isStorno && !isMahnung) { - if (ctx.qrFormat === 'swiss') { - appendSwissQrBill(doc, ctx); - } else if (ctx.qrFormat === 'epc') { - await appendEpcQr(doc, ctx); - } - } - - // ---- page numbers ("Page 1 of N" / "Seite 1 von N") ----------- - // Stamped after everything else so we know the final page - // count. bufferPages: true (on the PDFDocument options above) - // keeps every page open for back-editing — bufferedPageRange() - // returns {start, count}. We switchToPage() each one, draw the - // pagination label in the bottom-right corner, then end. - try { - const range = doc.bufferedPageRange(); - const total = range.count; - // Stamp on EVERY page including single-page documents. The - // "Page 1 of 1" label is a tamper-evidence cue for the - // recipient — if they receive page 1 of 3 in isolation, - // they know pages are missing; conversely "1 of 1" lets a - // single-page invoice confirm it's complete. The cost (one - // grey line in the bottom corner) is negligible. - for (let i = 0; i < total; i++) { - doc.switchToPage(range.start + i); - // Drop this page's bottom margin to 0 so writing the label INTO the - // margin band (below the content area the line-item table fills) can't - // trigger PDFKit's auto-page-break. Previously the label sat at - // marginBottom-12 — INSIDE the content area — so on a full multi-page - // invoice the table's last row overlapped the "Seite X von Y" stamp - // (#794). The page is already fully laid out (buffered), so zeroing the - // margin here is safe. - doc.page.margins.bottom = 0; - doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8).fillColor('#888'); - const label = t(ctx.locale, 'page_of', { - current: i + 1, - total, - }); - // Bottom-right corner, INSIDE the bottom margin (below the content - // edge the table fills), so a full continuation page's last row can't - // overlap it. - const labelY = doc.page.height - PAGE.marginBottom + 8; - const labelW = 120; - const labelX = doc.page.width - PAGE.marginRight - labelW; - doc.text(label, labelX, labelY, { - width: labelW, align: 'right', lineBreak: false, - }); + // Mandatory Storno reference line — "Bezug: Storno zu Rechnung + // R-XXXX vom DATE". This is the §14c-defensible link from the + // cancellation document to the invoice it reverses; readers + // and Finanzamt auditors need both numbers + the original + // issue date to reconstruct the chain from the documents + // alone. Stamped FIRST (before sourceQuote / replaces) so + // it's the prominent reference on a Storno. + if (isStorno && ctx.doc.cancelsInvoice) { + const { number, issueDate } = ctx.doc.cancelsInvoice; + doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#666'); + const datePart = issueDate ? ` ${t(ctx.locale, 'reference_dated', { date: formatDate(issueDate, ctx.dateFormat) })}` : ''; + doc.text( + `${t(ctx.locale, 'reference_label')}: ${t(ctx.locale, 'reference_cancels')} ${t(ctx.locale, 'invoice_title')} ${number}${datePart}`, + leftX, y, { width: PAGE.contentWidth } + ); + y = doc.y + 6; doc.fillColor('#000'); } - } catch (err) { - const logger = require('../utils/logger'); - logger.warn('Failed to stamp page numbers on PDF', { err: err.message }); - } - doc.end(); - } catch (err) { - reject(err); - } + // Invoice → source quote cross-reference. We deliberately keep + // invoice numbers on a strict monotonic sequence (R-YYYY-NNNN) + // for tax-compliance reasons (CH/LI/DE/AT require + // "lückenlose Rechnungsnummern") — instead of mirroring the + // quote number on the invoice, we surface the link as a small + // "Bezug: Angebot Q-…" line under the title. Readers see the + // provenance without breaking the numbering scheme. Only + // rendered for invoices that came from a quote; no-op for + // standalone invoices and Storni (which don't reference quotes). + if (type === 'invoice' && !isStorno && ctx.doc.sourceQuoteNumber) { + doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#666'); + doc.text( + `${t(ctx.locale, 'reference_label')}: ${t(ctx.locale, 'quote_title')} ${ctx.doc.sourceQuoteNumber}`, + leftX, y, { width: PAGE.contentWidth } + ); + y = doc.y + 6; + doc.fillColor('#000'); + } + // Cancel + reissue trail (migration 114) — when this invoice + // replaces an earlier (cancelled) one, surface "Bezug: Ersetzt + // Rechnung R-XXXX vom DATE" so the customer (and auditors) can + // trace the chain. Rendered in the same grey-666 small-print + // style as the quote-source reference above. Suppressed on + // Storni (which carry their own cancelsInvoice reference). + if (type === 'invoice' && !isStorno && ctx.doc.replacesInvoice) { + const { number, issueDate } = ctx.doc.replacesInvoice; + doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#666'); + const datePart = issueDate ? ` ${t(ctx.locale, 'reference_dated', { date: formatDate(issueDate, ctx.dateFormat) })}` : ''; + doc.text( + `${t(ctx.locale, 'reference_label')}: ${t(ctx.locale, 'reference_replaces')} ${t(ctx.locale, 'invoice_title')} ${number}${datePart}`, + leftX, y, { width: PAGE.contentWidth } + ); + y = doc.y + 6; + doc.fillColor('#000'); + } + + // ---- salutation + lead-in ------------------------------------ + // Personalised greeting when the customer record has an + // honorific + last name on file ("Sehr geehrter Herr Bresch,"), + // otherwise the generic locale-specific opening from the i18n + // dictionary ("Sehr geehrte Damen und Herren,"). + const greeting = personalSalutation(ctx.locale, ctx.recipient?.salutation, ctx.recipient?.lastName) + || t(ctx.locale, 'salutation'); + doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).fontSize(10).fillColor('#000'); + doc.text(greeting, leftX, y, { width: PAGE.contentWidth }); + y = doc.y + 4; + doc.font(doc._fonts ? doc._fonts.body : FONT_BODY); + const leadIn = type === 'quote' + ? t(ctx.locale, 'lead_in_quote') + : t(ctx.locale, 'lead_in_invoice'); + doc.text(leadIn, leftX, y, { width: PAGE.contentWidth }); + y = doc.y + 16; + + // ---- intro text override (admin-customisable) ----------------- + if (ctx.doc.introText) { + doc.text(ctx.doc.introText, leftX, y, { width: PAGE.contentWidth }); + y = doc.y + 12; + } + + // ---- line items table ---------------------------------------- + // Small top padding — tight against the lead-in text since the + // maintainer wants the items right under the greeting/intro. + y += 8; + doc.y = y; + doc.x = leftX; + + // Let the items table paginate with the document's NORMAL + // margins so each page fills to the bottom. The header row is + // marked `header: true` so it auto-repeats on every + // continuation page. Totals/payment placement is handled below: + // they're pinned to a fixed anchor near the page bottom, and if + // the last item row spilled past that anchor we advance to a + // fresh page before drawing them (see the desiredTotalsY check). + // + // We deliberately do NOT inflate the bottom margin here to + // "reserve" the totals zone on every page. That older approach + // shortened the usable area on EVERY page (not just the last), + // so a long invoice broke far too early — only a handful of + // line items rendered on page 1 with a large blank gap beneath. + // Worse, the inflated margin was set on the page active when the + // table started but restored on whichever page the table ended, + // leaving page 1 permanently short: the page-number stamp later + // landed below that page's phantom bottom margin and spawned a + // stray blank trailing page (which then desynced "Seite X von Y"). + drawLineItems(doc, ctx); + // y after the table — used only to detect whether the items + // overflowed past the totals anchor below. We don't use it as + // the totals position directly because the totals block is + // pinned to a fixed offset from the page bottom regardless of + // how many items rendered. + y = doc.y; + + // ---- pin totals + payment block to footer --------------------- + // The totals box + payment block ALWAYS render at the same + // distance from the page bottom regardless of how many line + // items rendered. Reserves below are conservative-but-tight: + // they reflect the actual measured block heights, with just + // enough breathing room that a wrapped line or extra Skonto + // row doesn't crash into the footer. + // FOOTER_RESERVE = 30 (one footer line ~12pt + ~18pt gap) + // PAYMENT_BLOCK_HEIGHT = 80 with paymentTerm, 50 without + // (header + 3-4 rows including the + // skonto + skonto_amount lines) + // TOTALS_BLOCK_HEIGHT = 90 (top divider + Net + Shipping + + // VAT + middle divider + Total) + const FOOTER_RESERVE = 30; + const PAYMENT_BLOCK_HEIGHT = ctx.paymentTerm ? 80 : 50; + let TOTALS_BLOCK_HEIGHT = 90; + // A free-text VAT note (#794) adds a wrapped row under the MwSt. line — + // grow the reserved totals height by its measured height so a long note + // can't push the grand total / payment block into the footer. + if (ctx.vatNote) { + doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8); + const noteWidth = PAGE.contentWidth - ((PAGE.contentWidth - 20) / 2 + 20); + TOTALS_BLOCK_HEIGHT += doc.heightOfString(ctx.vatNote, { width: noteWidth }) + 4; + doc.fontSize(10); + } + const desiredPaymentY = PAGE.height - PAGE.marginBottom - FOOTER_RESERVE - PAYMENT_BLOCK_HEIGHT; + const desiredTotalsY = desiredPaymentY - 12 - TOTALS_BLOCK_HEIGHT; + + // If line items used more space than the totals anchor allows, + // advance to a new page before drawing totals — keeps the + // bottom block at a CONSTANT position from the footer on + // whatever page it lands on. + if (y > desiredTotalsY) { + doc.addPage(); + } + // Always reset to the fixed anchor — independent of where the + // table ended on the page. + y = desiredTotalsY; + + // ---- totals box (right-aligned) ------------------------------- + y = drawTotals(doc, ctx, leftX, y, PAGE.contentWidth); + + // ---- outro text ----------------------------------------------- + if (ctx.doc.outroText) { + doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#000'); + doc.text(ctx.doc.outroText, leftX, y, { width: PAGE.contentWidth }); + y = doc.y + 12; + } + + // ---- payment conditions + IBAN block -------------------------- + // Pin the payment block to the fixed anchor too — the totals + // box can end short of it (e.g. when only Net + Total render + // with no shipping/VAT), so we snap back unconditionally. + // Suppressed on Stornorechnungen: a cancellation document is + // not a payment instrument — no Zahlungsbedingungen, no IBAN, + // no Skonto. Customers reading a Storno expect total clarity + // that this is the REVERSAL of an obligation, not a new one. + if (!isStorno) { + y = desiredPaymentY; + y = drawPaymentBlock(doc, ctx, leftX, y, PAGE.contentWidth); + } + + // ---- folding marks (left edge) -------------------------------- + drawFoldingMarks(doc, ctx.issuer?.foldingMarks); + + // ---- footer --------------------------------------------------- + drawFooter(doc, ctx.issuer, ctx.locale); + + // ---- payment QR on fresh page (invoices only) ----------------- + // Two paths, mutually exclusive: + // - 'swiss' → SwissQRBill payment slip (CHF / EUR within CH/LI) + // - 'epc' → SEPA EPC069-12 QR code (EUR-only, every SEPA bank) + // Both append a fresh page; 'none' is a no-op. + // Suppressed on Stornorechnungen — negative-amount QR codes + // aren't a defined construct in either spec. + if (type === 'invoice' && !isStorno && !isMahnung) { + if (ctx.qrFormat === 'swiss') { + appendSwissQrBill(doc, ctx); + } else if (ctx.qrFormat === 'epc') { + await appendEpcQr(doc, ctx); + } + } + + // ---- page numbers ("Page 1 of N" / "Seite 1 von N") ----------- + // Stamped after everything else so we know the final page + // count. bufferPages: true (on the PDFDocument options above) + // keeps every page open for back-editing — bufferedPageRange() + // returns {start, count}. We switchToPage() each one, draw the + // pagination label in the bottom-right corner, then end. + try { + const range = doc.bufferedPageRange(); + const total = range.count; + // Stamp on EVERY page including single-page documents. The + // "Page 1 of 1" label is a tamper-evidence cue for the + // recipient — if they receive page 1 of 3 in isolation, + // they know pages are missing; conversely "1 of 1" lets a + // single-page invoice confirm it's complete. The cost (one + // grey line in the bottom corner) is negligible. + for (let i = 0; i < total; i++) { + doc.switchToPage(range.start + i); + // Drop this page's bottom margin to 0 so writing the label INTO the + // margin band (below the content area the line-item table fills) can't + // trigger PDFKit's auto-page-break. Previously the label sat at + // marginBottom-12 — INSIDE the content area — so on a full multi-page + // invoice the table's last row overlapped the "Seite X von Y" stamp + // (#794). The page is already fully laid out (buffered), so zeroing the + // margin here is safe. + doc.page.margins.bottom = 0; + doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8).fillColor('#888'); + const label = t(ctx.locale, 'page_of', { + current: i + 1, + total, + }); + // Bottom-right corner, INSIDE the bottom margin (below the content + // edge the table fills), so a full continuation page's last row can't + // overlap it. + const labelY = doc.page.height - PAGE.marginBottom + 8; + const labelW = 120; + const labelX = doc.page.width - PAGE.marginRight - labelW; + doc.text(label, labelX, labelY, { + width: labelW, align: 'right', lineBreak: false, + }); + doc.fillColor('#000'); + } + } catch (err) { + const logger = require('../utils/logger'); + logger.warn('Failed to stamp page numbers on PDF', { err: err.message }); + } + + doc.end(); + } catch (err) { + reject(err); + } })(); }); } @@ -1946,12 +1937,12 @@ function renderContractToBuffer(context) { // ---- helper: ensure space before drawing, paginate if needed. const bottomLimit = PAGE.height - PAGE.marginBottom - 20; - function ensureSpace(needed) { + const ensureSpace = (needed) => { if (y + needed > bottomLimit) { doc.addPage(); y = PAGE.marginTop; } - } + }; // ---- helper: render body text with inline **bold** support. // Splits on `**text**` markers, switches the font weight per @@ -1960,7 +1951,7 @@ function renderContractToBuffer(context) { // chunks continue from PDFKit's cursor so wrapping works // across font switches. After rendering, we read doc.y as // the new cursor. - function renderBodyMarkdown(text, opts) { + const renderBodyMarkdown = (text, opts) => { const parts = String(text || '').split(/(\*\*[^*]+\*\*)/g).filter((p) => p.length > 0); if (parts.length === 0) return; const last = parts.length - 1; @@ -1976,7 +1967,7 @@ function renderContractToBuffer(context) { doc.text(chunk, { ...opts, continued: i < last }); } } - } + }; // ---- intro text --------------------------------------------- if (ctx.doc?.introText) { @@ -2174,7 +2165,7 @@ function renderContractToBuffer(context) { // Two empty signature boxes — customer on the left, admin on // the right. drawn at fixed coordinates so the stamp service // can find them later by constant rather than runtime layout. - function drawEmptySignaturePane(x, label, info) { + const drawEmptySignaturePane = (x, label, info) => { doc.font(doc._fonts.bold).fontSize(10).fillColor('#000'); doc.text(label, x, L.paneLabelY, { width: L.boxWidth }); doc.strokeColor('#cccccc').lineWidth(0.5) @@ -2194,7 +2185,7 @@ function renderContractToBuffer(context) { `${t(locale, 'signed_label_date')}: ${info?.signedAt ? formatDate(info.signedAt, locale) : ''}`, x, captionY + 12, { width: L.boxWidth }, ); - } + }; drawEmptySignaturePane(L.customerX, t(locale, 'signature_customer'), ctx.signatures?.customer); drawEmptySignaturePane(L.adminX, t(locale, 'signature_admin'), ctx.signatures?.admin); diff --git a/backend/src/services/pdfStampService.js b/backend/src/services/pdfStampService.js index 45c6702a..3ccfb309 100644 --- a/backend/src/services/pdfStampService.js +++ b/backend/src/services/pdfStampService.js @@ -23,7 +23,6 @@ */ const fs = require('fs'); -const path = require('path'); const crypto = require('crypto'); const PDFKit = require('pdfkit'); const { PDFDocument } = require('pdf-lib'); @@ -84,7 +83,7 @@ function pdfkitToPdfLib(pageHeight, x, y, w, h) { * or the input file. */ async function stampSignature({ pdfBuffer, signaturePngPath, role, caption }) { - const { L, FONT_BODY, FONT_BOLD, formatDate } = pdfConsts(); + const { L, formatDate } = pdfConsts(); if (!Buffer.isBuffer(pdfBuffer)) { throw new Error('stampSignature: pdfBuffer must be a Buffer'); } @@ -261,7 +260,7 @@ async function renderAuditCertificate({ contract, customer, admin, locale = 'de' const labelW = 200; const valueW = PAGE.contentWidth - labelW; - function row(labelKey, value) { + const row = (labelKey, value) => { if (!value) return; doc.font(doc._fonts.bold).fontSize(9).fillColor('#444'); doc.text(t(locale, labelKey), PAGE.marginLeft, y, { @@ -272,7 +271,7 @@ async function renderAuditCertificate({ contract, customer, admin, locale = 'de' width: valueW, align: 'left', }); y = Math.max(y + 12, doc.y + 4); - } + }; row('audit_contract_number', contract.contract_number); row('audit_issued_at', contract.sent_at diff --git a/backend/src/services/photoExportService.js b/backend/src/services/photoExportService.js index ede1e34e..622e8b49 100644 --- a/backend/src/services/photoExportService.js +++ b/backend/src/services/photoExportService.js @@ -12,7 +12,6 @@ const feedbackService = require('./feedbackService'); const { neutralizeSpreadsheetFormula } = require('../utils/spreadsheetSafe'); const { db } = require('../database/db'); const path = require('path'); -const fs = require('fs').promises; /** * The name the camera gave the file, or null when nothing was recorded (#1229). @@ -128,16 +127,16 @@ class PhotoExportService { } switch (format) { - case 'txt': - return this.exportAsTxt(photos, options); - case 'csv': - return this.exportAsCsv(photos, options); - case 'xmp': - return this.exportAsXmpZip(photos, options); - case 'json': - return this.exportAsJson(photos, eventId, options); - default: - throw new Error(`Unknown export format: ${format}`); + case 'txt': + return this.exportAsTxt(photos, options); + case 'csv': + return this.exportAsCsv(photos, options); + case 'xmp': + return this.exportAsXmpZip(photos, options); + case 'json': + return this.exportAsJson(photos, eventId, options); + default: + throw new Error(`Unknown export format: ${format}`); } } @@ -168,14 +167,14 @@ class PhotoExportService { let content; switch (separator) { - case 'comma': - content = filenames.join(','); - break; - case 'semicolon': - content = filenames.join(';'); - break; - default: - content = filenames.join('\n'); + case 'comma': + content = filenames.join(','); + break; + case 'semicolon': + content = filenames.join(';'); + break; + default: + content = filenames.join('\n'); } return { @@ -289,7 +288,7 @@ class PhotoExportService { /** * Export as JSON metadata */ - async exportAsJson(photos, eventId, options = {}) { + async exportAsJson(photos, eventId, _options = {}) { // Get event info const event = await db('events') .where('id', eventId) diff --git a/backend/src/services/picpeakImportService.js b/backend/src/services/picpeakImportService.js index 3dfd1843..75716ee4 100644 --- a/backend/src/services/picpeakImportService.js +++ b/backend/src/services/picpeakImportService.js @@ -261,7 +261,7 @@ const PRESERVED_AUTH_FIELDS = [ async function jsonColumnsFor(trx, table) { if (!isPostgres()) return new Set(); const res = await trx.raw( - "SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ? AND data_type IN ('json', 'jsonb')", + 'SELECT column_name FROM information_schema.columns WHERE table_schema = \'public\' AND table_name = ? AND data_type IN (\'json\', \'jsonb\')', [table] ); return new Set(res.rows.map((r) => r.column_name)); @@ -337,7 +337,7 @@ async function replaceAllTables(tables, dataDir, currentAdmin, roleSnapshot, { c await db.transaction(async (trx) => { if (isPostgres()) { try { - await trx.raw("SET session_replication_role = 'replica'"); + await trx.raw('SET session_replication_role = \'replica\''); } catch (_) { // session_replication_role requires a Postgres SUPERUSER. The bundled // postgres image's role is one; managed Postgres (RDS / Cloud SQL / …) @@ -420,7 +420,7 @@ async function replaceAllTables(tables, dataDir, currentAdmin, roleSnapshot, { c } // Reset the pg session flag BEFORE the connection returns to the pool. - if (isPostgres()) await trx.raw("SET session_replication_role = 'origin'"); + if (isPostgres()) await trx.raw('SET session_replication_role = \'origin\''); }); } diff --git a/backend/src/services/quoteService.js b/backend/src/services/quoteService.js index 11ccb552..f8d4db40 100644 --- a/backend/src/services/quoteService.js +++ b/backend/src/services/quoteService.js @@ -46,6 +46,10 @@ const { hasColumnCached } = require('../utils/schemaCache'); const fs = require('fs'); const path = require('path'); +// NOTE: this transition table is currently never consulted — quote status +// changes are not validated against it anywhere in the codebase. Kept as the +// documented intent; wiring it up is tracked separately. +// eslint-disable-next-line no-unused-vars -- unwired state machine, see note above const VALID_QUOTE_TRANSITIONS = { draft: new Set(['sent', 'declined']), sent: new Set(['draft', 'accepted', 'declined', 'expired']), @@ -620,7 +624,7 @@ async function createQuote(payload, adminId) { // Pass `trx` so the audit insert rides the transaction's connection — // the global db here deadlocks the single-connection SQLite pool. await logActivity('quote_created', { quoteId, quoteNumber, customerAccountId: payload.customerAccountId }, null, `admin:${adminId}`, trx); - } catch (_) {} + } catch (_) { /* non-fatal */ } logger.info('Quote created', { adminId, quoteId, quoteNumber }); return quoteId; @@ -760,7 +764,7 @@ async function updateQuote(id, payload, adminId) { try { await logActivity('quote_updated', { quoteId: id }, null, `admin:${adminId}`); - } catch (_) {} + } catch (_) { /* non-fatal */ } }); } @@ -1033,7 +1037,7 @@ async function sendQuote(id, adminId) { // Do NOT log the raw bearer token — it grants quote actions and the // activity log is readable later (GHSA-prch). The quoteId is the audit key. await logActivity('quote_sent', { quoteId: id }, null, `admin:${adminId}`); - } catch (_) {} + } catch (_) { /* non-fatal */ } // Fire the quote.sent workflow trigger (best-effort; emit is fail-closed when // the workflows flag is off). The accepted/declined emits already exist; this @@ -1259,7 +1263,7 @@ async function recordResponse({ token, action, ip, tosAccepted }) { try { // Raw bearer token must not reach the activity log (GHSA-prch). await logActivity(`quote_${newStatus}`, { quoteId: quote.id }, null, 'customer:public'); - } catch (_) {} + } catch (_) { /* non-fatal */ } // Defer the workflow emit until the 15-min toggle window locks — so accepting // (then converting) can't strip the customer's ability to decline. The @@ -1317,7 +1321,7 @@ async function adminAcceptQuote(id, adminId) { try { await logActivity('quote_accepted_by_admin', { quoteId: id }, null, `admin:${adminId}`); - } catch (_) {} + } catch (_) { /* non-fatal */ } // ---- customer confirmation email ------------------------------- // Renders the quote PDF + queues a "quote accepted — on your @@ -1428,7 +1432,7 @@ async function adminDeclineQuote(id, adminId, reason = null) { try { await logActivity('quote_declined_by_admin', { quoteId: id, reason: cleanReason }, null, `admin:${adminId}`); - } catch (_) {} + } catch (_) { /* non-fatal */ } // Admin decline locks the window immediately (response_locked_at = now), so // this emits straight away (and stamps emitted) rather than deferring. @@ -1569,7 +1573,7 @@ async function convertToInvoiceOnly(quoteId, adminId, options = {}) { try { await logActivity('quote_converted_invoices_only', { quoteId: quote.id, installments: result.installmentsCreated }, null, `admin:${adminId}`); - } catch (_) {} + } catch (_) { /* non-fatal */ } logger.info('Quote converted to invoices only (no event)', { adminId, quoteId: quote.id, installments: result.installmentsCreated }); return result; @@ -1750,7 +1754,7 @@ async function convertToEvent(quoteId, adminId, options = {}) { // (prepare_event runs this unattended from the booking flow). try { await logActivity('quote_converted', { quoteId: quote.id, eventId: result.eventId }, result.eventId, `admin:${adminId}`); - } catch (_) {} + } catch (_) { /* non-fatal */ } logger.info('Quote converted to event', { adminId, quoteId: quote.id, eventId: result.eventId }); return result; diff --git a/backend/src/services/rateLimitService.js b/backend/src/services/rateLimitService.js index 028fb8a6..4b6e6808 100644 --- a/backend/src/services/rateLimitService.js +++ b/backend/src/services/rateLimitService.js @@ -96,7 +96,7 @@ function clearSettingsCache() { */ function isAuthenticated(req) { try { - const slugMatch = req.path.match(/\/api\/(?:gallery|secure-images)\/([^\/]+)/); + const slugMatch = req.path.match(/\/api\/(?:gallery|secure-images)\/([^/]+)/); const slug = slugMatch ? slugMatch[1] : req.requestedSlug; const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug); const decoded = jwt.verify(token, process.env.JWT_SECRET); diff --git a/backend/src/services/restoreService.js b/backend/src/services/restoreService.js index 692730a6..b5edd9b8 100644 --- a/backend/src/services/restoreService.js +++ b/backend/src/services/restoreService.js @@ -771,7 +771,7 @@ class RestoreService { * Download backup from S3 */ async downloadFromS3(s3Url, manifest, options) { - const s3PathMatch = s3Url.match(/^s3:\/\/([^\/]+)\/(.+)$/); + const s3PathMatch = s3Url.match(/^s3:\/\/([^/]+)\/(.+)$/); if (!s3PathMatch) { throw new Error('Invalid S3 URL format'); } @@ -930,7 +930,7 @@ class RestoreService { /** * Perform database-only restore */ - async performDatabaseRestore(backupPath, manifest, options) { + async performDatabaseRestore(backupPath, manifest, _options) { this.updateProgress('Restoring database...'); const dbBackupFile = manifest.database.backup_file; @@ -1524,7 +1524,8 @@ END $$;` try { // Read backup manifest const manifestPath = path.join(preRestoreBackupPath, 'backup-manifest.json'); - const backupManifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')); + // Parsed for its side effect: throws if the manifest is missing/corrupt. + JSON.parse(await fs.readFile(manifestPath, 'utf8')); // Restore database if backed up const dbBackupPath = path.join(preRestoreBackupPath, 'database.sql.gz'); @@ -1622,7 +1623,7 @@ END $$;` * Download file from S3 */ async downloadFileFromS3(s3Url, localPath, s3Config) { - const s3PathMatch = s3Url.match(/^s3:\/\/([^\/]+)\/(.+)$/); + const s3PathMatch = s3Url.match(/^s3:\/\/([^/]+)\/(.+)$/); if (!s3PathMatch) { throw new Error('Invalid S3 URL format'); } diff --git a/backend/src/services/secureImageService.js b/backend/src/services/secureImageService.js index c7224ec9..7e006940 100644 --- a/backend/src/services/secureImageService.js +++ b/backend/src/services/secureImageService.js @@ -1,8 +1,6 @@ const crypto = require('crypto'); const sharp = require('sharp'); const { db } = require('../database/db'); -const watermarkService = require('./watermarkService'); -const path = require('path'); const fs = require('fs').promises; const logger = require('../utils/logger'); diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js index 55418809..5d881c65 100644 --- a/backend/src/services/settingsService.js +++ b/backend/src/services/settingsService.js @@ -203,18 +203,18 @@ const parseSettingValue = (value, type) => { } switch (type) { - case 'boolean': - return parseBooleanInput(value, false); - case 'number': - return parseNumberInput(value, 0); - case 'json': - try { - return JSON.parse(value); - } catch { - return null; - } - default: - return value; + case 'boolean': + return parseBooleanInput(value, false); + case 'number': + return parseNumberInput(value, 0); + case 'json': + try { + return JSON.parse(value); + } catch { + return null; + } + default: + return value; } }; @@ -230,14 +230,14 @@ const serializeSettingValue = (value, type) => { } switch (type) { - case 'boolean': - return String(value === true || value === 'true' || value === 1); - case 'number': - return String(value); - case 'json': - return JSON.stringify(value); - default: - return String(value); + case 'boolean': + return String(value === true || value === 'true' || value === 1); + case 'number': + return String(value); + case 'json': + return JSON.stringify(value); + default: + return String(value); } }; diff --git a/backend/src/services/storage/__tests__/s3Storage.test.js b/backend/src/services/storage/__tests__/s3Storage.test.js index 53ec11df..2936aae7 100644 --- a/backend/src/services/storage/__tests__/s3Storage.test.js +++ b/backend/src/services/storage/__tests__/s3Storage.test.js @@ -51,7 +51,7 @@ describe('S3StorageAdapter', () => { }); it('should configure for MinIO with path style', () => { - const minioStorage = new S3StorageAdapter({ + new S3StorageAdapter({ bucket: 'test-bucket', endpoint: 'http://localhost:9000', forcePathStyle: true, diff --git a/backend/src/services/workerManager.js b/backend/src/services/workerManager.js index 3e87e169..716da38d 100644 --- a/backend/src/services/workerManager.js +++ b/backend/src/services/workerManager.js @@ -64,7 +64,7 @@ process.on('uncaughtException', (error) => { process.exit(1); }); -process.on('unhandledRejection', (reason, promise) => { +process.on('unhandledRejection', (reason) => { logger.error('Unhandled rejection in worker manager:', reason); }); diff --git a/backend/src/services/workflows/engine.js b/backend/src/services/workflows/engine.js index 8bfccb87..3d51b781 100644 --- a/backend/src/services/workflows/engine.js +++ b/backend/src/services/workflows/engine.js @@ -72,11 +72,11 @@ function matchFilter(filter, payload) { // Strict equality: a filter {value: 0} must NOT match false/''/null (loose == // conflated them). Authors must therefore match the payload's actual type. switch (op) { - case 'neq': return actual !== value; - case 'truthy': return Boolean(actual); - case 'falsy': return !actual; - case 'eq': - default: return actual === value; + case 'neq': return actual !== value; + case 'truthy': return Boolean(actual); + case 'falsy': return !actual; + case 'eq': + default: return actual === value; } } @@ -125,84 +125,84 @@ async function advanceRun(runId) { try { switch (node.type) { - case 'trigger': { + case 'trigger': { + const e = outEdge(edges, currentKey, null); + nextKey = e ? e.to_node : null; + await recordStep(runId, node, 'done', null); + break; + } + case 'condition': + case 'branch': { + const cond = registry.getCondition(node.config?.condition || 'expr'); + const result = cond ? await cond(ctx) : false; + const handle = result ? (node.config?.trueHandle || 'yes') : (node.config?.falseHandle || 'no'); + const e = outEdge(edges, currentKey, handle) || outEdge(edges, currentKey, result ? 'true' : 'false'); + nextKey = e ? e.to_node : null; + await recordStep(runId, node, 'done', { result, handle }); + break; + } + case 'loop': { + const counterKey = `__loop_${node.node_key}`; + const count = (Number(context.vars[counterKey]) || 0) + 1; + context.vars[counterKey] = count; + const max = Number(node.config?.maxIterations ?? node.config?.max ?? 3); + const handle = count > max ? (node.config?.exitHandle || 'exit') : (node.config?.loopHandle || 'loop'); + const e = outEdge(edges, currentKey, handle); + nextKey = e ? e.to_node : null; + await recordStep(runId, node, 'done', { count, max, handle }); + break; + } + case 'wait': { + // Dry-run (test-fire): don't park — pass straight through so the whole + // flow runs in one shot, recording what it WOULD have waited for. + if (context.vars.__dryRun) { const e = outEdge(edges, currentKey, null); nextKey = e ? e.to_node : null; - await recordStep(runId, node, 'done', null); + await recordStep(runId, node, 'skipped', { dryRun: true, wouldWaitUntil: computeWakeAt(node.config, context.vars) }); break; } - case 'condition': - case 'branch': { - const cond = registry.getCondition(node.config?.condition || 'expr'); - const result = cond ? await cond(ctx) : false; - const handle = result ? (node.config?.trueHandle || 'yes') : (node.config?.falseHandle || 'no'); - const e = outEdge(edges, currentKey, handle) || outEdge(edges, currentKey, result ? 'true' : 'false'); + const wakeAt = computeWakeAt(node.config, context.vars); + await db('workflow_runs').where({ id: runId }) + .update({ status: 'waiting', wake_at: wakeAt, current_node: currentKey, context: JSON.stringify(context) }); + await recordStep(runId, node, 'waiting', { wake_at: wakeAt }); + return; // paused — scheduler resumes when wake_at passes + } + case 'gate': { + // Dry-run (test-fire): auto-take the 'confirm' path so the escalation + // is exercised end-to-end, without creating an approval / emailing. + if (context.vars.__dryRun) { + const e = outEdge(edges, currentKey, 'confirm') || outEdge(edges, currentKey, null); nextKey = e ? e.to_node : null; - await recordStep(runId, node, 'done', { result, handle }); + await recordStep(runId, node, 'skipped', { dryRun: true, gateAutoConfirm: true }); break; } - case 'loop': { - const counterKey = `__loop_${node.node_key}`; - const count = (Number(context.vars[counterKey]) || 0) + 1; - context.vars[counterKey] = count; - const max = Number(node.config?.maxIterations ?? node.config?.max ?? 3); - const handle = count > max ? (node.config?.exitHandle || 'exit') : (node.config?.loopHandle || 'loop'); - const e = outEdge(edges, currentKey, handle); - nextKey = e ? e.to_node : null; - await recordStep(runId, node, 'done', { count, max, handle }); - break; - } - case 'wait': { - // Dry-run (test-fire): don't park — pass straight through so the whole - // flow runs in one shot, recording what it WOULD have waited for. - if (context.vars.__dryRun) { - const e = outEdge(edges, currentKey, null); - nextKey = e ? e.to_node : null; - await recordStep(runId, node, 'skipped', { dryRun: true, wouldWaitUntil: computeWakeAt(node.config, context.vars) }); - break; - } - const wakeAt = computeWakeAt(node.config, context.vars); - await db('workflow_runs').where({ id: runId }) - .update({ status: 'waiting', wake_at: wakeAt, current_node: currentKey, context: JSON.stringify(context) }); - await recordStep(runId, node, 'waiting', { wake_at: wakeAt }); - return; // paused — scheduler resumes when wake_at passes - } - case 'gate': { - // Dry-run (test-fire): auto-take the 'confirm' path so the escalation - // is exercised end-to-end, without creating an approval / emailing. - if (context.vars.__dryRun) { - const e = outEdge(edges, currentKey, 'confirm') || outEdge(edges, currentKey, null); - nextKey = e ? e.to_node : null; - await recordStep(runId, node, 'skipped', { dryRun: true, gateAutoConfirm: true }); - break; - } - await db('workflow_runs').where({ id: runId }) - .update({ status: 'waiting', wake_at: gateTimeout(node.config), current_node: currentKey, context: JSON.stringify(context) }); - await recordStep(runId, node, 'waiting', { gate: true }); - // Optional setup hook (create approval + send admin email) — registered - // by the approval phase. Engine still pauses cleanly without it. - const setup = registry.getAction('gate_setup'); - if (setup) { - try { await setup(ctx); } catch (e) { logger.error('[workflow] gate setup failed', { runId, error: e.message }); } - } - return; // paused — an approval (email or inbox) resumes via resumeRun - } - case 'action': - case 'webhook': { - const actionKey = node.config?.action || (node.type === 'webhook' ? 'webhook' : 'noop'); - const action = registry.getAction(actionKey); - const result = action ? (await action(ctx)) || {} : { skipped: true, reason: `unknown action ${actionKey}` }; - if (result.set && typeof result.set === 'object') Object.assign(context.vars, result.set); - const e = outEdge(edges, currentKey, null); - nextKey = e ? e.to_node : null; - await recordStep(runId, node, result.skipped ? 'skipped' : 'done', result); - break; - } - default: { - await recordStep(runId, node, 'skipped', { reason: `unknown node type ${node.type}` }); - const e = outEdge(edges, currentKey, null); - nextKey = e ? e.to_node : null; + await db('workflow_runs').where({ id: runId }) + .update({ status: 'waiting', wake_at: gateTimeout(node.config), current_node: currentKey, context: JSON.stringify(context) }); + await recordStep(runId, node, 'waiting', { gate: true }); + // Optional setup hook (create approval + send admin email) — registered + // by the approval phase. Engine still pauses cleanly without it. + const setup = registry.getAction('gate_setup'); + if (setup) { + try { await setup(ctx); } catch (e) { logger.error('[workflow] gate setup failed', { runId, error: e.message }); } } + return; // paused — an approval (email or inbox) resumes via resumeRun + } + case 'action': + case 'webhook': { + const actionKey = node.config?.action || (node.type === 'webhook' ? 'webhook' : 'noop'); + const action = registry.getAction(actionKey); + const result = action ? (await action(ctx)) || {} : { skipped: true, reason: `unknown action ${actionKey}` }; + if (result.set && typeof result.set === 'object') Object.assign(context.vars, result.set); + const e = outEdge(edges, currentKey, null); + nextKey = e ? e.to_node : null; + await recordStep(runId, node, result.skipped ? 'skipped' : 'done', result); + break; + } + default: { + await recordStep(runId, node, 'skipped', { reason: `unknown node type ${node.type}` }); + const e = outEdge(edges, currentKey, null); + nextKey = e ? e.to_node : null; + } } } catch (err) { await recordStep(runId, node, 'failed', null, err.message); diff --git a/backend/src/services/workflows/registry.js b/backend/src/services/workflows/registry.js index ae21ca87..a586fe10 100644 --- a/backend/src/services/workflows/registry.js +++ b/backend/src/services/workflows/registry.js @@ -28,15 +28,15 @@ registerCondition('expr', async (ctx) => { const { field, op = 'truthy', value } = ctx.node.config || {}; const actual = field != null ? ctx.vars[field] : undefined; switch (op) { - case 'eq': return actual == value; // eslint-disable-line eqeqeq - case 'neq': return actual != value; // eslint-disable-line eqeqeq - case 'gt': return Number(actual) > Number(value); - case 'gte': return Number(actual) >= Number(value); - case 'lt': return Number(actual) < Number(value); - case 'lte': return Number(actual) <= Number(value); - case 'falsy': return !actual; - case 'truthy': - default: return Boolean(actual); + case 'eq': return actual == value; // eslint-disable-line eqeqeq + case 'neq': return actual != value; // eslint-disable-line eqeqeq + case 'gt': return Number(actual) > Number(value); + case 'gte': return Number(actual) >= Number(value); + case 'lt': return Number(actual) < Number(value); + case 'lte': return Number(actual) <= Number(value); + case 'falsy': return !actual; + case 'truthy': + default: return Boolean(actual); } }); diff --git a/backend/src/utils/__tests__/filenameSanitizer.test.js b/backend/src/utils/__tests__/filenameSanitizer.test.js index 586e0533..83572d2a 100644 --- a/backend/src/utils/__tests__/filenameSanitizer.test.js +++ b/backend/src/utils/__tests__/filenameSanitizer.test.js @@ -28,9 +28,9 @@ describe('sanitizeFilename — accented characters transliterate via NFD (#607)' const legacyBroken = (s) => String(s).trim() .replace(/\s+/g, '_') - .replace(/[^a-zA-Z0-9_\-\.]/g, '') - .replace(/[_\-]{2,}/g, '_') - .replace(/^[_\-]+|[_\-]+$/g, ''); + .replace(/[^a-zA-Z0-9_\-.]/g, '') + .replace(/[_-]{2,}/g, '_') + .replace(/^[_-]+|[_-]+$/g, ''); it.each([ ['Ägypten', 'Agypten'], @@ -138,8 +138,8 @@ describe('sanitizeForContentDisposition — header-safe ASCII fallback', () => { describe('buildContentDisposition — RFC 6266 / RFC 5987 dual form', () => { it('emits both filename="..." (ASCII) and filename*=UTF-8\'\'... (unicode) for accented names', () => { const header = buildContentDisposition('Ägypten.jpg'); - expect(header).toContain("filename=\"gypten.jpg\""); - expect(header).toContain("filename*=UTF-8''%C3%84gypten.jpg"); + expect(header).toContain('filename="gypten.jpg"'); + expect(header).toContain('filename*=UTF-8\'\'%C3%84gypten.jpg'); expect(header.startsWith('attachment;')).toBe(true); }); diff --git a/backend/src/utils/authSecurity.js b/backend/src/utils/authSecurity.js index 41204172..04f3e0c1 100644 --- a/backend/src/utils/authSecurity.js +++ b/backend/src/utils/authSecurity.js @@ -79,37 +79,37 @@ async function loadSecurityConfigFromSettings() { const value = parseStoredValue(row.setting_value); switch (row.setting_key) { - case 'security_max_login_attempts': { - config.maxAttempts = normalizePositiveInteger( - 'security_max_login_attempts', - value, - DEFAULT_SECURITY_CONFIG.maxAttempts, - { min: 1, max: 50 } - ); - break; - } - case 'security_lockout_duration_minutes': { - const minutes = normalizePositiveInteger( - 'security_lockout_duration_minutes', - value, - DEFAULT_SECURITY_CONFIG.lockoutDurationMs / (60 * 1000), - { min: 1, max: 24 * 60 } - ); - config.lockoutDurationMs = minutes * 60 * 1000; - break; - } - case 'security_attempt_window_minutes': { - const minutes = normalizePositiveInteger( - 'security_attempt_window_minutes', - value, - DEFAULT_SECURITY_CONFIG.attemptWindowMs / (60 * 1000), - { min: 1, max: 24 * 60 } - ); - config.attemptWindowMs = minutes * 60 * 1000; - break; - } - default: - break; + case 'security_max_login_attempts': { + config.maxAttempts = normalizePositiveInteger( + 'security_max_login_attempts', + value, + DEFAULT_SECURITY_CONFIG.maxAttempts, + { min: 1, max: 50 } + ); + break; + } + case 'security_lockout_duration_minutes': { + const minutes = normalizePositiveInteger( + 'security_lockout_duration_minutes', + value, + DEFAULT_SECURITY_CONFIG.lockoutDurationMs / (60 * 1000), + { min: 1, max: 24 * 60 } + ); + config.lockoutDurationMs = minutes * 60 * 1000; + break; + } + case 'security_attempt_window_minutes': { + const minutes = normalizePositiveInteger( + 'security_attempt_window_minutes', + value, + DEFAULT_SECURITY_CONFIG.attemptWindowMs / (60 * 1000), + { min: 1, max: 24 * 60 } + ); + config.attemptWindowMs = minutes * 60 * 1000; + break; + } + default: + break; } }); diff --git a/backend/src/utils/cssSanitizer.js b/backend/src/utils/cssSanitizer.js index 884b78dd..c61f98a7 100644 --- a/backend/src/utils/cssSanitizer.js +++ b/backend/src/utils/cssSanitizer.js @@ -58,6 +58,7 @@ function sanitizeCss(css) { sanitized = sanitized.replace(pattern, ''); }); + // eslint-disable-next-line no-control-regex -- intentional: strips control chars from untrusted CSS sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, ''); const MAX_LENGTH = 100 * 1024; @@ -112,6 +113,7 @@ function sanitizeCSS(cssContent) { sanitized = sanitized.replace(//g, ''); // Remove control characters + // eslint-disable-next-line no-control-regex -- intentional: strips control chars from untrusted CSS sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, ''); // Remove any remaining script-like content diff --git a/backend/src/utils/feedbackValidation.js b/backend/src/utils/feedbackValidation.js index 2dc77b69..04800852 100644 --- a/backend/src/utils/feedbackValidation.js +++ b/backend/src/utils/feedbackValidation.js @@ -89,6 +89,7 @@ function sanitizeComment(text) { text = text.replace(/[\u200B-\u200D\uFEFF]/g, ''); // Remove control characters + // eslint-disable-next-line no-control-regex -- intentional: strips control chars from feedback text text = text.replace(/[\x00-\x1F\x7F]/g, ''); // Limit consecutive special characters diff --git a/backend/src/utils/fileSecurityUtils.js b/backend/src/utils/fileSecurityUtils.js index 05dbb194..a36f1e47 100644 --- a/backend/src/utils/fileSecurityUtils.js +++ b/backend/src/utils/fileSecurityUtils.js @@ -37,8 +37,9 @@ function safePathJoin(basePath, userPath) { function isPathSafe(filePath) { // Check for common path traversal patterns const dangerousPatterns = [ - /\.\.[\/\\]/, // ../ or ..\ + /\.\.[/\\]/, // ../ or ..\ /^[A-Za-z]:/, // Windows drive letters + // eslint-disable-next-line no-control-regex -- intentional: detects control chars in paths /[\x00-\x1f]/ // Control characters ]; diff --git a/backend/src/utils/filenameSanitizer.js b/backend/src/utils/filenameSanitizer.js index 502dacff..2d5764c0 100644 --- a/backend/src/utils/filenameSanitizer.js +++ b/backend/src/utils/filenameSanitizer.js @@ -27,13 +27,13 @@ function sanitizeFilename(str, maxLength = 50) { sanitized = sanitized.replace(/\s+/g, '_'); // Remove special characters except hyphens, underscores, and dots - sanitized = sanitized.replace(/[^a-zA-Z0-9_\-\.]/g, ''); + sanitized = sanitized.replace(/[^a-zA-Z0-9_\-.]/g, ''); // Remove multiple consecutive underscores or hyphens - sanitized = sanitized.replace(/[_\-]{2,}/g, '_'); + sanitized = sanitized.replace(/[_-]{2,}/g, '_'); // Remove leading/trailing underscores or hyphens - sanitized = sanitized.replace(/^[_\-]+|[_\-]+$/g, ''); + sanitized = sanitized.replace(/^[_-]+|[_-]+$/g, ''); // Limit length if (sanitized.length > maxLength) { diff --git a/backend/src/utils/passwordGenerator.js b/backend/src/utils/passwordGenerator.js index 0200766c..bb302a88 100644 --- a/backend/src/utils/passwordGenerator.js +++ b/backend/src/utils/passwordGenerator.js @@ -93,7 +93,7 @@ function validatePasswordStrength(password) { result.score += 1; } - if (!/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(password)) { + if (!/[!@#$%^&*()_+\-=[\]{}|;:,.<>?]/.test(password)) { result.messages.push('Password must contain special characters'); } else { result.score += 1; diff --git a/backend/src/utils/passwordValidation.js b/backend/src/utils/passwordValidation.js index aff27287..09dba192 100644 --- a/backend/src/utils/passwordValidation.js +++ b/backend/src/utils/passwordValidation.js @@ -66,7 +66,7 @@ function validatePassword(password, options = {}) { } // Check special character requirement - if (config.requireSpecialChars && !/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) { + if (config.requireSpecialChars && !/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) { errors.push('Password must contain at least one special character'); } @@ -230,7 +230,7 @@ async function validatePasswordInContext(password, context, userData = {}) { // Only allow date-format passwords when complexity is 'simple' if (complexityLevel === 'simple') { - const datePattern = /^\d{1,2}[.\/-]\d{1,2}[.\/-]\d{4}$/; + const datePattern = /^\d{1,2}[./-]\d{1,2}[./-]\d{4}$/; if (datePattern.test(password)) { return { valid: true, diff --git a/backend/src/utils/whatsNew.js b/backend/src/utils/whatsNew.js index 796ba386..8ce41094 100644 --- a/backend/src/utils/whatsNew.js +++ b/backend/src/utils/whatsNew.js @@ -25,7 +25,7 @@ function decodeEntities(s) { .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') - .replace(/�*39;|�*27;|'/gi, "'") + .replace(/�*39;|�*27;|'/gi, '\'') .replace(/&/g, '&'); }