style(backend): clear the eslint backlog to zero
929 problems (928 errors, 1 warning) -> 0, exit 0.
Rule breakdown, which corrects the report's premise -- `indent` dominated, not
`quotes`: indent 719, quotes 68, no-unused-vars 54, no-empty 36,
no-useless-escape 22, no-case-declarations 17, no-inner-declarations 6,
no-control-regex 5, no-useless-catch 1, no-console 1 (warn).
--fix handled only indent + quotes (719+68 = exactly the "fixable" count).
no-useless-escape was NOT auto-fixable in this eslint version, so the one
genuinely risky class never went through the autofixer -- all 22 were done by
hand. Two mechanical proofs on the autofix diff: a token-level AST diff
(espree, before vs after) shows exactly 68 differing tokens, all quotes, with
the 719 indent fixes producing zero token changes; and a cooked-value diff of
every string/template/regex literal shows 0 differences.
Regex escapes: eslint was correctly conservative and did not flag the
load-bearing ones -- \- in [^a-zA-Z0-9_\-\.] (unescaping makes an invalid
reversed _ -> . range) or in [!@#$%^&*()_+\-=...] (would become a + -> = range
silently matching ",-."). Every removal was a \/ \[ or \. inside a character
class; all 11 old/new pairs were brute-forced over 794 inputs with 0
mismatches.
Manual fixes: no-empty were all deliberate best-effort catches around activity
logging, annotated rather than restructured; no-case-declarations braced in
two adminBackup switches; no-inner-declarations converted to const arrows
after checking no call precedes the declaration and no this/arguments use;
no-control-regex and no-console got targeted disables with stated reasons;
one `catch (e) { throw e; }` wrapper removed.
Two unused bindings were near-misses worth noting: secureStatic.js's
`fullPath` is a path-traversal guard (safePathJoin throws on escape) and
restoreService.js's `backupManifest` is the throw-on-corrupt-manifest gate
before a rollback -- deleting either would have silently removed a check. Only
the bindings were dropped; the calls stay.
Two real bugs found and deliberately preserved with a comment plus a narrow
disable rather than deleted, since deleting would erase the evidence:
_workflowSeedBoot.js's `booted` is written but never read, so the intended
once-per-process guard is missing its early return and workflows re-seed on
every call; and quoteService.js's VALID_QUOTE_TRANSITIONS is a full state
machine nothing consults, so quote status changes are unvalidated.
Backend test suite: 253 suites / 2552 tests passing, 0 failures, before and
after.
Refs testplan REPORT.md #22 (Part 1.2.02).
This commit is contained in:
@@ -65,7 +65,7 @@ function makeRes() {
|
|||||||
res.json = jest.fn().mockReturnValue(res);
|
res.json = jest.fn().mockReturnValue(res);
|
||||||
return 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 } };
|
return { headers: { authorization: undefined, ...headers }, cookies, originalUrl, ip, connection: { remoteAddress: ip } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const { sanitizeCss } = require('../utils/cssSanitizer');
|
|||||||
const buildPublicSiteRows = (overrides = {}) => ([
|
const buildPublicSiteRows = (overrides = {}) => ([
|
||||||
{ setting_key: 'general_public_site_enabled', setting_value: JSON.stringify(overrides.enabled ?? true) },
|
{ 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 ?? '<h1>{{company_name}}</h1>') },
|
{ setting_key: 'general_public_site_html', setting_value: JSON.stringify(overrides.html ?? '<h1>{{company_name}}</h1>') },
|
||||||
{ 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 = {}) => ([
|
const buildBrandingRows = (overrides = {}) => ([
|
||||||
@@ -64,7 +64,7 @@ describe('publicSiteService', () => {
|
|||||||
|
|
||||||
it('sanitizes custom CSS and removes dangerous patterns', async () => {
|
it('sanitizes custom CSS and removes dangerous patterns', async () => {
|
||||||
const publicSiteRows = buildPublicSiteRows({
|
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();
|
const brandingRows = buildBrandingRows();
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ try {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Non-fatal: log and continue; SQLite will fail later if still missing
|
// 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.
|
// Create database connection with built-in retry logic.
|
||||||
@@ -205,20 +205,20 @@ 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 existingColumns = pragmaRows.map(row => row.name);
|
||||||
const selectColumns = existingColumns.map((col) => {
|
const selectColumns = existingColumns.map((col) => {
|
||||||
switch (col) {
|
switch (col) {
|
||||||
case 'allow_user_uploads':
|
case 'allow_user_uploads':
|
||||||
return "COALESCE(allow_user_uploads, 0) as allow_user_uploads";
|
return 'COALESCE(allow_user_uploads, 0) as allow_user_uploads';
|
||||||
case 'upload_category_id':
|
case 'upload_category_id':
|
||||||
return "upload_category_id";
|
return 'upload_category_id';
|
||||||
case 'allow_downloads':
|
case 'allow_downloads':
|
||||||
return "COALESCE(allow_downloads, 1) as allow_downloads";
|
return 'COALESCE(allow_downloads, 1) as allow_downloads';
|
||||||
case 'disable_right_click':
|
case 'disable_right_click':
|
||||||
return "COALESCE(disable_right_click, 0) as disable_right_click";
|
return 'COALESCE(disable_right_click, 0) as disable_right_click';
|
||||||
case 'watermark_downloads':
|
case 'watermark_downloads':
|
||||||
return "COALESCE(watermark_downloads, 0) as watermark_downloads";
|
return 'COALESCE(watermark_downloads, 0) as watermark_downloads';
|
||||||
case 'watermark_text':
|
case 'watermark_text':
|
||||||
return 'watermark_text';
|
return 'watermark_text';
|
||||||
case 'hero_photo_id':
|
case 'hero_photo_id':
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ function feedbackRateLimit(actionType) {
|
|||||||
|
|
||||||
return res.status(429).json({
|
return res.status(429).json({
|
||||||
error: 'Too many requests',
|
error: 'Too many requests',
|
||||||
message: `Rate limit exceeded. Please try again later.`,
|
message: 'Rate limit exceeded. Please try again later.',
|
||||||
retryAfter: rateLimitStatus.window
|
retryAfter: rateLimitStatus.window
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ function requireEventOwnership(req, res, next) {
|
|||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((_err) => {
|
||||||
res.status(500).json({ error: 'Failed to verify ownership' });
|
res.status(500).json({ error: 'Failed to verify ownership' });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const secureImageService = require('../services/secureImageService');
|
const secureImageService = require('../services/secureImageService');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enhanced secure image middleware with comprehensive protection
|
* Enhanced secure image middleware with comprehensive protection
|
||||||
@@ -69,7 +68,7 @@ class SecureImageMiddleware {
|
|||||||
/**
|
/**
|
||||||
* Perform comprehensive security checks
|
* Perform comprehensive security checks
|
||||||
*/
|
*/
|
||||||
async performSecurityChecks(req, res) {
|
async performSecurityChecks(req, _res) {
|
||||||
const { clientInfo } = req;
|
const { clientInfo } = req;
|
||||||
const { photoId } = req.params;
|
const { photoId } = req.params;
|
||||||
|
|
||||||
@@ -132,7 +131,6 @@ class SecureImageMiddleware {
|
|||||||
*/
|
*/
|
||||||
async checkRateLimit(req) {
|
async checkRateLimit(req) {
|
||||||
const { clientInfo } = req;
|
const { clientInfo } = req;
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
// Get rate limit settings from database
|
// Get rate limit settings from database
|
||||||
const settings = await this.getRateLimitSettings();
|
const settings = await this.getRateLimitSettings();
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ function secureStatic(basePath, options = {}) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Validate the full path is within the base directory
|
// 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
|
// If validation passes, use express.static
|
||||||
const staticMiddleware = express.static(normalizedBase, {
|
const staticMiddleware = express.static(normalizedBase, {
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ async function sessionTimeoutMiddleware(req, res, next) {
|
|||||||
// Clean up old token if user has a new one
|
// Clean up old token if user has a new one
|
||||||
// This prevents memory leaks from token renewals
|
// This prevents memory leaks from token renewals
|
||||||
const userId = decoded.id;
|
const userId = decoded.id;
|
||||||
for (const [oldToken, _] of sessions.entries()) {
|
for (const oldToken of sessions.keys()) {
|
||||||
if (oldToken !== token) {
|
if (oldToken !== token) {
|
||||||
try {
|
try {
|
||||||
const oldDecoded = jwt.verify(oldToken, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
const oldDecoded = jwt.verify(oldToken, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||||
@@ -198,7 +198,7 @@ function getActiveSessions() {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
let active = 0;
|
let active = 0;
|
||||||
|
|
||||||
for (const [_, lastActivity] of sessions.entries()) {
|
for (const lastActivity of sessions.values()) {
|
||||||
if (now - lastActivity <= DEFAULT_SESSION_TIMEOUT) {
|
if (now - lastActivity <= DEFAULT_SESSION_TIMEOUT) {
|
||||||
active++;
|
active++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ const { formatBoolean } = require('../utils/dbCompat');
|
|||||||
const { slugify } = require('../utils/slug');
|
const { slugify } = require('../utils/slug');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const { requirePermission } = require('../middleware/permissions');
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const archiver = require('archiver');
|
|
||||||
const StreamZip = require('node-stream-zip');
|
const StreamZip = require('node-stream-zip');
|
||||||
const { requireEventOwnership } = require('../middleware/ownership');
|
const { requireEventOwnership } = require('../middleware/ownership');
|
||||||
const { assertZipEntriesWithin } = require('../utils/safePath');
|
const { assertZipEntriesWithin } = require('../utils/safePath');
|
||||||
|
|||||||
@@ -346,7 +346,7 @@ router.post('/test-connection', adminAuth, requirePermission('backup.create'), a
|
|||||||
const { destination_type, ...config } = req.body;
|
const { destination_type, ...config } = req.body;
|
||||||
|
|
||||||
switch (destination_type) {
|
switch (destination_type) {
|
||||||
case 'local':
|
case 'local': {
|
||||||
// Test local path access
|
// Test local path access
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
try {
|
try {
|
||||||
@@ -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.' });
|
res.json({ success: false, message: 'Cannot write to local path. Check server logs for details.' });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'rsync':
|
case 'rsync': {
|
||||||
// Test rsync connection using spawn with argument arrays to prevent command injection
|
// Test rsync connection using spawn with argument arrays to prevent command injection
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
@@ -425,7 +426,7 @@ router.post('/test-connection', adminAuth, requirePermission('backup.create'), a
|
|||||||
sshArgs.push('echo', 'Connection successful');
|
sshArgs.push('echo', 'Connection successful');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const sshProcess = spawn('ssh', sshArgs, {
|
const sshProcess = spawn('ssh', sshArgs, {
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
stdio: ['ignore', 'pipe', 'pipe']
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
@@ -459,6 +460,7 @@ router.post('/test-connection', adminAuth, requirePermission('backup.create'), a
|
|||||||
res.json({ success: false, message: 'Rsync connection failed. Check server logs for details.' });
|
res.json({ success: false, message: 'Rsync connection failed. Check server logs for details.' });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 's3':
|
case 's3':
|
||||||
// Test S3 connection (would need AWS SDK)
|
// Test S3 connection (would need AWS SDK)
|
||||||
@@ -829,7 +831,7 @@ router.get('/download/:backupId', adminAuth, requirePermission('backup.view'), a
|
|||||||
|
|
||||||
// Handle different backup types
|
// Handle different backup types
|
||||||
switch (config.backup_destination_type) {
|
switch (config.backup_destination_type) {
|
||||||
case 'local':
|
case 'local': {
|
||||||
// Stream local backup as zip
|
// Stream local backup as zip
|
||||||
const backupPath = path.join(config.backup_destination_path, `backup-${backupRun.id}`);
|
const backupPath = path.join(config.backup_destination_path, `backup-${backupRun.id}`);
|
||||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||||
@@ -847,8 +849,9 @@ router.get('/download/:backupId', adminAuth, requirePermission('backup.view'), a
|
|||||||
|
|
||||||
await archive.finalize();
|
await archive.finalize();
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 's3':
|
case 's3': {
|
||||||
// For S3, provide pre-signed URLs or stream files
|
// For S3, provide pre-signed URLs or stream files
|
||||||
const s3Adapter = new S3StorageAdapter({
|
const s3Adapter = new S3StorageAdapter({
|
||||||
endpoint: config.backup_s3_endpoint,
|
endpoint: config.backup_s3_endpoint,
|
||||||
@@ -882,6 +885,7 @@ router.get('/download/:backupId', adminAuth, requirePermission('backup.view'), a
|
|||||||
message: 'Use the provided URLs to download individual files'
|
message: 'Use the provided URLs to download individual files'
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'rsync':
|
case 'rsync':
|
||||||
return res.status(400).json({ error: 'Direct download not available for rsync backups' });
|
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
|
// Calculate checksums for files
|
||||||
async function calculateDirChecksums(dirPath, relative = '') {
|
const calculateDirChecksums = async (dirPath, relative = '') => {
|
||||||
try {
|
try {
|
||||||
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||||
|
|
||||||
@@ -940,7 +944,7 @@ router.get('/checksums', adminAuth, requirePermission('backup.view'), async (req
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to calculate checksums for ${dirPath}:`, error);
|
logger.error(`Failed to calculate checksums for ${dirPath}:`, error);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
await calculateDirChecksums(basePath);
|
await calculateDirChecksums(basePath);
|
||||||
|
|
||||||
@@ -978,7 +982,7 @@ router.post('/estimate', adminAuth, requirePermission('backup.view'), async (req
|
|||||||
const breakdown = {};
|
const breakdown = {};
|
||||||
|
|
||||||
// Estimate size for each directory
|
// Estimate size for each directory
|
||||||
async function estimateDir(dirPath, category) {
|
const estimateDir = async (dirPath, category) => {
|
||||||
let dirSize = 0;
|
let dirSize = 0;
|
||||||
let dirCount = 0;
|
let dirCount = 0;
|
||||||
|
|
||||||
@@ -1005,7 +1009,7 @@ router.post('/estimate', adminAuth, requirePermission('backup.view'), async (req
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { size: dirSize, count: dirCount };
|
return { size: dirSize, count: dirCount };
|
||||||
}
|
};
|
||||||
|
|
||||||
// Estimate each category
|
// Estimate each category
|
||||||
const categories = [
|
const categories = [
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const express = require('express');
|
|||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const { requirePermission } = require('../middleware/permissions');
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity');
|
const { sanitizeDays } = require('../utils/sqlSecurity');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { resolveAdapter } = require('../services/trackers');
|
const { resolveAdapter } = require('../services/trackers');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
|||||||
@@ -318,8 +318,6 @@ module.exports = (router) => {
|
|||||||
css_template_id = null,
|
css_template_id = null,
|
||||||
// Hero logo settings
|
// Hero logo settings
|
||||||
hero_logo_visible = true,
|
hero_logo_visible = true,
|
||||||
hero_logo_size = 'medium',
|
|
||||||
hero_logo_position = 'top',
|
|
||||||
// Header style settings
|
// Header style settings
|
||||||
header_style = 'standard',
|
header_style = 'standard',
|
||||||
hero_divider_style = 'wave',
|
hero_divider_style = 'wave',
|
||||||
|
|||||||
@@ -176,8 +176,9 @@ const mapEventForApi = (event) => {
|
|||||||
customer_name,
|
customer_name,
|
||||||
customer_email,
|
customer_email,
|
||||||
customer_phone,
|
customer_phone,
|
||||||
password_hash: _ph,
|
// Bound only to exclude the secrets from `...rest` — never read.
|
||||||
client_password_hash: _cph,
|
// eslint-disable-next-line no-unused-vars -- rest-sibling omission
|
||||||
|
password_hash: _ph, client_password_hash: _cph,
|
||||||
...rest
|
...rest
|
||||||
} = event;
|
} = event;
|
||||||
|
|
||||||
|
|||||||
@@ -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-Type', row.mime_type || 'application/octet-stream');
|
||||||
res.setHeader('Content-Disposition', isPdf ? 'attachment' : 'inline');
|
res.setHeader('Content-Disposition', isPdf ? 'attachment' : 'inline');
|
||||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
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);
|
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-Type', 'image/png');
|
||||||
res.setHeader('Content-Disposition', 'inline');
|
res.setHeader('Content-Disposition', 'inline');
|
||||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
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);
|
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-Type', isPdf ? 'application/pdf' : 'application/octet-stream');
|
||||||
res.setHeader('Content-Disposition', isPdf ? 'attachment' : 'inline');
|
res.setHeader('Content-Disposition', isPdf ? 'attachment' : 'inline');
|
||||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
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);
|
createReadStream(safe).pipe(res);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const fs = require('fs').promises;
|
|||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const { requirePermission } = require('../middleware/permissions');
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const { requireEventOwnership } = require('../middleware/ownership');
|
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 { db, logActivity } = require('../database/db');
|
||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ const { body, param, query } = require('express-validator');
|
|||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const { requirePermission } = require('../middleware/permissions');
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||||
const { db } = require('../database/db');
|
|
||||||
const ledgerService = require('../services/ledgerService');
|
const ledgerService = require('../services/ledgerService');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const { requirePermission } = require('../middleware/permissions');
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '.
|
|||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: (req, file, cb) => {
|
destination: (req, file, cb) => {
|
||||||
logger.info('Multer destination called for file:', file.originalname);
|
logger.info('Multer destination called for file:', file.originalname);
|
||||||
const { eventId } = req.params;
|
|
||||||
|
|
||||||
// We'll validate the event exists in the route handler
|
// We'll validate the event exists in the route handler
|
||||||
// For now, just create a temp destination
|
// For now, just create a temp destination
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ const router = express.Router();
|
|||||||
const { restoreService } = require('../services/restoreService');
|
const { restoreService } = require('../services/restoreService');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const { requirePermission } = require('../middleware/permissions');
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const { body, query, validationResult } = require('express-validator');
|
const { body, validationResult } = require('express-validator');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { getPagination } = require('../utils/routeHelpers');
|
const { getPagination } = require('../utils/routeHelpers');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { db, withRetry } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const { requirePermission } = require('../middleware/permissions');
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ const bcrypt = require('bcrypt');
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { body, param, validationResult } = require('express-validator');
|
const { body, param, validationResult } = require('express-validator');
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
|
||||||
const { getBcryptRounds } = require('../utils/passwordValidation');
|
const { getBcryptRounds } = require('../utils/passwordValidation');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { errorResponse } = require('../utils/routeHelpers');
|
const { errorResponse } = require('../utils/routeHelpers');
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ const bcrypt = require('bcrypt');
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { body, param, validationResult } = require('express-validator');
|
const { body, param, validationResult } = require('express-validator');
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
|
||||||
const { verifyRecaptcha } = require('../services/recaptcha');
|
const { verifyRecaptcha } = require('../services/recaptcha');
|
||||||
const {
|
const {
|
||||||
trackFailedAttempt,
|
trackFailedAttempt,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ const {
|
|||||||
checkValidation,
|
checkValidation,
|
||||||
validateGuestRequirements
|
validateGuestRequirements
|
||||||
} = require('../utils/feedbackValidation');
|
} = require('../utils/feedbackValidation');
|
||||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
|
||||||
|
|
||||||
// Get feedback settings for a gallery
|
// Get feedback settings for a gallery
|
||||||
router.get('/:slug/feedback-settings',
|
router.get('/:slug/feedback-settings',
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ function sanitizeName(value) {
|
|||||||
// Strip HTML/control chars, collapse whitespace.
|
// Strip HTML/control chars, collapse whitespace.
|
||||||
const cleaned = value
|
const cleaned = value
|
||||||
.replace(/[<>&"']/g, '')
|
.replace(/[<>&"']/g, '')
|
||||||
|
// eslint-disable-next-line no-control-regex -- intentional: strips control chars from guest input
|
||||||
.replace(/[\u0000-\u001F\u007F]/g, '')
|
.replace(/[\u0000-\u001F\u007F]/g, '')
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
.trim();
|
.trim();
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ function verifyImageToken(token) {
|
|||||||
router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, blockHiddenGallery, async (req, res) => {
|
router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, blockHiddenGallery, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { photoId } = req.params;
|
const { photoId } = req.params;
|
||||||
const { protectionLevel = 'standard', token } = req.query;
|
const { protectionLevel = 'standard' } = req.query;
|
||||||
|
|
||||||
// Create client fingerprint
|
// Create client fingerprint
|
||||||
const clientFingerprint = secureImageService.createClientFingerprint(req);
|
const clientFingerprint = secureImageService.createClientFingerprint(req);
|
||||||
|
|||||||
@@ -16,32 +16,32 @@ const router = express.Router();
|
|||||||
const { actByToken, peekApproval } = require('../services/workflows');
|
const { actByToken, peekApproval } = require('../services/workflows');
|
||||||
|
|
||||||
function page(title, body) {
|
function page(title, body) {
|
||||||
return `<!doctype html><html><head><meta charset="utf-8">`
|
return '<!doctype html><html><head><meta charset="utf-8">'
|
||||||
+ `<meta name="viewport" content="width=device-width, initial-scale=1">`
|
+ '<meta name="viewport" content="width=device-width, initial-scale=1">'
|
||||||
+ `<title>${title}</title></head>`
|
+ `<title>${title}</title></head>`
|
||||||
+ `<body style="font-family:system-ui,sans-serif;max-width:480px;margin:64px auto;padding:0 20px;text-align:center;color:#1f2937">`
|
+ '<body style="font-family:system-ui,sans-serif;max-width:480px;margin:64px auto;padding:0 20px;text-align:center;color:#1f2937">'
|
||||||
+ `<h2 style="font-weight:600">${title}</h2><p style="color:#4b5563;line-height:1.6">${body}</p></body></html>`;
|
+ `<h2 style="font-weight:600">${title}</h2><p style="color:#4b5563;line-height:1.6">${body}</p></body></html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Escape any prompt text we echo into the interstitial HTML.
|
// Escape any prompt text we echo into the interstitial HTML.
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
||||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
{ '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' }[c]
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
function decisionPage(token, emphasis, prompt) {
|
function decisionPage(token, emphasis, prompt) {
|
||||||
const btn = (href, label, primary) => `<form method="POST" action="${href}" style="display:inline">`
|
const btn = (href, label, primary) => `<form method="POST" action="${href}" style="display:inline">`
|
||||||
+ `<button type="submit" style="cursor:pointer;margin:6px;padding:12px 20px;border-radius:8px;border:1px solid #d1d5db;`
|
+ '<button type="submit" style="cursor:pointer;margin:6px;padding:12px 20px;border-radius:8px;border:1px solid #d1d5db;'
|
||||||
+ `font-size:15px;font-weight:600;${primary
|
+ `font-size:15px;font-weight:600;${primary
|
||||||
? 'background:#1d9e75;color:#fff;border-color:#1d9e75'
|
? 'background:#1d9e75;color:#fff;border-color:#1d9e75'
|
||||||
: 'background:#fff;color:#374151'}">${label}</button></form>`;
|
: 'background:#fff;color:#374151'}">${label}</button></form>`;
|
||||||
const body = (prompt ? `<span style="display:block;margin-bottom:16px">${esc(prompt)}</span>` : '')
|
const body = (prompt ? `<span style="display:block;margin-bottom:16px">${esc(prompt)}</span>` : '')
|
||||||
+ `<div>`
|
+ '<div>'
|
||||||
+ btn(`confirm`, 'Confirm payment received', emphasis === 'confirm')
|
+ btn('confirm', 'Confirm payment received', emphasis === 'confirm')
|
||||||
+ btn(`deny`, 'No payment received', emphasis === 'deny')
|
+ btn('deny', 'No payment received', emphasis === 'deny')
|
||||||
+ `</div>`
|
+ '</div>'
|
||||||
+ `<p style="color:#9ca3af;font-size:13px;margin-top:20px">Choosing is a single, final action.</p>`;
|
+ '<p style="color:#9ca3af;font-size:13px;margin-top:20px">Choosing is a single, final action.</p>';
|
||||||
return page('Confirm your response', body);
|
return page('Confirm your response', body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
const { DatabaseBackupService } = require('../databaseBackup');
|
const { DatabaseBackupService } = require('../databaseBackup');
|
||||||
const { db } = require('../../database/db');
|
const { db } = require('../../database/db');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const path = require('path');
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
@@ -12,11 +11,9 @@ jest.mock('child_process');
|
|||||||
|
|
||||||
describe('DatabaseBackupService', () => {
|
describe('DatabaseBackupService', () => {
|
||||||
let service;
|
let service;
|
||||||
let mockExecAsync;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
service = new DatabaseBackupService();
|
service = new DatabaseBackupService();
|
||||||
mockExecAsync = jest.fn();
|
|
||||||
|
|
||||||
// Reset mocks
|
// Reset mocks
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ async function tryInstallFromBackup(db, logger) {
|
|||||||
// console.log as well so the docker-logs surface tells the story
|
// console.log as well so the docker-logs surface tells the story
|
||||||
// without needing to exec into the container.
|
// without needing to exec into the container.
|
||||||
const announce = (msg) => {
|
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 */ }
|
try { console.log(`[install-from-backup] ${msg}`); } catch (_) { /* defensive */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
let booted = false;
|
||||||
|
|
||||||
function parseSeedConfig(raw) {
|
function parseSeedConfig(raw) {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ const fsSync = require('fs');
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const childProcess = require('child_process');
|
const childProcess = require('child_process');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const { promisify } = require('util');
|
|
||||||
|
|
||||||
const cron = require('node-cron');
|
const cron = require('node-cron');
|
||||||
const cronParser = require('cron-parser');
|
const cronParser = require('cron-parser');
|
||||||
@@ -69,7 +68,6 @@ function ensureMockableExec() {
|
|||||||
|
|
||||||
ensureMockableExec();
|
ensureMockableExec();
|
||||||
|
|
||||||
const getExecAsync = () => promisify(childProcess.exec);
|
|
||||||
|
|
||||||
async function resolveConfigWithFallback() {
|
async function resolveConfigWithFallback() {
|
||||||
let config;
|
let config;
|
||||||
@@ -763,7 +761,7 @@ async function performLocalBackup(config, files) {
|
|||||||
|
|
||||||
function validateRsyncParam(value, label) {
|
function validateRsyncParam(value, label) {
|
||||||
if (!value || typeof value !== 'string') return null;
|
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`);
|
throw new Error(`Invalid ${label}: contains disallowed characters`);
|
||||||
}
|
}
|
||||||
if (value.length > 1024) {
|
if (value.length > 1024) {
|
||||||
@@ -1532,7 +1530,7 @@ async function loadManifestFromAnywhere(manifestPath, config) {
|
|||||||
throw new Error('S3 credentials not configured for manifest retrieval');
|
throw new Error('S3 credentials not configured for manifest retrieval');
|
||||||
}
|
}
|
||||||
|
|
||||||
const match = manifestPath.match(/^s3:\/\/([^\/]+)\/(.+)$/);
|
const match = manifestPath.match(/^s3:\/\/([^/]+)\/(.+)$/);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new Error('Invalid S3 manifest path');
|
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');
|
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) {
|
if (!match) {
|
||||||
throw new Error('Invalid S3 manifest path');
|
throw new Error('Invalid S3 manifest path');
|
||||||
}
|
}
|
||||||
@@ -1640,7 +1638,7 @@ async function validateBackupManifest(manifestPath) {
|
|||||||
let manifest;
|
let manifest;
|
||||||
|
|
||||||
if (manifestPath.startsWith('s3://')) {
|
if (manifestPath.startsWith('s3://')) {
|
||||||
const match = manifestPath.match(/^s3:\/\/([^\/]+)\/(.+)$/);
|
const match = manifestPath.match(/^s3:\/\/([^/]+)\/(.+)$/);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new Error('Invalid S3 manifest path');
|
throw new Error('Invalid S3 manifest path');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const CONTRACT_EMAIL_TEMPLATES = {
|
|||||||
<p>Or open the full contract:<br>
|
<p>Or open the full contract:<br>
|
||||||
<span style="word-break: break-all; font-size: 13px;">{{response_url}}</span></p>
|
<span style="word-break: break-all; font-size: 13px;">{{response_url}}</span></p>
|
||||||
{{#if valid_until}}<p style="font-size: 13px; color: #666;">Please sign by {{valid_until}}.</p>{{/if}}`,
|
{{#if valid_until}}<p style="font-size: 13px; color: #666;">Please sign by {{valid_until}}.</p>{{/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: {
|
de: {
|
||||||
subject: 'Vertrag {{contract_number}} zur Unterzeichnung bereit',
|
subject: 'Vertrag {{contract_number}} zur Unterzeichnung bereit',
|
||||||
@@ -44,7 +44,7 @@ const CONTRACT_EMAIL_TEMPLATES = {
|
|||||||
<p>Oder öffnen Sie den vollständigen Vertrag im Browser:<br>
|
<p>Oder öffnen Sie den vollständigen Vertrag im Browser:<br>
|
||||||
<span style="word-break: break-all; font-size: 13px;">{{response_url}}</span></p>
|
<span style="word-break: break-all; font-size: 13px;">{{response_url}}</span></p>
|
||||||
{{#if valid_until}}<p style="font-size: 13px; color: #666;">Bitte unterzeichnen Sie bis {{valid_until}}.</p>{{/if}}`,
|
{{#if valid_until}}<p style="font-size: 13px; color: #666;">Bitte unterzeichnen Sie bis {{valid_until}}.</p>{{/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: {
|
contract_fully_signed: {
|
||||||
@@ -56,7 +56,7 @@ const CONTRACT_EMAIL_TEMPLATES = {
|
|||||||
<p>Dear {{customer_name}},</p>
|
<p>Dear {{customer_name}},</p>
|
||||||
<p>Both parties have now signed contract {{contract_number}}{{#if title}} — "{{title}}"{{/if}}. Please find the fully signed PDF attached for your records.</p>
|
<p>Both parties have now signed contract {{contract_number}}{{#if title}} — "{{title}}"{{/if}}. Please find the fully signed PDF attached for your records.</p>
|
||||||
<p style="font-size: 13px; color: #666;">This is the authoritative signed copy. Keep it alongside the related quote and invoices.</p>`,
|
<p style="font-size: 13px; color: #666;">This is the authoritative signed copy. Keep it alongside the related quote and invoices.</p>`,
|
||||||
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: {
|
de: {
|
||||||
subject: 'Vertrag {{contract_number}} vollständig unterzeichnet',
|
subject: 'Vertrag {{contract_number}} vollständig unterzeichnet',
|
||||||
@@ -64,7 +64,7 @@ const CONTRACT_EMAIL_TEMPLATES = {
|
|||||||
<p>Sehr geehrte/r {{customer_name}},</p>
|
<p>Sehr geehrte/r {{customer_name}},</p>
|
||||||
<p>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.</p>
|
<p>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.</p>
|
||||||
<p style="font-size: 13px; color: #666;">Dies ist die massgebliche unterzeichnete Fassung. Bewahren Sie sie zusammen mit dem zugehörigen Angebot und den Rechnungen auf.</p>`,
|
<p style="font-size: 13px; color: #666;">Dies ist die massgebliche unterzeichnete Fassung. Bewahren Sie sie zusammen mit dem zugehörigen Angebot und den Rechnungen auf.</p>`,
|
||||||
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: {
|
contract_signed_admin_notification: {
|
||||||
@@ -75,14 +75,14 @@ const CONTRACT_EMAIL_TEMPLATES = {
|
|||||||
body_html: `<h2>Contract signed</h2><p>{{signed_customer_name}} ({{customer_email}}) has just signed contract <strong>{{contract_number}}</strong>.</p>
|
body_html: `<h2>Contract signed</h2><p>{{signed_customer_name}} ({{customer_email}}) has just signed contract <strong>{{contract_number}}</strong>.</p>
|
||||||
<p style="text-align: center; margin: 30px 0;"><a href="{{admin_dashboard_url}}" class="button">Open in admin</a></p>
|
<p style="text-align: center; margin: 30px 0;"><a href="{{admin_dashboard_url}}" class="button">Open in admin</a></p>
|
||||||
<p style="font-size: 13px; color: #666;">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.</p>`,
|
<p style="font-size: 13px; color: #666;">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.</p>`,
|
||||||
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: {
|
de: {
|
||||||
subject: 'Vertrag {{contract_number}} von {{customer_email}} unterzeichnet',
|
subject: 'Vertrag {{contract_number}} von {{customer_email}} unterzeichnet',
|
||||||
body_html: `<h2>Vertrag unterzeichnet</h2><p>{{signed_customer_name}} ({{customer_email}}) hat soeben den Vertrag <strong>{{contract_number}}</strong> unterzeichnet.</p>
|
body_html: `<h2>Vertrag unterzeichnet</h2><p>{{signed_customer_name}} ({{customer_email}}) hat soeben den Vertrag <strong>{{contract_number}}</strong> unterzeichnet.</p>
|
||||||
<p style="text-align: center; margin: 30px 0;"><a href="{{admin_dashboard_url}}" class="button">Im Admin-Bereich öffnen</a></p>
|
<p style="text-align: center; margin: 30px 0;"><a href="{{admin_dashboard_url}}" class="button">Im Admin-Bereich öffnen</a></p>
|
||||||
<p style="font-size: 13px; color: #666;">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.</p>`,
|
<p style="font-size: 13px; color: #666;">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.</p>`,
|
||||||
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}}',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ quote_sent: {
|
|||||||
<p>Or open the full quote in your browser:<br>
|
<p>Or open the full quote in your browser:<br>
|
||||||
<span style="word-break: break-all; font-size: 13px;">{{response_url}}</span></p>
|
<span style="word-break: break-all; font-size: 13px;">{{response_url}}</span></p>
|
||||||
{{#if valid_until}}<p style="font-size: 13px; color: #666;">This quote is valid until {{valid_until}}.</p>{{/if}}`,
|
{{#if valid_until}}<p style="font-size: 13px; color: #666;">This quote is valid until {{valid_until}}.</p>{{/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: {
|
de: {
|
||||||
subject: 'Ihr Angebot {{quote_number}} ist bereit',
|
subject: 'Ihr Angebot {{quote_number}} ist bereit',
|
||||||
@@ -56,7 +56,7 @@ quote_sent: {
|
|||||||
<p>Oder öffnen Sie das vollständige Angebot im Browser:<br>
|
<p>Oder öffnen Sie das vollständige Angebot im Browser:<br>
|
||||||
<span style="word-break: break-all; font-size: 13px;">{{response_url}}</span></p>
|
<span style="word-break: break-all; font-size: 13px;">{{response_url}}</span></p>
|
||||||
{{#if valid_until}}<p style="font-size: 13px; color: #666;">Dieses Angebot ist gültig bis {{valid_until}}.</p>{{/if}}`,
|
{{#if valid_until}}<p style="font-size: 13px; color: #666;">Dieses Angebot ist gültig bis {{valid_until}}.</p>{{/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: {
|
quote_accepted_admin: {
|
||||||
@@ -66,13 +66,13 @@ quote_sent: {
|
|||||||
subject: 'Quote {{quote_number}} accepted by {{customer_email}}',
|
subject: 'Quote {{quote_number}} accepted by {{customer_email}}',
|
||||||
body_html: `<h2>Quote accepted</h2><p>{{customer_email}} just accepted quote <strong>{{quote_number}}</strong>{{#if event_name}} for "{{event_name}}"{{/if}}. Total: {{total_amount}}.</p>
|
body_html: `<h2>Quote accepted</h2><p>{{customer_email}} just accepted quote <strong>{{quote_number}}</strong>{{#if event_name}} for "{{event_name}}"{{/if}}. Total: {{total_amount}}.</p>
|
||||||
<p style="text-align: center; margin: 30px 0;"><a href="{{admin_dashboard_url}}" class="button">Open in admin</a></p>`,
|
<p style="text-align: center; margin: 30px 0;"><a href="{{admin_dashboard_url}}" class="button">Open in admin</a></p>`,
|
||||||
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: {
|
de: {
|
||||||
subject: 'Angebot {{quote_number}} von {{customer_email}} angenommen',
|
subject: 'Angebot {{quote_number}} von {{customer_email}} angenommen',
|
||||||
body_html: `<h2>Angebot angenommen</h2><p>{{customer_email}} hat soeben das Angebot <strong>{{quote_number}}</strong>{{#if event_name}} für "{{event_name}}"{{/if}} angenommen. Gesamtbetrag: {{total_amount}}.</p>
|
body_html: `<h2>Angebot angenommen</h2><p>{{customer_email}} hat soeben das Angebot <strong>{{quote_number}}</strong>{{#if event_name}} für "{{event_name}}"{{/if}} angenommen. Gesamtbetrag: {{total_amount}}.</p>
|
||||||
<p style="text-align: center; margin: 30px 0;"><a href="{{admin_dashboard_url}}" class="button">Im Admin-Bereich öffnen</a></p>`,
|
<p style="text-align: center; margin: 30px 0;"><a href="{{admin_dashboard_url}}" class="button">Im Admin-Bereich öffnen</a></p>`,
|
||||||
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: {
|
quote_declined_admin: {
|
||||||
@@ -82,13 +82,13 @@ quote_sent: {
|
|||||||
subject: 'Quote {{quote_number}} declined by {{customer_email}}',
|
subject: 'Quote {{quote_number}} declined by {{customer_email}}',
|
||||||
body_html: `<p>{{customer_email}} declined quote <strong>{{quote_number}}</strong>{{#if event_name}} for "{{event_name}}"{{/if}}.</p>
|
body_html: `<p>{{customer_email}} declined quote <strong>{{quote_number}}</strong>{{#if event_name}} for "{{event_name}}"{{/if}}.</p>
|
||||||
<p><a href="{{admin_dashboard_url}}">Open quote in admin</a></p>`,
|
<p><a href="{{admin_dashboard_url}}">Open quote in admin</a></p>`,
|
||||||
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: {
|
de: {
|
||||||
subject: 'Angebot {{quote_number}} von {{customer_email}} abgelehnt',
|
subject: 'Angebot {{quote_number}} von {{customer_email}} abgelehnt',
|
||||||
body_html: `<p>{{customer_email}} hat das Angebot <strong>{{quote_number}}</strong>{{#if event_name}} für "{{event_name}}"{{/if}} abgelehnt.</p>
|
body_html: `<p>{{customer_email}} hat das Angebot <strong>{{quote_number}}</strong>{{#if event_name}} für "{{event_name}}"{{/if}} abgelehnt.</p>
|
||||||
<p><a href="{{admin_dashboard_url}}">Angebot im Admin-Bereich öffnen</a></p>`,
|
<p><a href="{{admin_dashboard_url}}">Angebot im Admin-Bereich öffnen</a></p>`,
|
||||||
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: {
|
invoice_sent: {
|
||||||
@@ -101,7 +101,7 @@ quote_sent: {
|
|||||||
<p>Please find the attached invoice {{invoice_number}}{{#if event_name}} for "{{event_name}}"{{/if}}.</p>
|
<p>Please find the attached invoice {{invoice_number}}{{#if event_name}} for "{{event_name}}"{{/if}}.</p>
|
||||||
<p><strong>Amount:</strong> {{total_amount}}<br><strong>Due:</strong> {{due_date}}{{#if installment_label}}<br><strong>Installment:</strong> {{installment_label}} ({{installment_index}}/{{installment_total}}){{/if}}</p>
|
<p><strong>Amount:</strong> {{total_amount}}<br><strong>Due:</strong> {{due_date}}{{#if installment_label}}<br><strong>Installment:</strong> {{installment_label}} ({{installment_index}}/{{installment_total}}){{/if}}</p>
|
||||||
<p>The payment details and IBAN are on the attached PDF.</p>`,
|
<p>The payment details and IBAN are on the attached PDF.</p>`,
|
||||||
body_text: `Invoice {{invoice_number}}: {{total_amount}}, due {{due_date}}.`,
|
body_text: 'Invoice {{invoice_number}}: {{total_amount}}, due {{due_date}}.',
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
subject: 'Rechnung {{invoice_number}} — {{total_amount}}',
|
subject: 'Rechnung {{invoice_number}} — {{total_amount}}',
|
||||||
@@ -109,7 +109,7 @@ quote_sent: {
|
|||||||
<p>im Anhang finden Sie die Rechnung {{invoice_number}}{{#if event_name}} für "{{event_name}}"{{/if}}.</p>
|
<p>im Anhang finden Sie die Rechnung {{invoice_number}}{{#if event_name}} für "{{event_name}}"{{/if}}.</p>
|
||||||
<p><strong>Betrag:</strong> {{total_amount}}<br><strong>Fällig:</strong> {{due_date}}{{#if installment_label}}<br><strong>Teilzahlung:</strong> {{installment_label}} ({{installment_index}}/{{installment_total}}){{/if}}</p>
|
<p><strong>Betrag:</strong> {{total_amount}}<br><strong>Fällig:</strong> {{due_date}}{{#if installment_label}}<br><strong>Teilzahlung:</strong> {{installment_label}} ({{installment_index}}/{{installment_total}}){{/if}}</p>
|
||||||
<p>Die Zahlungsdetails und IBAN finden Sie auf dem beigefügten PDF.</p>`,
|
<p>Die Zahlungsdetails und IBAN finden Sie auf dem beigefügten PDF.</p>`,
|
||||||
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: {
|
invoice_reminder_first: {
|
||||||
@@ -120,14 +120,14 @@ quote_sent: {
|
|||||||
body_html: `<h2>Payment reminder</h2><p>Dear {{customer_name}},</p>
|
body_html: `<h2>Payment reminder</h2><p>Dear {{customer_name}},</p>
|
||||||
<p>Our records show that invoice <strong>{{invoice_number}}</strong> (originally due {{due_date}}) is now {{days_overdue}} days overdue. The outstanding amount is <strong>{{total_amount}}</strong>.</p>
|
<p>Our records show that invoice <strong>{{invoice_number}}</strong> (originally due {{due_date}}) is now {{days_overdue}} days overdue. The outstanding amount is <strong>{{total_amount}}</strong>.</p>
|
||||||
<p>If you have already paid, please ignore this reminder. Otherwise, please find a fresh copy attached.</p>`,
|
<p>If you have already paid, please ignore this reminder. Otherwise, please find a fresh copy attached.</p>`,
|
||||||
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: {
|
de: {
|
||||||
subject: 'Zahlungserinnerung: Rechnung {{invoice_number}}',
|
subject: 'Zahlungserinnerung: Rechnung {{invoice_number}}',
|
||||||
body_html: `<h2>Zahlungserinnerung</h2><p>Sehr geehrte/r {{customer_name}},</p>
|
body_html: `<h2>Zahlungserinnerung</h2><p>Sehr geehrte/r {{customer_name}},</p>
|
||||||
<p>laut unseren Unterlagen ist die Rechnung <strong>{{invoice_number}}</strong> (ursprünglich fällig am {{due_date}}) seit {{days_overdue}} Tagen überfällig. Der offene Betrag beträgt <strong>{{total_amount}}</strong>.</p>
|
<p>laut unseren Unterlagen ist die Rechnung <strong>{{invoice_number}}</strong> (ursprünglich fällig am {{due_date}}) seit {{days_overdue}} Tagen überfällig. Der offene Betrag beträgt <strong>{{total_amount}}</strong>.</p>
|
||||||
<p>Sollten Sie die Zahlung bereits veranlasst haben, betrachten Sie diese Erinnerung als gegenstandslos. Im Anhang finden Sie eine aktuelle Kopie der Rechnung.</p>`,
|
<p>Sollten Sie die Zahlung bereits veranlasst haben, betrachten Sie diese Erinnerung als gegenstandslos. Im Anhang finden Sie eine aktuelle Kopie der Rechnung.</p>`,
|
||||||
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: {
|
invoice_reminder_second: {
|
||||||
@@ -139,14 +139,14 @@ quote_sent: {
|
|||||||
body_html: `<h2>Second payment reminder</h2><p>Dear {{customer_name}},</p>
|
body_html: `<h2>Second payment reminder</h2><p>Dear {{customer_name}},</p>
|
||||||
<p>Invoice <strong>{{invoice_number}}</strong> is now {{days_overdue}} days overdue. As advised in our payment terms, a late fee of <strong>{{late_fee_amount}}</strong> has been added. The new total is <strong>{{new_total_amount}}</strong>.</p>
|
<p>Invoice <strong>{{invoice_number}}</strong> is now {{days_overdue}} days overdue. As advised in our payment terms, a late fee of <strong>{{late_fee_amount}}</strong> has been added. The new total is <strong>{{new_total_amount}}</strong>.</p>
|
||||||
<p>Please settle the outstanding amount as soon as possible. A revised invoice is attached.</p>`,
|
<p>Please settle the outstanding amount as soon as possible. A revised invoice is attached.</p>`,
|
||||||
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: {
|
de: {
|
||||||
subject: 'Zweite Mahnung: Rechnung {{invoice_number}}',
|
subject: 'Zweite Mahnung: Rechnung {{invoice_number}}',
|
||||||
body_html: `<h2>Zweite Zahlungserinnerung</h2><p>Sehr geehrte/r {{customer_name}},</p>
|
body_html: `<h2>Zweite Zahlungserinnerung</h2><p>Sehr geehrte/r {{customer_name}},</p>
|
||||||
<p>die Rechnung <strong>{{invoice_number}}</strong> ist nun seit {{days_overdue}} Tagen überfällig. Gemäss unseren Zahlungsbedingungen wurde eine Mahngebühr von <strong>{{late_fee_amount}}</strong> hinzugefügt. Der neue Gesamtbetrag beträgt <strong>{{new_total_amount}}</strong>.</p>
|
<p>die Rechnung <strong>{{invoice_number}}</strong> ist nun seit {{days_overdue}} Tagen überfällig. Gemäss unseren Zahlungsbedingungen wurde eine Mahngebühr von <strong>{{late_fee_amount}}</strong> hinzugefügt. Der neue Gesamtbetrag beträgt <strong>{{new_total_amount}}</strong>.</p>
|
||||||
<p>Wir bitten Sie, den offenen Betrag umgehend zu begleichen. Eine aktualisierte Rechnung finden Sie im Anhang.</p>`,
|
<p>Wir bitten Sie, den offenen Betrag umgehend zu begleichen. Eine aktualisierte Rechnung finden Sie im Anhang.</p>`,
|
||||||
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: {
|
invoice_paid_receipt: {
|
||||||
@@ -156,13 +156,13 @@ quote_sent: {
|
|||||||
subject: 'Receipt for invoice {{invoice_number}}',
|
subject: 'Receipt for invoice {{invoice_number}}',
|
||||||
body_html: `<h2>Payment received</h2><p>Dear {{customer_name}},</p>
|
body_html: `<h2>Payment received</h2><p>Dear {{customer_name}},</p>
|
||||||
<p>We received your payment of <strong>{{paid_amount}}</strong> for invoice {{invoice_number}} on {{paid_at}}. Thank you!</p>`,
|
<p>We received your payment of <strong>{{paid_amount}}</strong> for invoice {{invoice_number}} on {{paid_at}}. Thank you!</p>`,
|
||||||
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: {
|
de: {
|
||||||
subject: 'Zahlungsbestätigung für Rechnung {{invoice_number}}',
|
subject: 'Zahlungsbestätigung für Rechnung {{invoice_number}}',
|
||||||
body_html: `<h2>Zahlung erhalten</h2><p>Sehr geehrte/r {{customer_name}},</p>
|
body_html: `<h2>Zahlung erhalten</h2><p>Sehr geehrte/r {{customer_name}},</p>
|
||||||
<p>vielen Dank für Ihre Zahlung in Höhe von <strong>{{paid_amount}}</strong> für die Rechnung {{invoice_number}} am {{paid_at}}.</p>`,
|
<p>vielen Dank für Ihre Zahlung in Höhe von <strong>{{paid_amount}}</strong> für die Rechnung {{invoice_number}} am {{paid_at}}.</p>`,
|
||||||
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: {
|
invoice_cancelled: {
|
||||||
@@ -170,13 +170,13 @@ quote_sent: {
|
|||||||
variables: ['invoice_number', 'customer_name'],
|
variables: ['invoice_number', 'customer_name'],
|
||||||
en: {
|
en: {
|
||||||
subject: 'Invoice {{invoice_number}} cancelled',
|
subject: 'Invoice {{invoice_number}} cancelled',
|
||||||
body_html: `<p>Dear {{customer_name}},</p><p>Invoice {{invoice_number}} has been cancelled. Please disregard any previous reminders for this invoice.</p>`,
|
body_html: '<p>Dear {{customer_name}},</p><p>Invoice {{invoice_number}} has been cancelled. Please disregard any previous reminders for this invoice.</p>',
|
||||||
body_text: `Invoice {{invoice_number}} has been cancelled.`,
|
body_text: 'Invoice {{invoice_number}} has been cancelled.',
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
subject: 'Rechnung {{invoice_number}} storniert',
|
subject: 'Rechnung {{invoice_number}} storniert',
|
||||||
body_html: `<p>Sehr geehrte/r {{customer_name}},</p><p>die Rechnung {{invoice_number}} wurde storniert. Bitte ignorieren Sie eventuelle frühere Erinnerungen zu dieser Rechnung.</p>`,
|
body_html: '<p>Sehr geehrte/r {{customer_name}},</p><p>die Rechnung {{invoice_number}} wurde storniert. Bitte ignorieren Sie eventuelle frühere Erinnerungen zu dieser Rechnung.</p>',
|
||||||
body_text: `Rechnung {{invoice_number}} wurde storniert.`,
|
body_text: 'Rechnung {{invoice_number}} wurde storniert.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
quote_accepted_customer: {
|
quote_accepted_customer: {
|
||||||
@@ -294,14 +294,14 @@ Bei „Nicht bezahlt" oder „Teilweise bezahlt" wird automatisch die Zahlungser
|
|||||||
body_html: `<p>Dear {{customer_name}},</p>
|
body_html: `<p>Dear {{customer_name}},</p>
|
||||||
<p>Please find attached cancellation invoice <strong>{{storno_number}}</strong>, which formally reverses invoice <strong>{{original_invoice_number}}</strong> dated {{original_issue_date}} for {{total_amount}}.</p>
|
<p>Please find attached cancellation invoice <strong>{{storno_number}}</strong>, which formally reverses invoice <strong>{{original_invoice_number}}</strong> dated {{original_issue_date}} for {{total_amount}}.</p>
|
||||||
<p>The original invoice is no longer payable. Please retain the attached PDF for your records and disregard any prior reminders.</p>`,
|
<p>The original invoice is no longer payable. Please retain the attached PDF for your records and disregard any prior reminders.</p>`,
|
||||||
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: {
|
de: {
|
||||||
subject: 'Stornorechnung {{storno_number}} zu Rechnung {{original_invoice_number}}',
|
subject: 'Stornorechnung {{storno_number}} zu Rechnung {{original_invoice_number}}',
|
||||||
body_html: `<p>Sehr geehrte/r {{customer_name}},</p>
|
body_html: `<p>Sehr geehrte/r {{customer_name}},</p>
|
||||||
<p>anbei erhalten Sie die Stornorechnung <strong>{{storno_number}}</strong>, mit der die Rechnung <strong>{{original_invoice_number}}</strong> vom {{original_issue_date}} über {{total_amount}} förmlich aufgehoben wird.</p>
|
<p>anbei erhalten Sie die Stornorechnung <strong>{{storno_number}}</strong>, mit der die Rechnung <strong>{{original_invoice_number}}</strong> vom {{original_issue_date}} über {{total_amount}} förmlich aufgehoben wird.</p>
|
||||||
<p>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.</p>`,
|
<p>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.</p>`,
|
||||||
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: {
|
invoice_paid_admin_notification: {
|
||||||
|
|||||||
@@ -23,10 +23,8 @@
|
|||||||
* — same legal-record discipline as line items today.
|
* — same legal-record discipline as line items today.
|
||||||
*/
|
*/
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
|
||||||
const { AppError } = require('../utils/errors');
|
const { AppError } = require('../utils/errors');
|
||||||
const { hasColumnCached } = require('../utils/schemaCache');
|
const { hasColumnCached } = require('../utils/schemaCache');
|
||||||
const logger = require('../utils/logger');
|
|
||||||
const invoiceService = require('./invoiceService');
|
const invoiceService = require('./invoiceService');
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
@@ -287,7 +285,7 @@ async function createEntry(customerId, payload, adminId) {
|
|||||||
logInfo = { type: 'hour_entry_logged', meta: { entryId, customerId: customer.id } };
|
logInfo = { type: 'hour_entry_logged', meta: { entryId, customerId: customer.id } };
|
||||||
return { id: entryId, status: 'unbilled' };
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,7 +383,7 @@ async function updateEntry(entryId, payload, adminId) {
|
|||||||
logInfo = { type: 'hour_entry_updated', meta: { entryId, customerId: entry.customer_account_id } };
|
logInfo = { type: 'hour_entry_updated', meta: { entryId, customerId: entry.customer_account_id } };
|
||||||
return { id: entryId };
|
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;
|
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 } };
|
logInfo = { type: 'hour_entry_deleted', meta: { entryId, customerId: entry.customer_account_id, hadInvoice: !!entry.invoice_id } };
|
||||||
return { deleted: true };
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,7 +519,7 @@ async function billUnbilledEntries(customerId, adminId) {
|
|||||||
logInfo = { type: 'hour_entries_billed', meta: { customerId: customer.id, invoiceId, entryCount: unbilled.length } };
|
logInfo = { type: 'hour_entries_billed', meta: { customerId: customer.id, invoiceId, entryCount: unbilled.length } };
|
||||||
return { invoiceId, entriesBilled: 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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ const { formatBoolean } = require('../utils/dbCompat');
|
|||||||
const packageJson = require('../../package.json');
|
const packageJson = require('../../package.json');
|
||||||
|
|
||||||
// Constants
|
// 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
|
// Face recognition tables (#1074). Their SCHEMA is backed up, their CONTENTS
|
||||||
// are not: embeddings are biometric data (GDPR Art. 9) and fully derived from
|
// are not: embeddings are biometric data (GDPR Art. 9) and fully derived from
|
||||||
@@ -183,7 +181,7 @@ class DatabaseBackupService {
|
|||||||
/**
|
/**
|
||||||
* Create SQLite backup
|
* Create SQLite backup
|
||||||
*/
|
*/
|
||||||
async createSQLiteBackup(outputPath, options = {}) {
|
async createSQLiteBackup(outputPath, _options = {}) {
|
||||||
const dbPath = knexConfig.connection.filename;
|
const dbPath = knexConfig.connection.filename;
|
||||||
const tempPath = `${outputPath}.tmp`;
|
const tempPath = `${outputPath}.tmp`;
|
||||||
|
|
||||||
@@ -214,7 +212,7 @@ class DatabaseBackupService {
|
|||||||
// works out that a manual re-scan is needed. Requeue instead.
|
// works out that a manual re-scan is needed. Requeue instead.
|
||||||
await spawnAsync('sqlite3', [
|
await spawnAsync('sqlite3', [
|
||||||
tempPath,
|
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;',
|
+ 'face_count = NULL, face_started_at = NULL, face_error = NULL;',
|
||||||
]).catch(() => {});
|
]).catch(() => {});
|
||||||
// FATAL, not a warning. Deleting rows leaves their pages in the file
|
// FATAL, not a warning. Deleting rows leaves their pages in the file
|
||||||
@@ -337,7 +335,7 @@ class DatabaseBackupService {
|
|||||||
/**
|
/**
|
||||||
* Validate backup integrity
|
* Validate backup integrity
|
||||||
*/
|
*/
|
||||||
async validateBackup(backupPath, originalChecksums) {
|
async validateBackup(backupPath, _originalChecksums) {
|
||||||
const tempDbPath = `${backupPath}.validate`;
|
const tempDbPath = `${backupPath}.validate`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -747,7 +745,7 @@ class DatabaseBackupService {
|
|||||||
/**
|
/**
|
||||||
* Restore from backup (with version checking)
|
* 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
|
// 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.');
|
throw new Error('Restore functionality not implemented for safety. Please use restore service or restore manually.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ async function getRecipientLanguage(email, eventId = null) {
|
|||||||
.first();
|
.first();
|
||||||
if (langSetting && langSetting.setting_value) {
|
if (langSetting && langSetting.setting_value) {
|
||||||
let lang = 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();
|
if (typeof lang === 'string' && lang.trim()) return lang.trim();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -63,8 +63,6 @@ const { ensureEventReminderTemplatesSeeded } = require('./eventReminderTemplates
|
|||||||
|
|
||||||
const DEFAULT_DAYS_BEFORE = 2;
|
const DEFAULT_DAYS_BEFORE = 2;
|
||||||
const DEFAULT_TEMPLATE_GROUP = 'event_reminder';
|
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
|
// One-shot guard: the "schema not migrated" warn would otherwise fire
|
||||||
// once per cron tick (≈ hourly) on installs that haven't applied
|
// once per cron tick (≈ hourly) on installs that haven't applied
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ const VARIABLES = [
|
|||||||
|
|
||||||
// Tiny HTML signature line shared across templates so the maintainer
|
// Tiny HTML signature line shared across templates so the maintainer
|
||||||
// only has to brand once. Variables substitute at render time.
|
// only has to brand once. Variables substitute at render time.
|
||||||
const SIGNATURE_EN = `<p style="margin-top: 24px;">See you soon,<br>{{business_name}}</p>`;
|
const SIGNATURE_EN = '<p style="margin-top: 24px;">See you soon,<br>{{business_name}}</p>';
|
||||||
const SIGNATURE_DE = `<p style="margin-top: 24px;">Bis bald,<br>{{business_name}}</p>`;
|
const SIGNATURE_DE = '<p style="margin-top: 24px;">Bis bald,<br>{{business_name}}</p>';
|
||||||
|
|
||||||
const EVENT_REMINDER_TEMPLATES = {
|
const EVENT_REMINDER_TEMPLATES = {
|
||||||
event_reminder_default: {
|
event_reminder_default: {
|
||||||
@@ -54,7 +54,7 @@ const EVENT_REMINDER_TEMPLATES = {
|
|||||||
</ul>
|
</ul>
|
||||||
<p>If anything has changed since we last spoke, just hit reply.</p>
|
<p>If anything has changed since we last spoke, just hit reply.</p>
|
||||||
${SIGNATURE_EN}`,
|
${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: {
|
de: {
|
||||||
subject: 'Erinnerung: {{event_name}} in {{days_before}} Tag(en)',
|
subject: 'Erinnerung: {{event_name}} in {{days_before}} Tag(en)',
|
||||||
@@ -68,7 +68,7 @@ ${SIGNATURE_EN}`,
|
|||||||
</ul>
|
</ul>
|
||||||
<p>Hat sich seit unserem letzten Austausch etwas geändert? Einfach kurz auf diese Mail antworten.</p>
|
<p>Hat sich seit unserem letzten Austausch etwas geändert? Einfach kurz auf diese Mail antworten.</p>
|
||||||
${SIGNATURE_DE}`,
|
${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}`,
|
|||||||
</ul>
|
</ul>
|
||||||
<p>If anything has shifted since we last spoke — even small things — just hit reply.</p>
|
<p>If anything has shifted since we last spoke — even small things — just hit reply.</p>
|
||||||
${SIGNATURE_EN}`,
|
${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: {
|
de: {
|
||||||
subject: 'Eure Hochzeit am {{event_date}} — letzte Details',
|
subject: 'Eure Hochzeit am {{event_date}} — letzte Details',
|
||||||
@@ -103,7 +103,7 @@ ${SIGNATURE_EN}`,
|
|||||||
</ul>
|
</ul>
|
||||||
<p>Hat sich seit unserem letzten Gespräch etwas verschoben — auch Kleinigkeiten? Einfach kurz antworten.</p>
|
<p>Hat sich seit unserem letzten Gespräch etwas verschoben — auch Kleinigkeiten? Einfach kurz antworten.</p>
|
||||||
${SIGNATURE_DE}`,
|
${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}`,
|
|||||||
</ul>
|
</ul>
|
||||||
<p>Looking forward to celebrating — let us know if anything has changed.</p>
|
<p>Looking forward to celebrating — let us know if anything has changed.</p>
|
||||||
${SIGNATURE_EN}`,
|
${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: {
|
de: {
|
||||||
subject: '{{event_name}} am {{event_date}} — kurze Rückfrage',
|
subject: '{{event_name}} am {{event_date}} — kurze Rückfrage',
|
||||||
@@ -134,7 +134,7 @@ ${SIGNATURE_EN}`,
|
|||||||
</ul>
|
</ul>
|
||||||
<p>Wir freuen uns auf das Fest — kurz Bescheid geben, falls sich etwas geändert hat.</p>
|
<p>Wir freuen uns auf das Fest — kurz Bescheid geben, falls sich etwas geändert hat.</p>
|
||||||
${SIGNATURE_DE}`,
|
${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}`,
|
|||||||
</ul>
|
</ul>
|
||||||
<p>Happy to jump on a 10-min call beforehand if it is easier than email.</p>
|
<p>Happy to jump on a 10-min call beforehand if it is easier than email.</p>
|
||||||
${SIGNATURE_EN}`,
|
${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: {
|
de: {
|
||||||
subject: 'Vorbereitung Bildbegleitung: {{event_name}} am {{event_date}}',
|
subject: 'Vorbereitung Bildbegleitung: {{event_name}} am {{event_date}}',
|
||||||
@@ -169,7 +169,7 @@ ${SIGNATURE_EN}`,
|
|||||||
</ul>
|
</ul>
|
||||||
<p>Falls eine kurze 10-Min-Abstimmung einfacher ist als E-Mail, gerne jederzeit melden.</p>
|
<p>Falls eine kurze 10-Min-Abstimmung einfacher ist als E-Mail, gerne jederzeit melden.</p>
|
||||||
${SIGNATURE_DE}`,
|
${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}`,
|
|||||||
</ul>
|
</ul>
|
||||||
<p>If anything has changed since we last spoke, hit reply.</p>
|
<p>If anything has changed since we last spoke, hit reply.</p>
|
||||||
${SIGNATURE_EN}`,
|
${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: {
|
de: {
|
||||||
subject: '{{event_name}} am {{event_date}} — Vorbereitungs-Hinweise',
|
subject: '{{event_name}} am {{event_date}} — Vorbereitungs-Hinweise',
|
||||||
@@ -200,7 +200,7 @@ ${SIGNATURE_EN}`,
|
|||||||
</ul>
|
</ul>
|
||||||
<p>Hat sich seit dem letzten Austausch etwas geändert? Einfach kurz antworten.</p>
|
<p>Hat sich seit dem letzten Austausch etwas geändert? Einfach kurz antworten.</p>
|
||||||
${SIGNATURE_DE}`,
|
${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}}',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Handles renaming events including slug updates, file system changes, and database updates
|
* 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 fs = require('fs').promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
@@ -228,7 +228,7 @@ class EventRenameService {
|
|||||||
const event = await trx('events').where({ id: eventId }).first();
|
const event = await trx('events').where({ id: eventId }).first();
|
||||||
|
|
||||||
// Generate new share link
|
// Generate new share link
|
||||||
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({
|
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({
|
||||||
slug: newSlug,
|
slug: newSlug,
|
||||||
shareToken: event.share_token
|
shareToken: event.share_token
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ async function getById(id) {
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function create({ name, color, displayOrder }, adminId) {
|
async function create({ name, color, displayOrder }, _adminId) {
|
||||||
if (!name || !String(name).trim()) {
|
if (!name || !String(name).trim()) {
|
||||||
throw new AppError('Category name is required', 400, 'NAME_REQUIRED');
|
throw new AppError('Category name is required', 400, 'NAME_REQUIRED');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ async function list(relativePath = '') {
|
|||||||
const targetDir = safePathJoin(root, relativePath || '.');
|
const targetDir = safePathJoin(root, relativePath || '.');
|
||||||
|
|
||||||
const entries = [];
|
const entries = [];
|
||||||
try {
|
// Errors propagate to the caller to handle (e.g. invalid path).
|
||||||
const dirents = await fs.readdir(targetDir, { withFileTypes: true });
|
const dirents = await fs.readdir(targetDir, { withFileTypes: true });
|
||||||
for (const d of dirents) {
|
for (const d of dirents) {
|
||||||
// Skip hidden files and directories
|
// Skip hidden files and directories
|
||||||
@@ -79,10 +79,6 @@ async function list(relativePath = '') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
// Propagate errors for caller to handle (e.g., invalid path)
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootResolved = path.resolve(root);
|
const rootResolved = path.resolve(root);
|
||||||
const currentResolved = path.resolve(targetDir);
|
const currentResolved = path.resolve(targetDir);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function startFileWatcher() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const watcher = chokidar.watch(WATCH_PATH(), {
|
const watcher = chokidar.watch(WATCH_PATH(), {
|
||||||
ignored: /(^|[\/\\])\../, // ignore dotfiles
|
ignored: /(^|[/\\])\../, // ignore dotfiles
|
||||||
persistent: true,
|
persistent: true,
|
||||||
awaitWriteFinish: {
|
awaitWriteFinish: {
|
||||||
stabilityThreshold: 2000,
|
stabilityThreshold: 2000,
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ async function scanRoot(rootAbs) {
|
|||||||
if (result.has(lc)) {
|
if (result.has(lc)) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[fonts] Duplicate family ${family.family} within ${rootAbs}; ` +
|
`[fonts] Duplicate family ${family.family} within ${rootAbs}; ` +
|
||||||
`keeping the first encountered folder`
|
'keeping the first encountered folder'
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -306,7 +306,7 @@ async function createInvoice(payload, adminId, trx = db) {
|
|||||||
await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', invoiceId, items);
|
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] };
|
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).
|
// pool (this runs unattended from the booking flow's prepare_invoice).
|
||||||
await logActivity('invoice_scheduled', { invoiceId, invoiceNumber, eventId, quoteId, scheduledSendAt },
|
await logActivity('invoice_scheduled', { invoiceId, invoiceNumber, eventId, quoteId, scheduledSendAt },
|
||||||
eventId, `admin:${adminId}`, trx);
|
eventId, `admin:${adminId}`, trx);
|
||||||
} catch (_) {}
|
} catch (_) { /* non-fatal */ }
|
||||||
invoiceIds.push(invoiceId);
|
invoiceIds.push(invoiceId);
|
||||||
}
|
}
|
||||||
return { invoiceIds };
|
return { invoiceIds };
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ async function appendToMonthlyDraft(payload, customer, adminId, trx) {
|
|||||||
await logActivity('monthly_billing_items_queued',
|
await logActivity('monthly_billing_items_queued',
|
||||||
{ invoiceId: draft.id, customerId: customer.id, itemsAdded: newItems.length },
|
{ invoiceId: draft.id, customerId: customer.id, itemsAdded: newItems.length },
|
||||||
null, `admin:${adminId}`);
|
null, `admin:${adminId}`);
|
||||||
} catch (_) {}
|
} catch (_) { /* non-fatal */ }
|
||||||
|
|
||||||
return draft.id;
|
return draft.id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -347,7 +347,7 @@ async function updateInstallmentPlan({ trx, dealUuid, installments, adminId }) {
|
|||||||
await logActivity('invoice_scheduled', {
|
await logActivity('invoice_scheduled', {
|
||||||
invoiceId: newId, invoiceNumber, eventId: sample.event_id, source: 'plan_reshape',
|
invoiceId: newId, invoiceNumber, eventId: sample.event_id, source: 'plan_reshape',
|
||||||
}, sample.event_id, `admin:${adminId}`);
|
}, sample.event_id, `admin:${adminId}`);
|
||||||
} catch (_) {}
|
} catch (_) { /* non-fatal */ }
|
||||||
|
|
||||||
created.push(newId);
|
created.push(newId);
|
||||||
}
|
}
|
||||||
@@ -365,7 +365,7 @@ async function updateInstallmentPlan({ trx, dealUuid, installments, adminId }) {
|
|||||||
dealUuid, newCount,
|
dealUuid, newCount,
|
||||||
kept: kept.length, created: created.length, deleted: deleted.length,
|
kept: kept.length, created: created.length, deleted: deleted.length,
|
||||||
}, sample.event_id, `admin:${adminId}`);
|
}, sample.event_id, `admin:${adminId}`);
|
||||||
} catch (_) {}
|
} catch (_) { /* non-fatal */ }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
invoiceIds: [...kept, ...created],
|
invoiceIds: [...kept, ...created],
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ async function markPaid(id, { amountMinor, paidAt, paymentMethod, reference, not
|
|||||||
|
|
||||||
try { await logActivity(isFull ? 'invoice_paid' : 'invoice_partial_payment',
|
try { await logActivity(isFull ? 'invoice_paid' : 'invoice_partial_payment',
|
||||||
{ invoiceId: id, amountMinor: amount, totalPaidMinor: total },
|
{ 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
|
// Migration 127 — admin payment-received notification. Fires only
|
||||||
// on the transition into 'paid' so admins don't get duplicate
|
// 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,
|
paidTotalMinor: markResult.paidTotalMinor,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (_) {}
|
} catch (_) { /* non-fatal */ }
|
||||||
}
|
}
|
||||||
return markResult;
|
return markResult;
|
||||||
}
|
}
|
||||||
@@ -203,7 +203,7 @@ async function queueInvoicePaidAdminNotification({
|
|||||||
try {
|
try {
|
||||||
await logActivity('invoice_paid_admin_notified', { invoiceId: invoice.id },
|
await logActivity('invoice_paid_admin_notified', { invoiceId: invoice.id },
|
||||||
invoice.event_id || null, 'system');
|
invoice.event_id || null, 'system');
|
||||||
} catch (_) {}
|
} catch (_) { /* non-fatal */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function queuePaymentCheckEmail(invoiceId, { skipThrottle = false } = {}) {
|
async function queuePaymentCheckEmail(invoiceId, { skipThrottle = false } = {}) {
|
||||||
@@ -318,7 +318,7 @@ async function queuePaymentCheckEmail(invoiceId, { skipThrottle = false } = {})
|
|||||||
try {
|
try {
|
||||||
await logActivity('invoice_payment_check_sent', { invoiceId, token: token.slice(0, 8) },
|
await logActivity('invoice_payment_check_sent', { invoiceId, token: token.slice(0, 8) },
|
||||||
invoice.event_id || null, 'scheduler');
|
invoice.event_id || null, 'scheduler');
|
||||||
} catch (_) {}
|
} catch (_) { /* non-fatal */ }
|
||||||
|
|
||||||
return { token, sent: true };
|
return { token, sent: true };
|
||||||
}
|
}
|
||||||
@@ -449,7 +449,7 @@ async function recordPaymentCheckAction({ token, action, amountMinor, ip, adminI
|
|||||||
{ invoiceId: invoice.id, action, amountMinor: amountMinor || null },
|
{ invoiceId: invoice.id, action, amountMinor: amountMinor || null },
|
||||||
invoice.event_id || null,
|
invoice.event_id || null,
|
||||||
adminId ? `admin:${adminId}` : 'public:payment-check');
|
adminId ? `admin:${adminId}` : 'public:payment-check');
|
||||||
} catch (_) {}
|
} catch (_) { /* non-fatal */ }
|
||||||
|
|
||||||
// --- Apply the action -----------------------------------------
|
// --- Apply the action -----------------------------------------
|
||||||
if (action === 'paid_full') {
|
if (action === 'paid_full') {
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ async function applyReminder(invoice, lineItems, level, adminId) {
|
|||||||
currency: invoice.currency,
|
currency: invoice.currency,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (_) {}
|
} catch (_) { /* non-fatal */ }
|
||||||
|
|
||||||
// Render the MAHNUNG (reminder letter). The original invoice PDF is left
|
// Render the MAHNUNG (reminder letter). The original invoice PDF is left
|
||||||
// UNTOUCHED (immutable). The Mahnung reuses the invoice layout via a
|
// UNTOUCHED (immutable). The Mahnung reuses the invoice layout via a
|
||||||
@@ -180,7 +180,7 @@ async function applyReminder(invoice, lineItems, level, adminId) {
|
|||||||
try {
|
try {
|
||||||
await logActivity('invoice_reminder_sent', { invoiceId: invoice.id, level, lateFeeMinor: lateFeeGross },
|
await logActivity('invoice_reminder_sent', { invoiceId: invoice.id, level, lateFeeMinor: lateFeeGross },
|
||||||
invoice.event_id || null, `admin:${adminId || 'system'}`);
|
invoice.event_id || null, `admin:${adminId || 'system'}`);
|
||||||
} catch (_) {}
|
} catch (_) { /* non-fatal */ }
|
||||||
|
|
||||||
return { level, lateFeeMinor: lateFeeGross };
|
return { level, lateFeeMinor: lateFeeGross };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ async function runScheduledTasks() {
|
|||||||
await logActivity('monthly_bill_skipped_empty',
|
await logActivity('monthly_bill_skipped_empty',
|
||||||
{ invoiceId: draft.id, customerId: draft.customer_account_id },
|
{ invoiceId: draft.id, customerId: draft.customer_account_id },
|
||||||
null, 'scheduler');
|
null, 'scheduler');
|
||||||
} catch (_) {}
|
} catch (_) { /* non-fatal */ }
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Arm for the flush pass: clear the draft flag, set the send
|
// 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,
|
{ invoiceId: draft.id, customerId: draft.customer_account_id,
|
||||||
periodEnd: draft.monthly_period_end },
|
periodEnd: draft.monthly_period_end },
|
||||||
null, 'scheduler');
|
null, 'scheduler');
|
||||||
} catch (_) {}
|
} catch (_) { /* non-fatal */ }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Monthly bill issuance failed', { invoiceId: draft.id, err: err.message });
|
logger.error('Monthly bill issuance failed', { invoiceId: draft.id, err: err.message });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ async function sendInvoice(id, adminId, options = {}) {
|
|||||||
attachments: invoiceAttachments,
|
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 +
|
// 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
|
// 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,
|
currency: invoice.currency,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (_) {}
|
} catch (_) { /* non-fatal */ }
|
||||||
|
|
||||||
return { sent: true, pdfPath };
|
return { sent: true, pdfPath };
|
||||||
}
|
}
|
||||||
@@ -355,7 +355,7 @@ async function createStorno(originalId, adminId, trx = db) {
|
|||||||
await logActivity('invoice_cancelled_via_storno',
|
await logActivity('invoice_cancelled_via_storno',
|
||||||
{ invoiceId: originalId, stornoId, stornoNumber },
|
{ invoiceId: originalId, stornoId, stornoNumber },
|
||||||
original.event_id || null, `admin:${adminId}`, trx);
|
original.event_id || null, `admin:${adminId}`, trx);
|
||||||
} catch (_) {}
|
} catch (_) { /* non-fatal */ }
|
||||||
|
|
||||||
return stornoId;
|
return stornoId;
|
||||||
}
|
}
|
||||||
@@ -430,7 +430,7 @@ async function sendStorno(stornoId, adminId) {
|
|||||||
await logActivity('storno_sent',
|
await logActivity('storno_sent',
|
||||||
{ stornoId, stornoNumber: storno.invoice_number, originalInvoiceId: storno.cancels_invoice_id || null },
|
{ stornoId, stornoNumber: storno.invoice_number, originalInvoiceId: storno.cancels_invoice_id || null },
|
||||||
storno.event_id || null, `admin:${adminId || 'system'}`);
|
storno.event_id || null, `admin:${adminId || 'system'}`);
|
||||||
} catch (_) {}
|
} catch (_) { /* non-fatal */ }
|
||||||
|
|
||||||
return { status: 'sent', stornoId };
|
return { status: 'sent', stornoId };
|
||||||
}
|
}
|
||||||
@@ -558,7 +558,7 @@ async function reissueInvoice(id, adminId) {
|
|||||||
await logActivity('invoice_reissued',
|
await logActivity('invoice_reissued',
|
||||||
{ originalInvoiceId: id, newInvoiceId: newId, stornoId },
|
{ originalInvoiceId: id, newInvoiceId: newId, stornoId },
|
||||||
original.event_id || null, `admin:${adminId}`, trx);
|
original.event_id || null, `admin:${adminId}`, trx);
|
||||||
} catch (_) {}
|
} catch (_) { /* non-fatal */ }
|
||||||
|
|
||||||
return { id: newId, replaces: id, stornoId };
|
return { id: newId, replaces: id, stornoId };
|
||||||
});
|
});
|
||||||
@@ -592,7 +592,7 @@ async function releaseForDelivery(id, adminId) {
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await logActivity('invoice_released_for_delivery', { invoiceId: id }, invoice.event_id || null, `admin:${adminId}`);
|
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
|
// Fire immediately rather than waiting for the next scheduler
|
||||||
// tick — admin clicked the button because they want it out now.
|
// tick — admin clicked the button because they want it out now.
|
||||||
return await sendInvoice(id, adminId);
|
return await sendInvoice(id, adminId);
|
||||||
@@ -646,7 +646,7 @@ async function cancelInvoice(id, adminId) {
|
|||||||
await logActivity('invoice_cancelled',
|
await logActivity('invoice_cancelled',
|
||||||
{ invoiceId: id, viaStorno: false },
|
{ invoiceId: id, viaStorno: false },
|
||||||
invoice.event_id || null, `admin:${adminId}`);
|
invoice.event_id || null, `admin:${adminId}`);
|
||||||
} catch (_) {}
|
} catch (_) { /* non-fatal */ }
|
||||||
return { cancelled: true, stornoId: null };
|
return { cancelled: true, stornoId: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -690,7 +690,7 @@ async function triggerMonthlyBillNow(customerId, adminId) {
|
|||||||
await logActivity('monthly_bill_triggered_manually',
|
await logActivity('monthly_bill_triggered_manually',
|
||||||
{ invoiceId: draft.id, customerId, periodEnd: draft.monthly_period_end },
|
{ invoiceId: draft.id, customerId, periodEnd: draft.monthly_period_end },
|
||||||
null, `admin:${adminId}`);
|
null, `admin:${adminId}`);
|
||||||
} catch (_) {}
|
} catch (_) { /* non-fatal */ }
|
||||||
|
|
||||||
// Inline send so admin gets immediate feedback (PDF stored, status
|
// Inline send so admin gets immediate feedback (PDF stored, status
|
||||||
// flipped to 'sent', email queued). A failure here doesn't roll
|
// flipped to 'sent', email queued). A failure here doesn't roll
|
||||||
|
|||||||
@@ -527,15 +527,6 @@ function drawTitle(doc, title, x, y) {
|
|||||||
return doc.y + 8;
|
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
|
* Render the line-items table via swissqrbill's Table helper. We supply
|
||||||
* widths in points; the helper draws the borderless layout the
|
* widths in points; the helper draws the borderless layout the
|
||||||
@@ -1946,12 +1937,12 @@ function renderContractToBuffer(context) {
|
|||||||
|
|
||||||
// ---- helper: ensure space before drawing, paginate if needed.
|
// ---- helper: ensure space before drawing, paginate if needed.
|
||||||
const bottomLimit = PAGE.height - PAGE.marginBottom - 20;
|
const bottomLimit = PAGE.height - PAGE.marginBottom - 20;
|
||||||
function ensureSpace(needed) {
|
const ensureSpace = (needed) => {
|
||||||
if (y + needed > bottomLimit) {
|
if (y + needed > bottomLimit) {
|
||||||
doc.addPage();
|
doc.addPage();
|
||||||
y = PAGE.marginTop;
|
y = PAGE.marginTop;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// ---- helper: render body text with inline **bold** support.
|
// ---- helper: render body text with inline **bold** support.
|
||||||
// Splits on `**text**` markers, switches the font weight per
|
// 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
|
// chunks continue from PDFKit's cursor so wrapping works
|
||||||
// across font switches. After rendering, we read doc.y as
|
// across font switches. After rendering, we read doc.y as
|
||||||
// the new cursor.
|
// the new cursor.
|
||||||
function renderBodyMarkdown(text, opts) {
|
const renderBodyMarkdown = (text, opts) => {
|
||||||
const parts = String(text || '').split(/(\*\*[^*]+\*\*)/g).filter((p) => p.length > 0);
|
const parts = String(text || '').split(/(\*\*[^*]+\*\*)/g).filter((p) => p.length > 0);
|
||||||
if (parts.length === 0) return;
|
if (parts.length === 0) return;
|
||||||
const last = parts.length - 1;
|
const last = parts.length - 1;
|
||||||
@@ -1976,7 +1967,7 @@ function renderContractToBuffer(context) {
|
|||||||
doc.text(chunk, { ...opts, continued: i < last });
|
doc.text(chunk, { ...opts, continued: i < last });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// ---- intro text ---------------------------------------------
|
// ---- intro text ---------------------------------------------
|
||||||
if (ctx.doc?.introText) {
|
if (ctx.doc?.introText) {
|
||||||
@@ -2174,7 +2165,7 @@ function renderContractToBuffer(context) {
|
|||||||
// Two empty signature boxes — customer on the left, admin on
|
// Two empty signature boxes — customer on the left, admin on
|
||||||
// the right. drawn at fixed coordinates so the stamp service
|
// the right. drawn at fixed coordinates so the stamp service
|
||||||
// can find them later by constant rather than runtime layout.
|
// 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.font(doc._fonts.bold).fontSize(10).fillColor('#000');
|
||||||
doc.text(label, x, L.paneLabelY, { width: L.boxWidth });
|
doc.text(label, x, L.paneLabelY, { width: L.boxWidth });
|
||||||
doc.strokeColor('#cccccc').lineWidth(0.5)
|
doc.strokeColor('#cccccc').lineWidth(0.5)
|
||||||
@@ -2194,7 +2185,7 @@ function renderContractToBuffer(context) {
|
|||||||
`${t(locale, 'signed_label_date')}: ${info?.signedAt ? formatDate(info.signedAt, locale) : ''}`,
|
`${t(locale, 'signed_label_date')}: ${info?.signedAt ? formatDate(info.signedAt, locale) : ''}`,
|
||||||
x, captionY + 12, { width: L.boxWidth },
|
x, captionY + 12, { width: L.boxWidth },
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
drawEmptySignaturePane(L.customerX, t(locale, 'signature_customer'), ctx.signatures?.customer);
|
drawEmptySignaturePane(L.customerX, t(locale, 'signature_customer'), ctx.signatures?.customer);
|
||||||
drawEmptySignaturePane(L.adminX, t(locale, 'signature_admin'), ctx.signatures?.admin);
|
drawEmptySignaturePane(L.adminX, t(locale, 'signature_admin'), ctx.signatures?.admin);
|
||||||
|
|||||||
@@ -23,7 +23,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const PDFKit = require('pdfkit');
|
const PDFKit = require('pdfkit');
|
||||||
const { PDFDocument } = require('pdf-lib');
|
const { PDFDocument } = require('pdf-lib');
|
||||||
@@ -84,7 +83,7 @@ function pdfkitToPdfLib(pageHeight, x, y, w, h) {
|
|||||||
* or the input file.
|
* or the input file.
|
||||||
*/
|
*/
|
||||||
async function stampSignature({ pdfBuffer, signaturePngPath, role, caption }) {
|
async function stampSignature({ pdfBuffer, signaturePngPath, role, caption }) {
|
||||||
const { L, FONT_BODY, FONT_BOLD, formatDate } = pdfConsts();
|
const { L, formatDate } = pdfConsts();
|
||||||
if (!Buffer.isBuffer(pdfBuffer)) {
|
if (!Buffer.isBuffer(pdfBuffer)) {
|
||||||
throw new Error('stampSignature: pdfBuffer must be a Buffer');
|
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 labelW = 200;
|
||||||
const valueW = PAGE.contentWidth - labelW;
|
const valueW = PAGE.contentWidth - labelW;
|
||||||
function row(labelKey, value) {
|
const row = (labelKey, value) => {
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
doc.font(doc._fonts.bold).fontSize(9).fillColor('#444');
|
doc.font(doc._fonts.bold).fontSize(9).fillColor('#444');
|
||||||
doc.text(t(locale, labelKey), PAGE.marginLeft, y, {
|
doc.text(t(locale, labelKey), PAGE.marginLeft, y, {
|
||||||
@@ -272,7 +271,7 @@ async function renderAuditCertificate({ contract, customer, admin, locale = 'de'
|
|||||||
width: valueW, align: 'left',
|
width: valueW, align: 'left',
|
||||||
});
|
});
|
||||||
y = Math.max(y + 12, doc.y + 4);
|
y = Math.max(y + 12, doc.y + 4);
|
||||||
}
|
};
|
||||||
|
|
||||||
row('audit_contract_number', contract.contract_number);
|
row('audit_contract_number', contract.contract_number);
|
||||||
row('audit_issued_at', contract.sent_at
|
row('audit_issued_at', contract.sent_at
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ const feedbackService = require('./feedbackService');
|
|||||||
const { neutralizeSpreadsheetFormula } = require('../utils/spreadsheetSafe');
|
const { neutralizeSpreadsheetFormula } = require('../utils/spreadsheetSafe');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs').promises;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The name the camera gave the file, or null when nothing was recorded (#1229).
|
* The name the camera gave the file, or null when nothing was recorded (#1229).
|
||||||
@@ -289,7 +288,7 @@ class PhotoExportService {
|
|||||||
/**
|
/**
|
||||||
* Export as JSON metadata
|
* Export as JSON metadata
|
||||||
*/
|
*/
|
||||||
async exportAsJson(photos, eventId, options = {}) {
|
async exportAsJson(photos, eventId, _options = {}) {
|
||||||
// Get event info
|
// Get event info
|
||||||
const event = await db('events')
|
const event = await db('events')
|
||||||
.where('id', eventId)
|
.where('id', eventId)
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ const PRESERVED_AUTH_FIELDS = [
|
|||||||
async function jsonColumnsFor(trx, table) {
|
async function jsonColumnsFor(trx, table) {
|
||||||
if (!isPostgres()) return new Set();
|
if (!isPostgres()) return new Set();
|
||||||
const res = await trx.raw(
|
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]
|
[table]
|
||||||
);
|
);
|
||||||
return new Set(res.rows.map((r) => r.column_name));
|
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) => {
|
await db.transaction(async (trx) => {
|
||||||
if (isPostgres()) {
|
if (isPostgres()) {
|
||||||
try {
|
try {
|
||||||
await trx.raw("SET session_replication_role = 'replica'");
|
await trx.raw('SET session_replication_role = \'replica\'');
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// session_replication_role requires a Postgres SUPERUSER. The bundled
|
// session_replication_role requires a Postgres SUPERUSER. The bundled
|
||||||
// postgres image's role is one; managed Postgres (RDS / Cloud SQL / …)
|
// 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.
|
// 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\'');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ const { hasColumnCached } = require('../utils/schemaCache');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
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 = {
|
const VALID_QUOTE_TRANSITIONS = {
|
||||||
draft: new Set(['sent', 'declined']),
|
draft: new Set(['sent', 'declined']),
|
||||||
sent: new Set(['draft', 'accepted', 'declined', 'expired']),
|
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 —
|
// Pass `trx` so the audit insert rides the transaction's connection —
|
||||||
// the global db here deadlocks the single-connection SQLite pool.
|
// the global db here deadlocks the single-connection SQLite pool.
|
||||||
await logActivity('quote_created', { quoteId, quoteNumber, customerAccountId: payload.customerAccountId }, null, `admin:${adminId}`, trx);
|
await logActivity('quote_created', { quoteId, quoteNumber, customerAccountId: payload.customerAccountId }, null, `admin:${adminId}`, trx);
|
||||||
} catch (_) {}
|
} catch (_) { /* non-fatal */ }
|
||||||
|
|
||||||
logger.info('Quote created', { adminId, quoteId, quoteNumber });
|
logger.info('Quote created', { adminId, quoteId, quoteNumber });
|
||||||
return quoteId;
|
return quoteId;
|
||||||
@@ -760,7 +764,7 @@ async function updateQuote(id, payload, adminId) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await logActivity('quote_updated', { quoteId: id }, null, `admin:${adminId}`);
|
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
|
// 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.
|
// activity log is readable later (GHSA-prch). The quoteId is the audit key.
|
||||||
await logActivity('quote_sent', { quoteId: id }, null, `admin:${adminId}`);
|
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
|
// 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
|
// the workflows flag is off). The accepted/declined emits already exist; this
|
||||||
@@ -1259,7 +1263,7 @@ async function recordResponse({ token, action, ip, tosAccepted }) {
|
|||||||
try {
|
try {
|
||||||
// Raw bearer token must not reach the activity log (GHSA-prch).
|
// Raw bearer token must not reach the activity log (GHSA-prch).
|
||||||
await logActivity(`quote_${newStatus}`, { quoteId: quote.id }, null, 'customer:public');
|
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
|
// 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
|
// (then converting) can't strip the customer's ability to decline. The
|
||||||
@@ -1317,7 +1321,7 @@ async function adminAcceptQuote(id, adminId) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await logActivity('quote_accepted_by_admin', { quoteId: id }, null, `admin:${adminId}`);
|
await logActivity('quote_accepted_by_admin', { quoteId: id }, null, `admin:${adminId}`);
|
||||||
} catch (_) {}
|
} catch (_) { /* non-fatal */ }
|
||||||
|
|
||||||
// ---- customer confirmation email -------------------------------
|
// ---- customer confirmation email -------------------------------
|
||||||
// Renders the quote PDF + queues a "quote accepted — on your
|
// Renders the quote PDF + queues a "quote accepted — on your
|
||||||
@@ -1428,7 +1432,7 @@ async function adminDeclineQuote(id, adminId, reason = null) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await logActivity('quote_declined_by_admin', { quoteId: id, reason: cleanReason }, null, `admin:${adminId}`);
|
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
|
// Admin decline locks the window immediately (response_locked_at = now), so
|
||||||
// this emits straight away (and stamps emitted) rather than deferring.
|
// this emits straight away (and stamps emitted) rather than deferring.
|
||||||
@@ -1569,7 +1573,7 @@ async function convertToInvoiceOnly(quoteId, adminId, options = {}) {
|
|||||||
try {
|
try {
|
||||||
await logActivity('quote_converted_invoices_only', { quoteId: quote.id, installments: result.installmentsCreated },
|
await logActivity('quote_converted_invoices_only', { quoteId: quote.id, installments: result.installmentsCreated },
|
||||||
null, `admin:${adminId}`);
|
null, `admin:${adminId}`);
|
||||||
} catch (_) {}
|
} catch (_) { /* non-fatal */ }
|
||||||
|
|
||||||
logger.info('Quote converted to invoices only (no event)', { adminId, quoteId: quote.id, installments: result.installmentsCreated });
|
logger.info('Quote converted to invoices only (no event)', { adminId, quoteId: quote.id, installments: result.installmentsCreated });
|
||||||
return result;
|
return result;
|
||||||
@@ -1750,7 +1754,7 @@ async function convertToEvent(quoteId, adminId, options = {}) {
|
|||||||
// (prepare_event runs this unattended from the booking flow).
|
// (prepare_event runs this unattended from the booking flow).
|
||||||
try {
|
try {
|
||||||
await logActivity('quote_converted', { quoteId: quote.id, eventId: result.eventId }, result.eventId, `admin:${adminId}`);
|
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 });
|
logger.info('Quote converted to event', { adminId, quoteId: quote.id, eventId: result.eventId });
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ function clearSettingsCache() {
|
|||||||
*/
|
*/
|
||||||
function isAuthenticated(req) {
|
function isAuthenticated(req) {
|
||||||
try {
|
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 slug = slugMatch ? slugMatch[1] : req.requestedSlug;
|
||||||
const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug);
|
const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug);
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
|||||||
@@ -771,7 +771,7 @@ class RestoreService {
|
|||||||
* Download backup from S3
|
* Download backup from S3
|
||||||
*/
|
*/
|
||||||
async downloadFromS3(s3Url, manifest, options) {
|
async downloadFromS3(s3Url, manifest, options) {
|
||||||
const s3PathMatch = s3Url.match(/^s3:\/\/([^\/]+)\/(.+)$/);
|
const s3PathMatch = s3Url.match(/^s3:\/\/([^/]+)\/(.+)$/);
|
||||||
if (!s3PathMatch) {
|
if (!s3PathMatch) {
|
||||||
throw new Error('Invalid S3 URL format');
|
throw new Error('Invalid S3 URL format');
|
||||||
}
|
}
|
||||||
@@ -930,7 +930,7 @@ class RestoreService {
|
|||||||
/**
|
/**
|
||||||
* Perform database-only restore
|
* Perform database-only restore
|
||||||
*/
|
*/
|
||||||
async performDatabaseRestore(backupPath, manifest, options) {
|
async performDatabaseRestore(backupPath, manifest, _options) {
|
||||||
this.updateProgress('Restoring database...');
|
this.updateProgress('Restoring database...');
|
||||||
|
|
||||||
const dbBackupFile = manifest.database.backup_file;
|
const dbBackupFile = manifest.database.backup_file;
|
||||||
@@ -1524,7 +1524,8 @@ END $$;`
|
|||||||
try {
|
try {
|
||||||
// Read backup manifest
|
// Read backup manifest
|
||||||
const manifestPath = path.join(preRestoreBackupPath, 'backup-manifest.json');
|
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
|
// Restore database if backed up
|
||||||
const dbBackupPath = path.join(preRestoreBackupPath, 'database.sql.gz');
|
const dbBackupPath = path.join(preRestoreBackupPath, 'database.sql.gz');
|
||||||
@@ -1622,7 +1623,7 @@ END $$;`
|
|||||||
* Download file from S3
|
* Download file from S3
|
||||||
*/
|
*/
|
||||||
async downloadFileFromS3(s3Url, localPath, s3Config) {
|
async downloadFileFromS3(s3Url, localPath, s3Config) {
|
||||||
const s3PathMatch = s3Url.match(/^s3:\/\/([^\/]+)\/(.+)$/);
|
const s3PathMatch = s3Url.match(/^s3:\/\/([^/]+)\/(.+)$/);
|
||||||
if (!s3PathMatch) {
|
if (!s3PathMatch) {
|
||||||
throw new Error('Invalid S3 URL format');
|
throw new Error('Invalid S3 URL format');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const watermarkService = require('./watermarkService');
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ describe('S3StorageAdapter', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should configure for MinIO with path style', () => {
|
it('should configure for MinIO with path style', () => {
|
||||||
const minioStorage = new S3StorageAdapter({
|
new S3StorageAdapter({
|
||||||
bucket: 'test-bucket',
|
bucket: 'test-bucket',
|
||||||
endpoint: 'http://localhost:9000',
|
endpoint: 'http://localhost:9000',
|
||||||
forcePathStyle: true,
|
forcePathStyle: true,
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ process.on('uncaughtException', (error) => {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on('unhandledRejection', (reason, promise) => {
|
process.on('unhandledRejection', (reason) => {
|
||||||
logger.error('Unhandled rejection in worker manager:', reason);
|
logger.error('Unhandled rejection in worker manager:', reason);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ describe('sanitizeFilename — accented characters transliterate via NFD (#607)'
|
|||||||
const legacyBroken = (s) =>
|
const legacyBroken = (s) =>
|
||||||
String(s).trim()
|
String(s).trim()
|
||||||
.replace(/\s+/g, '_')
|
.replace(/\s+/g, '_')
|
||||||
.replace(/[^a-zA-Z0-9_\-\.]/g, '')
|
.replace(/[^a-zA-Z0-9_\-.]/g, '')
|
||||||
.replace(/[_\-]{2,}/g, '_')
|
.replace(/[_-]{2,}/g, '_')
|
||||||
.replace(/^[_\-]+|[_\-]+$/g, '');
|
.replace(/^[_-]+|[_-]+$/g, '');
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['Ägypten', 'Agypten'],
|
['Ägypten', 'Agypten'],
|
||||||
@@ -138,8 +138,8 @@ describe('sanitizeForContentDisposition — header-safe ASCII fallback', () => {
|
|||||||
describe('buildContentDisposition — RFC 6266 / RFC 5987 dual form', () => {
|
describe('buildContentDisposition — RFC 6266 / RFC 5987 dual form', () => {
|
||||||
it('emits both filename="..." (ASCII) and filename*=UTF-8\'\'... (unicode) for accented names', () => {
|
it('emits both filename="..." (ASCII) and filename*=UTF-8\'\'... (unicode) for accented names', () => {
|
||||||
const header = buildContentDisposition('Ägypten.jpg');
|
const header = buildContentDisposition('Ägypten.jpg');
|
||||||
expect(header).toContain("filename=\"gypten.jpg\"");
|
expect(header).toContain('filename="gypten.jpg"');
|
||||||
expect(header).toContain("filename*=UTF-8''%C3%84gypten.jpg");
|
expect(header).toContain('filename*=UTF-8\'\'%C3%84gypten.jpg');
|
||||||
expect(header.startsWith('attachment;')).toBe(true);
|
expect(header.startsWith('attachment;')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ function sanitizeCss(css) {
|
|||||||
sanitized = sanitized.replace(pattern, '');
|
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, '');
|
sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, '');
|
||||||
|
|
||||||
const MAX_LENGTH = 100 * 1024;
|
const MAX_LENGTH = 100 * 1024;
|
||||||
@@ -112,6 +113,7 @@ function sanitizeCSS(cssContent) {
|
|||||||
sanitized = sanitized.replace(/<!--[\s\S]*?-->/g, '');
|
sanitized = sanitized.replace(/<!--[\s\S]*?-->/g, '');
|
||||||
|
|
||||||
// Remove control characters
|
// Remove control characters
|
||||||
|
// eslint-disable-next-line no-control-regex -- intentional: strips control chars from untrusted CSS
|
||||||
sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, '');
|
sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, '');
|
||||||
|
|
||||||
// Remove any remaining script-like content
|
// Remove any remaining script-like content
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ function sanitizeComment(text) {
|
|||||||
text = text.replace(/[\u200B-\u200D\uFEFF]/g, '');
|
text = text.replace(/[\u200B-\u200D\uFEFF]/g, '');
|
||||||
|
|
||||||
// Remove control characters
|
// Remove control characters
|
||||||
|
// eslint-disable-next-line no-control-regex -- intentional: strips control chars from feedback text
|
||||||
text = text.replace(/[\x00-\x1F\x7F]/g, '');
|
text = text.replace(/[\x00-\x1F\x7F]/g, '');
|
||||||
|
|
||||||
// Limit consecutive special characters
|
// Limit consecutive special characters
|
||||||
|
|||||||
@@ -37,8 +37,9 @@ function safePathJoin(basePath, userPath) {
|
|||||||
function isPathSafe(filePath) {
|
function isPathSafe(filePath) {
|
||||||
// Check for common path traversal patterns
|
// Check for common path traversal patterns
|
||||||
const dangerousPatterns = [
|
const dangerousPatterns = [
|
||||||
/\.\.[\/\\]/, // ../ or ..\
|
/\.\.[/\\]/, // ../ or ..\
|
||||||
/^[A-Za-z]:/, // Windows drive letters
|
/^[A-Za-z]:/, // Windows drive letters
|
||||||
|
// eslint-disable-next-line no-control-regex -- intentional: detects control chars in paths
|
||||||
/[\x00-\x1f]/ // Control characters
|
/[\x00-\x1f]/ // Control characters
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -27,13 +27,13 @@ function sanitizeFilename(str, maxLength = 50) {
|
|||||||
sanitized = sanitized.replace(/\s+/g, '_');
|
sanitized = sanitized.replace(/\s+/g, '_');
|
||||||
|
|
||||||
// Remove special characters except hyphens, underscores, and dots
|
// 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
|
// Remove multiple consecutive underscores or hyphens
|
||||||
sanitized = sanitized.replace(/[_\-]{2,}/g, '_');
|
sanitized = sanitized.replace(/[_-]{2,}/g, '_');
|
||||||
|
|
||||||
// Remove leading/trailing underscores or hyphens
|
// Remove leading/trailing underscores or hyphens
|
||||||
sanitized = sanitized.replace(/^[_\-]+|[_\-]+$/g, '');
|
sanitized = sanitized.replace(/^[_-]+|[_-]+$/g, '');
|
||||||
|
|
||||||
// Limit length
|
// Limit length
|
||||||
if (sanitized.length > maxLength) {
|
if (sanitized.length > maxLength) {
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ function validatePasswordStrength(password) {
|
|||||||
result.score += 1;
|
result.score += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(password)) {
|
if (!/[!@#$%^&*()_+\-=[\]{}|;:,.<>?]/.test(password)) {
|
||||||
result.messages.push('Password must contain special characters');
|
result.messages.push('Password must contain special characters');
|
||||||
} else {
|
} else {
|
||||||
result.score += 1;
|
result.score += 1;
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ function validatePassword(password, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check special character requirement
|
// Check special character requirement
|
||||||
if (config.requireSpecialChars && !/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) {
|
if (config.requireSpecialChars && !/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) {
|
||||||
errors.push('Password must contain at least one special character');
|
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'
|
// Only allow date-format passwords when complexity is 'simple'
|
||||||
if (complexityLevel === '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)) {
|
if (datePattern.test(password)) {
|
||||||
return {
|
return {
|
||||||
valid: true,
|
valid: true,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function decodeEntities(s) {
|
|||||||
.replace(/</g, '<')
|
.replace(/</g, '<')
|
||||||
.replace(/>/g, '>')
|
.replace(/>/g, '>')
|
||||||
.replace(/"/g, '"')
|
.replace(/"/g, '"')
|
||||||
.replace(/�*39;|�*27;|'/gi, "'")
|
.replace(/�*39;|�*27;|'/gi, '\'')
|
||||||
.replace(/&/g, '&');
|
.replace(/&/g, '&');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user