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:
@@ -1,7 +1,6 @@
|
||||
const { DatabaseBackupService } = require('../databaseBackup');
|
||||
const { db } = require('../../database/db');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Mock dependencies
|
||||
@@ -12,11 +11,9 @@ jest.mock('child_process');
|
||||
|
||||
describe('DatabaseBackupService', () => {
|
||||
let service;
|
||||
let mockExecAsync;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new DatabaseBackupService();
|
||||
mockExecAsync = jest.fn();
|
||||
|
||||
// Reset mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
@@ -167,6 +167,7 @@ async function tryInstallFromBackup(db, logger) {
|
||||
// console.log as well so the docker-logs surface tells the story
|
||||
// without needing to exec into the container.
|
||||
const announce = (msg) => {
|
||||
// eslint-disable-next-line no-console -- deliberate: mirrors boot progress to docker logs
|
||||
try { console.log(`[install-from-backup] ${msg}`); } catch (_) { /* defensive */ }
|
||||
};
|
||||
|
||||
|
||||
@@ -335,6 +335,10 @@ const BUILTINS = [
|
||||
},
|
||||
];
|
||||
|
||||
// NOTE: written at the end of seedBuiltinWorkflowsAtBoot but never read — the
|
||||
// intended "seed only once per process" guard is missing its `if (booted) return;`
|
||||
// check. Left in place so the gap stays visible rather than being silently dropped.
|
||||
// eslint-disable-next-line no-unused-vars -- write-only boot guard, see note above
|
||||
let booted = false;
|
||||
|
||||
function parseSeedConfig(raw) {
|
||||
|
||||
@@ -4,7 +4,6 @@ const fsSync = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const childProcess = require('child_process');
|
||||
const os = require('os');
|
||||
const { promisify } = require('util');
|
||||
|
||||
const cron = require('node-cron');
|
||||
const cronParser = require('cron-parser');
|
||||
@@ -69,7 +68,6 @@ function ensureMockableExec() {
|
||||
|
||||
ensureMockableExec();
|
||||
|
||||
const getExecAsync = () => promisify(childProcess.exec);
|
||||
|
||||
async function resolveConfigWithFallback() {
|
||||
let config;
|
||||
@@ -763,7 +761,7 @@ async function performLocalBackup(config, files) {
|
||||
|
||||
function validateRsyncParam(value, label) {
|
||||
if (!value || typeof value !== 'string') return null;
|
||||
if (!/^[a-zA-Z0-9._\/@:-]+$/.test(value)) {
|
||||
if (!/^[a-zA-Z0-9._/@:-]+$/.test(value)) {
|
||||
throw new Error(`Invalid ${label}: contains disallowed characters`);
|
||||
}
|
||||
if (value.length > 1024) {
|
||||
@@ -1532,7 +1530,7 @@ async function loadManifestFromAnywhere(manifestPath, config) {
|
||||
throw new Error('S3 credentials not configured for manifest retrieval');
|
||||
}
|
||||
|
||||
const match = manifestPath.match(/^s3:\/\/([^\/]+)\/(.+)$/);
|
||||
const match = manifestPath.match(/^s3:\/\/([^/]+)\/(.+)$/);
|
||||
if (!match) {
|
||||
throw new Error('Invalid S3 manifest path');
|
||||
}
|
||||
@@ -1601,7 +1599,7 @@ async function getBackupManifest(backupRunId) {
|
||||
throw new Error('S3 credentials not configured for manifest retrieval');
|
||||
}
|
||||
|
||||
const match = run.manifest_path.match(/^s3:\/\/([^\/]+)\/(.+)$/);
|
||||
const match = run.manifest_path.match(/^s3:\/\/([^/]+)\/(.+)$/);
|
||||
if (!match) {
|
||||
throw new Error('Invalid S3 manifest path');
|
||||
}
|
||||
@@ -1640,7 +1638,7 @@ async function validateBackupManifest(manifestPath) {
|
||||
let manifest;
|
||||
|
||||
if (manifestPath.startsWith('s3://')) {
|
||||
const match = manifestPath.match(/^s3:\/\/([^\/]+)\/(.+)$/);
|
||||
const match = manifestPath.match(/^s3:\/\/([^/]+)\/(.+)$/);
|
||||
if (!match) {
|
||||
throw new Error('Invalid S3 manifest path');
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ const CONTRACT_EMAIL_TEMPLATES = {
|
||||
<p>Or open the full contract:<br>
|
||||
<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}}`,
|
||||
body_text: `Contract {{contract_number}}\n\nDear {{customer_name}},\n\nPlease review and sign the contract {{contract_number}}.\n\nOpen: {{response_url}}\n\n{{#if valid_until}}Please sign by {{valid_until}}.{{/if}}`,
|
||||
body_text: 'Contract {{contract_number}}\n\nDear {{customer_name}},\n\nPlease review and sign the contract {{contract_number}}.\n\nOpen: {{response_url}}\n\n{{#if valid_until}}Please sign by {{valid_until}}.{{/if}}',
|
||||
},
|
||||
de: {
|
||||
subject: 'Vertrag {{contract_number}} zur Unterzeichnung bereit',
|
||||
@@ -44,7 +44,7 @@ const CONTRACT_EMAIL_TEMPLATES = {
|
||||
<p>Oder öffnen Sie den vollständigen Vertrag im Browser:<br>
|
||||
<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}}`,
|
||||
body_text: `Vertrag {{contract_number}}\n\nSehr geehrte/r {{customer_name}},\n\nbitte prüfen und unterzeichnen Sie den Vertrag {{contract_number}}.\n\nÖffnen: {{response_url}}\n\n{{#if valid_until}}Bitte unterzeichnen bis {{valid_until}}.{{/if}}`,
|
||||
body_text: 'Vertrag {{contract_number}}\n\nSehr geehrte/r {{customer_name}},\n\nbitte prüfen und unterzeichnen Sie den Vertrag {{contract_number}}.\n\nÖffnen: {{response_url}}\n\n{{#if valid_until}}Bitte unterzeichnen bis {{valid_until}}.{{/if}}',
|
||||
},
|
||||
},
|
||||
contract_fully_signed: {
|
||||
@@ -56,7 +56,7 @@ const CONTRACT_EMAIL_TEMPLATES = {
|
||||
<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 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: {
|
||||
subject: 'Vertrag {{contract_number}} vollständig unterzeichnet',
|
||||
@@ -64,7 +64,7 @@ const CONTRACT_EMAIL_TEMPLATES = {
|
||||
<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 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: {
|
||||
@@ -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>
|
||||
<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>`,
|
||||
body_text: `Contract {{contract_number}} signed by {{signed_customer_name}} ({{customer_email}}). Open: {{admin_dashboard_url}}`,
|
||||
body_text: 'Contract {{contract_number}} signed by {{signed_customer_name}} ({{customer_email}}). Open: {{admin_dashboard_url}}',
|
||||
},
|
||||
de: {
|
||||
subject: 'Vertrag {{contract_number}} von {{customer_email}} unterzeichnet',
|
||||
body_html: `<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="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}}',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -22,10 +22,10 @@
|
||||
*/
|
||||
|
||||
const CRM_EMAIL_TEMPLATES = {
|
||||
quote_sent: {
|
||||
quote_sent: {
|
||||
category: 'quotes', feature_flag: 'quotes',
|
||||
variables: ['quote_number', 'customer_name', 'response_url', 'accept_url', 'decline_url',
|
||||
'valid_until', 'event_name', 'total_amount'],
|
||||
'valid_until', 'event_name', 'total_amount'],
|
||||
en: {
|
||||
subject: 'Your quote {{quote_number}} is ready',
|
||||
body_html: `<h2>Quote {{quote_number}}</h2>
|
||||
@@ -40,7 +40,7 @@ quote_sent: {
|
||||
<p>Or open the full quote in your browser:<br>
|
||||
<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}}`,
|
||||
body_text: `Quote {{quote_number}}\n\nDear {{customer_name}},\n\nPlease find the attached quote {{quote_number}}. Total: {{total_amount}}.\n\nRespond: {{response_url}}\nAccept: {{accept_url}}\nDecline: {{decline_url}}\n\n{{#if valid_until}}Valid until {{valid_until}}.{{/if}}`,
|
||||
body_text: 'Quote {{quote_number}}\n\nDear {{customer_name}},\n\nPlease find the attached quote {{quote_number}}. Total: {{total_amount}}.\n\nRespond: {{response_url}}\nAccept: {{accept_url}}\nDecline: {{decline_url}}\n\n{{#if valid_until}}Valid until {{valid_until}}.{{/if}}',
|
||||
},
|
||||
de: {
|
||||
subject: 'Ihr Angebot {{quote_number}} ist bereit',
|
||||
@@ -56,7 +56,7 @@ quote_sent: {
|
||||
<p>Oder öffnen Sie das vollständige Angebot im Browser:<br>
|
||||
<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}}`,
|
||||
body_text: `Angebot {{quote_number}}\n\nSehr geehrte/r {{customer_name}},\n\nim Anhang finden Sie das Angebot {{quote_number}}. Gesamtbetrag: {{total_amount}}.\n\nAnsehen: {{response_url}}\nAnnehmen: {{accept_url}}\nAblehnen: {{decline_url}}\n\n{{#if valid_until}}Gültig bis {{valid_until}}.{{/if}}`,
|
||||
body_text: 'Angebot {{quote_number}}\n\nSehr geehrte/r {{customer_name}},\n\nim Anhang finden Sie das Angebot {{quote_number}}. Gesamtbetrag: {{total_amount}}.\n\nAnsehen: {{response_url}}\nAnnehmen: {{accept_url}}\nAblehnen: {{decline_url}}\n\n{{#if valid_until}}Gültig bis {{valid_until}}.{{/if}}',
|
||||
},
|
||||
},
|
||||
quote_accepted_admin: {
|
||||
@@ -66,13 +66,13 @@ quote_sent: {
|
||||
subject: 'Quote {{quote_number}} accepted by {{customer_email}}',
|
||||
body_html: `<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>`,
|
||||
body_text: `Quote {{quote_number}} accepted by {{customer_email}}. Open: {{admin_dashboard_url}}`,
|
||||
body_text: 'Quote {{quote_number}} accepted by {{customer_email}}. Open: {{admin_dashboard_url}}',
|
||||
},
|
||||
de: {
|
||||
subject: 'Angebot {{quote_number}} von {{customer_email}} angenommen',
|
||||
body_html: `<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>`,
|
||||
body_text: `Angebot {{quote_number}} von {{customer_email}} angenommen. Öffnen: {{admin_dashboard_url}}`,
|
||||
body_text: 'Angebot {{quote_number}} von {{customer_email}} angenommen. Öffnen: {{admin_dashboard_url}}',
|
||||
},
|
||||
},
|
||||
quote_declined_admin: {
|
||||
@@ -82,26 +82,26 @@ quote_sent: {
|
||||
subject: 'Quote {{quote_number}} declined by {{customer_email}}',
|
||||
body_html: `<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>`,
|
||||
body_text: `Quote {{quote_number}} declined by {{customer_email}}. Open: {{admin_dashboard_url}}`,
|
||||
body_text: 'Quote {{quote_number}} declined by {{customer_email}}. Open: {{admin_dashboard_url}}',
|
||||
},
|
||||
de: {
|
||||
subject: 'Angebot {{quote_number}} von {{customer_email}} abgelehnt',
|
||||
body_html: `<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>`,
|
||||
body_text: `Angebot {{quote_number}} von {{customer_email}} abgelehnt. Öffnen: {{admin_dashboard_url}}`,
|
||||
body_text: 'Angebot {{quote_number}} von {{customer_email}} abgelehnt. Öffnen: {{admin_dashboard_url}}',
|
||||
},
|
||||
},
|
||||
invoice_sent: {
|
||||
category: 'billing', feature_flag: 'bills',
|
||||
variables: ['invoice_number', 'customer_name', 'event_name', 'total_amount', 'due_date',
|
||||
'installment_label', 'installment_index', 'installment_total'],
|
||||
'installment_label', 'installment_index', 'installment_total'],
|
||||
en: {
|
||||
subject: 'Invoice {{invoice_number}} — {{total_amount}}',
|
||||
body_html: `<h2>Invoice {{invoice_number}}</h2><p>Dear {{customer_name}},</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>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: {
|
||||
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><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>`,
|
||||
body_text: `Rechnung {{invoice_number}}: {{total_amount}}, fällig {{due_date}}.`,
|
||||
body_text: 'Rechnung {{invoice_number}}: {{total_amount}}, fällig {{due_date}}.',
|
||||
},
|
||||
},
|
||||
invoice_reminder_first: {
|
||||
@@ -120,33 +120,33 @@ quote_sent: {
|
||||
body_html: `<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>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: {
|
||||
subject: 'Zahlungserinnerung: Rechnung {{invoice_number}}',
|
||||
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>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: {
|
||||
category: 'billing', feature_flag: 'bills',
|
||||
variables: ['invoice_number', 'customer_name', 'total_amount', 'due_date', 'days_overdue',
|
||||
'late_fee_amount', 'new_total_amount'],
|
||||
'late_fee_amount', 'new_total_amount'],
|
||||
en: {
|
||||
subject: 'Second reminder: invoice {{invoice_number}}',
|
||||
body_html: `<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>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: {
|
||||
subject: 'Zweite Mahnung: Rechnung {{invoice_number}}',
|
||||
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>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: {
|
||||
@@ -156,13 +156,13 @@ quote_sent: {
|
||||
subject: 'Receipt for invoice {{invoice_number}}',
|
||||
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>`,
|
||||
body_text: `Receipt: {{paid_amount}} received for {{invoice_number}} on {{paid_at}}.`,
|
||||
body_text: 'Receipt: {{paid_amount}} received for {{invoice_number}} on {{paid_at}}.',
|
||||
},
|
||||
de: {
|
||||
subject: 'Zahlungsbestätigung für Rechnung {{invoice_number}}',
|
||||
body_html: `<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>`,
|
||||
body_text: `Zahlungsbestätigung: {{paid_amount}} erhalten für {{invoice_number}} am {{paid_at}}.`,
|
||||
body_text: 'Zahlungsbestätigung: {{paid_amount}} erhalten für {{invoice_number}} am {{paid_at}}.',
|
||||
},
|
||||
},
|
||||
invoice_cancelled: {
|
||||
@@ -170,56 +170,56 @@ quote_sent: {
|
||||
variables: ['invoice_number', 'customer_name'],
|
||||
en: {
|
||||
subject: 'Invoice {{invoice_number}} cancelled',
|
||||
body_html: `<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_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.',
|
||||
},
|
||||
de: {
|
||||
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_text: `Rechnung {{invoice_number}} wurde 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_text: 'Rechnung {{invoice_number}} wurde storniert.',
|
||||
},
|
||||
},
|
||||
quote_accepted_customer: {
|
||||
category: 'quotes',
|
||||
feature_flag: 'quotes',
|
||||
variables: ['customer_name', 'quote_number', 'event_name', 'total_amount', 'accepted_on_behalf'],
|
||||
en: {
|
||||
subject: 'Quote {{quote_number}} accepted — thank you',
|
||||
body_html: `<h2>Thank you</h2>
|
||||
category: 'quotes',
|
||||
feature_flag: 'quotes',
|
||||
variables: ['customer_name', 'quote_number', 'event_name', 'total_amount', 'accepted_on_behalf'],
|
||||
en: {
|
||||
subject: 'Quote {{quote_number}} accepted — thank you',
|
||||
body_html: `<h2>Thank you</h2>
|
||||
<p>Dear {{customer_name}},</p>
|
||||
<p>This confirms that quote <strong>{{quote_number}}</strong>{{#if event_name}} for "{{event_name}}"{{/if}} has been accepted. Total: <strong>{{total_amount}}</strong>.</p>
|
||||
{{#if accepted_on_behalf}}<p style="font-size: 13px; color: #666;">This acceptance was recorded on your behalf by your photographer.</p>{{/if}}
|
||||
<p>We'll be in touch with next steps shortly.</p>`,
|
||||
body_text: `Dear {{customer_name}},
|
||||
body_text: `Dear {{customer_name}},
|
||||
|
||||
This confirms that quote {{quote_number}}{{#if event_name}} for "{{event_name}}"{{/if}} has been accepted. Total: {{total_amount}}.
|
||||
{{#if accepted_on_behalf}}
|
||||
This acceptance was recorded on your behalf by your photographer.
|
||||
{{/if}}
|
||||
We'll be in touch with next steps shortly.`,
|
||||
},
|
||||
de: {
|
||||
subject: 'Angebot {{quote_number}} angenommen — vielen Dank',
|
||||
body_html: `<h2>Vielen Dank</h2>
|
||||
},
|
||||
de: {
|
||||
subject: 'Angebot {{quote_number}} angenommen — vielen Dank',
|
||||
body_html: `<h2>Vielen Dank</h2>
|
||||
<p>Sehr geehrte/r {{customer_name}},</p>
|
||||
<p>hiermit bestätigen wir, dass das Angebot <strong>{{quote_number}}</strong>{{#if event_name}} für „{{event_name}}"{{/if}} angenommen wurde. Gesamtbetrag: <strong>{{total_amount}}</strong>.</p>
|
||||
{{#if accepted_on_behalf}}<p style="font-size: 13px; color: #666;">Diese Bestätigung wurde stellvertretend durch Ihren Fotografen erfasst.</p>{{/if}}
|
||||
<p>Wir melden uns in Kürze mit den nächsten Schritten.</p>`,
|
||||
body_text: `Sehr geehrte/r {{customer_name}},
|
||||
body_text: `Sehr geehrte/r {{customer_name}},
|
||||
|
||||
hiermit bestätigen wir, dass das Angebot {{quote_number}}{{#if event_name}} für "{{event_name}}"{{/if}} angenommen wurde. Gesamtbetrag: {{total_amount}}.
|
||||
{{#if accepted_on_behalf}}
|
||||
Diese Bestätigung wurde stellvertretend durch Ihren Fotografen erfasst.
|
||||
{{/if}}
|
||||
Wir melden uns in Kürze mit den nächsten Schritten.`,
|
||||
},
|
||||
},
|
||||
},
|
||||
invoice_payment_check: {
|
||||
category: 'billing', feature_flag: 'bills',
|
||||
variables: ['invoice_number', 'customer_name', 'event_name', 'due_date', 'total_amount', 'paid_url', 'partial_url', 'unpaid_url', 'skonto_url', 'has_skonto', 'skonto_amount', 'late_fee_due', 'late_fee_amount'],
|
||||
en: {
|
||||
subject: 'Check payment for invoice {{invoice_number}}',
|
||||
body_html: `<h2>Time to check on a payment</h2>
|
||||
subject: 'Check payment for invoice {{invoice_number}}',
|
||||
body_html: `<h2>Time to check on a payment</h2>
|
||||
<p>Invoice <strong>{{invoice_number}}</strong> for <strong>{{customer_name}}</strong>{{#if event_name}} ({{event_name}}){{/if}} was due on <strong>{{due_date}}</strong>. Total: <strong>{{total_amount}}</strong>.</p>
|
||||
<p>Please check your bank to confirm what (if anything) has been received, then click the matching button below — no login required.</p>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin: 24px auto; border-collapse: collapse;">
|
||||
@@ -239,7 +239,7 @@ Wir melden uns in Kürze mit den nächsten Schritten.`,
|
||||
</tr>
|
||||
</table>
|
||||
<p style="font-size: 13px; color: #666;">If you select "Not paid yet" or "Partially paid", the system will queue the next reminder to the customer{{#if late_fee_due}} including a late fee of {{late_fee_amount}}{{/if}}.</p>`,
|
||||
body_text: `Time to check on a payment
|
||||
body_text: `Time to check on a payment
|
||||
|
||||
Invoice {{invoice_number}} for {{customer_name}}{{#if event_name}} ({{event_name}}){{/if}} was due on {{due_date}}. Total: {{total_amount}}.
|
||||
|
||||
@@ -250,10 +250,10 @@ Confirm what was received:
|
||||
Not paid yet: {{unpaid_url}}
|
||||
|
||||
Selecting "Not paid yet" or "Partially paid" will queue the customer reminder{{#if late_fee_due}} including a late fee of {{late_fee_amount}}{{/if}}.`,
|
||||
},
|
||||
},
|
||||
de: {
|
||||
subject: 'Zahlung prüfen für Rechnung {{invoice_number}}',
|
||||
body_html: `<h2>Zahlung prüfen</h2>
|
||||
subject: 'Zahlung prüfen für Rechnung {{invoice_number}}',
|
||||
body_html: `<h2>Zahlung prüfen</h2>
|
||||
<p>Rechnung <strong>{{invoice_number}}</strong> für <strong>{{customer_name}}</strong>{{#if event_name}} ({{event_name}}){{/if}} war am <strong>{{due_date}}</strong> fällig. Gesamtbetrag: <strong>{{total_amount}}</strong>.</p>
|
||||
<p>Bitte prüfen Sie auf Ihrem Konto, was eingegangen ist, und klicken Sie unten den passenden Button — kein Login nötig.</p>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin: 24px auto; border-collapse: collapse;">
|
||||
@@ -273,7 +273,7 @@ Selecting "Not paid yet" or "Partially paid" will queue the customer reminder{{#
|
||||
</tr>
|
||||
</table>
|
||||
<p style="font-size: 13px; color: #666;">Bei „Nicht bezahlt" oder „Teilweise bezahlt" wird automatisch die Zahlungserinnerung an den Kunden gesendet{{#if late_fee_due}} inklusive Mahngebühr von {{late_fee_amount}}{{/if}}.</p>`,
|
||||
body_text: `Zahlung prüfen
|
||||
body_text: `Zahlung prüfen
|
||||
|
||||
Rechnung {{invoice_number}} für {{customer_name}}{{#if event_name}} ({{event_name}}){{/if}} war am {{due_date}} fällig. Gesamtbetrag: {{total_amount}}.
|
||||
|
||||
@@ -284,32 +284,32 @@ Bitte bestätigen:
|
||||
Nicht bezahlt: {{unpaid_url}}
|
||||
|
||||
Bei „Nicht bezahlt" oder „Teilweise bezahlt" wird automatisch die Zahlungserinnerung gesendet{{#if late_fee_due}} inklusive Mahngebühr von {{late_fee_amount}}{{/if}}.`,
|
||||
},
|
||||
},
|
||||
},
|
||||
storno_issued: {
|
||||
category: 'billing', feature_flag: 'bills',
|
||||
variables: ['storno_number', 'original_invoice_number', 'original_issue_date', 'customer_name', 'total_amount'],
|
||||
en: {
|
||||
subject: 'Cancellation invoice {{storno_number}} for invoice {{original_invoice_number}}',
|
||||
body_html: `<p>Dear {{customer_name}},</p>
|
||||
subject: 'Cancellation invoice {{storno_number}} for invoice {{original_invoice_number}}',
|
||||
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>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: {
|
||||
subject: 'Stornorechnung {{storno_number}} zu Rechnung {{original_invoice_number}}',
|
||||
body_html: `<p>Sehr geehrte/r {{customer_name}},</p>
|
||||
subject: 'Stornorechnung {{storno_number}} zu Rechnung {{original_invoice_number}}',
|
||||
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>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: {
|
||||
category: 'billing', feature_flag: 'bills',
|
||||
variables: ['invoice_number', 'customer_name', 'event_name', 'total_amount', 'paid_amount', 'paid_at', 'payment_method', 'payment_reference', 'skonto_applied', 'skonto_percent', 'skonto_discount_amount'],
|
||||
en: {
|
||||
subject: 'Payment received: invoice {{invoice_number}}',
|
||||
body_html: `<h2>Payment recorded</h2>
|
||||
subject: 'Payment received: invoice {{invoice_number}}',
|
||||
body_html: `<h2>Payment recorded</h2>
|
||||
<p>Invoice <strong>{{invoice_number}}</strong> for <strong>{{customer_name}}</strong>{{#if event_name}} ({{event_name}}){{/if}} has been marked as fully paid.</p>
|
||||
<table role="presentation" cellpadding="6" cellspacing="0" border="0" style="border-collapse: collapse; margin: 16px 0;">
|
||||
<tr><td style="color: #666;">Total invoice amount</td><td><strong>{{total_amount}}</strong></td></tr>
|
||||
@@ -320,7 +320,7 @@ Bei „Nicht bezahlt" oder „Teilweise bezahlt" wird automatisch die Zahlungser
|
||||
<tr><td style="color: #666;">Recorded at</td><td>{{paid_at}}</td></tr>
|
||||
</table>
|
||||
<p style="font-size: 13px; color: #666;">This is an automatic notification — no action required.</p>`,
|
||||
body_text: `Payment recorded
|
||||
body_text: `Payment recorded
|
||||
|
||||
Invoice {{invoice_number}} for {{customer_name}}{{#if event_name}} ({{event_name}}){{/if}} has been marked as fully paid.
|
||||
|
||||
@@ -332,10 +332,10 @@ Invoice {{invoice_number}} for {{customer_name}}{{#if event_name}} ({{event_name
|
||||
Recorded at: {{paid_at}}
|
||||
|
||||
This is an automatic notification — no action required.`,
|
||||
},
|
||||
},
|
||||
de: {
|
||||
subject: 'Zahlung erhalten: Rechnung {{invoice_number}}',
|
||||
body_html: `<h2>Zahlung erfasst</h2>
|
||||
subject: 'Zahlung erhalten: Rechnung {{invoice_number}}',
|
||||
body_html: `<h2>Zahlung erfasst</h2>
|
||||
<p>Rechnung <strong>{{invoice_number}}</strong> für <strong>{{customer_name}}</strong>{{#if event_name}} ({{event_name}}){{/if}} wurde als vollständig bezahlt markiert.</p>
|
||||
<table role="presentation" cellpadding="6" cellspacing="0" border="0" style="border-collapse: collapse; margin: 16px 0;">
|
||||
<tr><td style="color: #666;">Rechnungsbetrag</td><td><strong>{{total_amount}}</strong></td></tr>
|
||||
@@ -346,7 +346,7 @@ This is an automatic notification — no action required.`,
|
||||
<tr><td style="color: #666;">Erfasst am</td><td>{{paid_at}}</td></tr>
|
||||
</table>
|
||||
<p style="font-size: 13px; color: #666;">Automatische Benachrichtigung — keine Aktion erforderlich.</p>`,
|
||||
body_text: `Zahlung erfasst
|
||||
body_text: `Zahlung erfasst
|
||||
|
||||
Rechnung {{invoice_number}} für {{customer_name}}{{#if event_name}} ({{event_name}}){{/if}} wurde als vollständig bezahlt markiert.
|
||||
|
||||
@@ -358,14 +358,14 @@ Rechnung {{invoice_number}} für {{customer_name}}{{#if event_name}} ({{event_na
|
||||
Erfasst am: {{paid_at}}
|
||||
|
||||
Automatische Benachrichtigung — keine Aktion erforderlich.`,
|
||||
},
|
||||
},
|
||||
},
|
||||
invoice_collections_handoff: {
|
||||
category: 'billing', feature_flag: 'bills',
|
||||
variables: ['invoice_number', 'customer_name', 'customer_email', 'customer_address', 'event_name', 'original_amount', 'late_fee_amount', 'paid_amount', 'outstanding_amount', 'due_date', 'reminder_level'],
|
||||
en: {
|
||||
subject: 'Collections handoff: invoice {{invoice_number}} still unpaid after dunning',
|
||||
body_html: `<h2>Ready to hand to collections</h2>
|
||||
subject: 'Collections handoff: invoice {{invoice_number}} still unpaid after dunning',
|
||||
body_html: `<h2>Ready to hand to collections</h2>
|
||||
<p>Invoice <strong>{{invoice_number}}</strong>{{#if event_name}} ({{event_name}}){{/if}} is still unpaid after {{reminder_level}} reminders. The invoice PDF is attached for forwarding.</p>
|
||||
<table role="presentation" cellpadding="6" cellspacing="0" border="0" style="border-collapse: collapse; margin: 16px 0;">
|
||||
<tr><td style="color:#666;">Customer</td><td><strong>{{customer_name}}</strong></td></tr>
|
||||
@@ -378,7 +378,7 @@ Automatische Benachrichtigung — keine Aktion erforderlich.`,
|
||||
<tr><td style="color:#666;"><strong>Outstanding</strong></td><td><strong>{{outstanding_amount}}</strong></td></tr>
|
||||
</table>
|
||||
<p style="font-size:13px;color:#666;">Forward to your collections agency / for Betreibung. Automatic notification.</p>`,
|
||||
body_text: `Ready to hand to collections
|
||||
body_text: `Ready to hand to collections
|
||||
|
||||
Invoice {{invoice_number}}{{#if event_name}} ({{event_name}}){{/if}} is still unpaid after {{reminder_level}} reminders. The invoice PDF is attached.
|
||||
|
||||
@@ -392,10 +392,10 @@ Invoice {{invoice_number}}{{#if event_name}} ({{event_name}}){{/if}} is still un
|
||||
Outstanding: {{outstanding_amount}}
|
||||
|
||||
Forward to your collections agency / for Betreibung.`,
|
||||
},
|
||||
},
|
||||
de: {
|
||||
subject: 'Inkasso-Übergabe: Rechnung {{invoice_number}} trotz Mahnungen offen',
|
||||
body_html: `<h2>Bereit zur Inkasso-Übergabe</h2>
|
||||
subject: 'Inkasso-Übergabe: Rechnung {{invoice_number}} trotz Mahnungen offen',
|
||||
body_html: `<h2>Bereit zur Inkasso-Übergabe</h2>
|
||||
<p>Rechnung <strong>{{invoice_number}}</strong>{{#if event_name}} ({{event_name}}){{/if}} ist nach {{reminder_level}} Mahnungen weiterhin offen. Das Rechnungs-PDF ist zur Weiterleitung angehängt.</p>
|
||||
<table role="presentation" cellpadding="6" cellspacing="0" border="0" style="border-collapse: collapse; margin: 16px 0;">
|
||||
<tr><td style="color:#666;">Kunde</td><td><strong>{{customer_name}}</strong></td></tr>
|
||||
@@ -408,7 +408,7 @@ Forward to your collections agency / for Betreibung.`,
|
||||
<tr><td style="color:#666;"><strong>Offen</strong></td><td><strong>{{outstanding_amount}}</strong></td></tr>
|
||||
</table>
|
||||
<p style="font-size:13px;color:#666;">Zur Weiterleitung an das Inkasso / für die Betreibung. Automatische Benachrichtigung.</p>`,
|
||||
body_text: `Bereit zur Inkasso-Übergabe
|
||||
body_text: `Bereit zur Inkasso-Übergabe
|
||||
|
||||
Rechnung {{invoice_number}}{{#if event_name}} ({{event_name}}){{/if}} ist nach {{reminder_level}} Mahnungen weiterhin offen. Das Rechnungs-PDF ist angehängt.
|
||||
|
||||
@@ -422,7 +422,7 @@ Rechnung {{invoice_number}}{{#if event_name}} ({{event_name}}){{/if}} ist nach {
|
||||
Offen: {{outstanding_amount}}
|
||||
|
||||
Zur Weiterleitung an das Inkasso / für die Betreibung.`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -23,10 +23,8 @@
|
||||
* — same legal-record discipline as line items today.
|
||||
*/
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { AppError } = require('../utils/errors');
|
||||
const { hasColumnCached } = require('../utils/schemaCache');
|
||||
const logger = require('../utils/logger');
|
||||
const invoiceService = require('./invoiceService');
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -287,7 +285,7 @@ async function createEntry(customerId, payload, adminId) {
|
||||
logInfo = { type: 'hour_entry_logged', meta: { entryId, customerId: customer.id } };
|
||||
return { id: entryId, status: 'unbilled' };
|
||||
});
|
||||
if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) {} }
|
||||
if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) { /* non-fatal */ } }
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -385,7 +383,7 @@ async function updateEntry(entryId, payload, adminId) {
|
||||
logInfo = { type: 'hour_entry_updated', meta: { entryId, customerId: entry.customer_account_id } };
|
||||
return { id: entryId };
|
||||
});
|
||||
if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) {} }
|
||||
if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) { /* non-fatal */ } }
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -436,7 +434,7 @@ async function deleteEntry(entryId, adminId) {
|
||||
logInfo = { type: 'hour_entry_deleted', meta: { entryId, customerId: entry.customer_account_id, hadInvoice: !!entry.invoice_id } };
|
||||
return { deleted: true };
|
||||
});
|
||||
if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) {} }
|
||||
if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) { /* non-fatal */ } }
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -521,7 +519,7 @@ async function billUnbilledEntries(customerId, adminId) {
|
||||
logInfo = { type: 'hour_entries_billed', meta: { customerId: customer.id, invoiceId, entryCount: unbilled.length } };
|
||||
return { invoiceId, entriesBilled: unbilled.length };
|
||||
});
|
||||
if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) {} }
|
||||
if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) { /* non-fatal */ } }
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,6 @@ const { formatBoolean } = require('../utils/dbCompat');
|
||||
const packageJson = require('../../package.json');
|
||||
|
||||
// Constants
|
||||
const CHUNK_SIZE = 1024 * 1024; // 1MB chunks for streaming
|
||||
const PROGRESS_INTERVAL = 100; // Report progress every 100 rows
|
||||
|
||||
// Face recognition tables (#1074). Their SCHEMA is backed up, their CONTENTS
|
||||
// are not: embeddings are biometric data (GDPR Art. 9) and fully derived from
|
||||
@@ -183,7 +181,7 @@ class DatabaseBackupService {
|
||||
/**
|
||||
* Create SQLite backup
|
||||
*/
|
||||
async createSQLiteBackup(outputPath, options = {}) {
|
||||
async createSQLiteBackup(outputPath, _options = {}) {
|
||||
const dbPath = knexConfig.connection.filename;
|
||||
const tempPath = `${outputPath}.tmp`;
|
||||
|
||||
@@ -214,7 +212,7 @@ class DatabaseBackupService {
|
||||
// works out that a manual re-scan is needed. Requeue instead.
|
||||
await spawnAsync('sqlite3', [
|
||||
tempPath,
|
||||
"UPDATE photos SET face_status = CASE WHEN face_status IS NULL THEN NULL ELSE 'pending' END, "
|
||||
'UPDATE photos SET face_status = CASE WHEN face_status IS NULL THEN NULL ELSE \'pending\' END, '
|
||||
+ 'face_count = NULL, face_started_at = NULL, face_error = NULL;',
|
||||
]).catch(() => {});
|
||||
// FATAL, not a warning. Deleting rows leaves their pages in the file
|
||||
@@ -337,7 +335,7 @@ class DatabaseBackupService {
|
||||
/**
|
||||
* Validate backup integrity
|
||||
*/
|
||||
async validateBackup(backupPath, originalChecksums) {
|
||||
async validateBackup(backupPath, _originalChecksums) {
|
||||
const tempDbPath = `${backupPath}.validate`;
|
||||
|
||||
try {
|
||||
@@ -747,7 +745,7 @@ class DatabaseBackupService {
|
||||
/**
|
||||
* Restore from backup (with version checking)
|
||||
*/
|
||||
async restore(backupPath, options = {}) {
|
||||
async restore(backupPath, _options = {}) {
|
||||
// This is a dangerous operation and should be used with extreme caution
|
||||
throw new Error('Restore functionality not implemented for safety. Please use restore service or restore manually.');
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ async function getRecipientLanguage(email, eventId = null) {
|
||||
.first();
|
||||
if (langSetting && langSetting.setting_value) {
|
||||
let lang = langSetting.setting_value;
|
||||
try { lang = JSON.parse(lang); } catch (_) {}
|
||||
try { lang = JSON.parse(lang); } catch (_) { /* non-fatal */ }
|
||||
if (typeof lang === 'string' && lang.trim()) return lang.trim();
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -789,13 +789,13 @@ async function sendTemplateEmail(to, templateKey, variables) {
|
||||
: undefined;
|
||||
const attachments = Array.isArray(variables.attachments)
|
||||
? variables.attachments
|
||||
.filter((a) => a && (a.contentPath || a.path || a.content))
|
||||
.map((a) => ({
|
||||
filename: a.filename,
|
||||
path: a.contentPath || a.path,
|
||||
content: a.content,
|
||||
contentType: a.contentType,
|
||||
}))
|
||||
.filter((a) => a && (a.contentPath || a.path || a.content))
|
||||
.map((a) => ({
|
||||
filename: a.filename,
|
||||
path: a.contentPath || a.path,
|
||||
content: a.content,
|
||||
contentType: a.contentType,
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
// Send email
|
||||
@@ -872,7 +872,7 @@ async function sendRawEmail({ to, cc, subject, html, text, attachments, accountK
|
||||
const ccList = Array.isArray(cc) ? cc.filter(Boolean) : (cc ? [cc] : undefined);
|
||||
const atts = Array.isArray(attachments)
|
||||
? attachments.filter((a) => a && (a.contentPath || a.path || a.content))
|
||||
.map((a) => ({ filename: a.filename, path: a.contentPath || a.path, content: a.content, contentType: a.contentType }))
|
||||
.map((a) => ({ filename: a.filename, path: a.contentPath || a.path, content: a.content, contentType: a.contentType }))
|
||||
: undefined;
|
||||
const mail = {
|
||||
from: `${fromName || 'picpeak'} <${fromEmail}>`,
|
||||
|
||||
@@ -63,8 +63,6 @@ const { ensureEventReminderTemplatesSeeded } = require('./eventReminderTemplates
|
||||
|
||||
const DEFAULT_DAYS_BEFORE = 2;
|
||||
const DEFAULT_TEMPLATE_GROUP = 'event_reminder';
|
||||
const TEMPLATE_KEY_DEFAULT = 'event_reminder_default';
|
||||
const TEMPLATE_KEY_PREFIX = 'event_reminder_';
|
||||
|
||||
// One-shot guard: the "schema not migrated" warn would otherwise fire
|
||||
// once per cron tick (≈ hourly) on installs that haven't applied
|
||||
|
||||
@@ -37,8 +37,8 @@ const VARIABLES = [
|
||||
|
||||
// Tiny HTML signature line shared across templates so the maintainer
|
||||
// only has to brand once. Variables substitute at render time.
|
||||
const SIGNATURE_EN = `<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_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 EVENT_REMINDER_TEMPLATES = {
|
||||
event_reminder_default: {
|
||||
@@ -54,7 +54,7 @@ const EVENT_REMINDER_TEMPLATES = {
|
||||
</ul>
|
||||
<p>If anything has changed since we last spoke, just hit reply.</p>
|
||||
${SIGNATURE_EN}`,
|
||||
body_text: `Hi {{customer_name}},\n\nJust a quick reminder that {{event_name}} is coming up on {{event_date}} — about {{days_before}} day(s) from now.\n\nA few things that help us hit the ground running on the day:\n- Confirm the exact start time and address.\n- Let us know if there is anything we should keep an eye on (VIPs, surprise moments, restricted areas).\n- Indoor venues: a small corner for equipment setup is a huge help.\n\nIf anything has changed since we last spoke, just hit reply.\n\nSee you soon,\n{{business_name}}`,
|
||||
body_text: 'Hi {{customer_name}},\n\nJust a quick reminder that {{event_name}} is coming up on {{event_date}} — about {{days_before}} day(s) from now.\n\nA few things that help us hit the ground running on the day:\n- Confirm the exact start time and address.\n- Let us know if there is anything we should keep an eye on (VIPs, surprise moments, restricted areas).\n- Indoor venues: a small corner for equipment setup is a huge help.\n\nIf anything has changed since we last spoke, just hit reply.\n\nSee you soon,\n{{business_name}}',
|
||||
},
|
||||
de: {
|
||||
subject: 'Erinnerung: {{event_name}} in {{days_before}} Tag(en)',
|
||||
@@ -68,7 +68,7 @@ ${SIGNATURE_EN}`,
|
||||
</ul>
|
||||
<p>Hat sich seit unserem letzten Austausch etwas geändert? Einfach kurz auf diese Mail antworten.</p>
|
||||
${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>
|
||||
<p>If anything has shifted since we last spoke — even small things — just hit reply.</p>
|
||||
${SIGNATURE_EN}`,
|
||||
body_text: `Dear {{customer_name}},\n\nYour wedding day is almost here — {{event_date}}, in about {{days_before}} day(s). We are very much looking forward to it.\n\nA short pre-day checklist so the photo coverage flows smoothly:\n- Timeline: a rough hour-by-hour run-of-day helps us anticipate every moment.\n- Family shots: a short list of must-have group photos (with names) keeps the formals quick.\n- Getting-ready space: a room with natural light makes a real difference.\n- Surprises: let us know so we are in the right place — and won't spoil them.\n- Logistics: ceremony start time, venue address, parking notes, coordinator contact.\n\nIf anything has shifted since we last spoke, just hit reply.\n\nSee you soon,\n{{business_name}}`,
|
||||
body_text: 'Dear {{customer_name}},\n\nYour wedding day is almost here — {{event_date}}, in about {{days_before}} day(s). We are very much looking forward to it.\n\nA short pre-day checklist so the photo coverage flows smoothly:\n- Timeline: a rough hour-by-hour run-of-day helps us anticipate every moment.\n- Family shots: a short list of must-have group photos (with names) keeps the formals quick.\n- Getting-ready space: a room with natural light makes a real difference.\n- Surprises: let us know so we are in the right place — and won\'t spoil them.\n- Logistics: ceremony start time, venue address, parking notes, coordinator contact.\n\nIf anything has shifted since we last spoke, just hit reply.\n\nSee you soon,\n{{business_name}}',
|
||||
},
|
||||
de: {
|
||||
subject: 'Eure Hochzeit am {{event_date}} — letzte Details',
|
||||
@@ -103,7 +103,7 @@ ${SIGNATURE_EN}`,
|
||||
</ul>
|
||||
<p>Hat sich seit unserem letzten Gespräch etwas verschoben — auch Kleinigkeiten? Einfach kurz antworten.</p>
|
||||
${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>
|
||||
<p>Looking forward to celebrating — let us know if anything has changed.</p>
|
||||
${SIGNATURE_EN}`,
|
||||
body_text: `Hi {{customer_name}},\n\n{{event_name}} is coming up on {{event_date}} — about {{days_before}} day(s) away. Quick check-in:\n- Headcount: roughly how many guests?\n- Schedule: when is the cake/song moment?\n- Theme or dress code, if any.\n- Surprises we should keep quiet about?\n\nLooking forward to celebrating — let us know if anything has changed.\n\nSee you soon,\n{{business_name}}`,
|
||||
body_text: 'Hi {{customer_name}},\n\n{{event_name}} is coming up on {{event_date}} — about {{days_before}} day(s) away. Quick check-in:\n- Headcount: roughly how many guests?\n- Schedule: when is the cake/song moment?\n- Theme or dress code, if any.\n- Surprises we should keep quiet about?\n\nLooking forward to celebrating — let us know if anything has changed.\n\nSee you soon,\n{{business_name}}',
|
||||
},
|
||||
de: {
|
||||
subject: '{{event_name}} am {{event_date}} — kurze Rückfrage',
|
||||
@@ -134,7 +134,7 @@ ${SIGNATURE_EN}`,
|
||||
</ul>
|
||||
<p>Wir freuen uns auf das Fest — kurz Bescheid geben, falls sich etwas geändert hat.</p>
|
||||
${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>
|
||||
<p>Happy to jump on a 10-min call beforehand if it is easier than email.</p>
|
||||
${SIGNATURE_EN}`,
|
||||
body_text: `Dear {{customer_name}},\n\n{{event_name}} is on {{event_date}} — about {{days_before}} day(s) away. To make sure the coverage matches your goals, a few items to confirm:\n- Shot brief: internal comms, press kit, social, website?\n- Agenda / run-of-show: speakers, awards, panels, Q&A.\n- VIPs & brand: names to prioritise, plus logo/colour direction.\n- Access: entrance, loading dock, on-site contact. Photo ID needed?\n- Confidentiality: any no-photo sessions?\n- Delivery: rough turnaround (24h press selects, full gallery later)?\n\nHappy to jump on a 10-min call beforehand if it is easier than email.\n\nSee you soon,\n{{business_name}}`,
|
||||
body_text: 'Dear {{customer_name}},\n\n{{event_name}} is on {{event_date}} — about {{days_before}} day(s) away. To make sure the coverage matches your goals, a few items to confirm:\n- Shot brief: internal comms, press kit, social, website?\n- Agenda / run-of-show: speakers, awards, panels, Q&A.\n- VIPs & brand: names to prioritise, plus logo/colour direction.\n- Access: entrance, loading dock, on-site contact. Photo ID needed?\n- Confidentiality: any no-photo sessions?\n- Delivery: rough turnaround (24h press selects, full gallery later)?\n\nHappy to jump on a 10-min call beforehand if it is easier than email.\n\nSee you soon,\n{{business_name}}',
|
||||
},
|
||||
de: {
|
||||
subject: 'Vorbereitung Bildbegleitung: {{event_name}} am {{event_date}}',
|
||||
@@ -169,7 +169,7 @@ ${SIGNATURE_EN}`,
|
||||
</ul>
|
||||
<p>Falls eine kurze 10-Min-Abstimmung einfacher ist als E-Mail, gerne jederzeit melden.</p>
|
||||
${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>
|
||||
<p>If anything has changed since we last spoke, hit reply.</p>
|
||||
${SIGNATURE_EN}`,
|
||||
body_text: `Hi {{customer_name}},\n\n{{event_name}} is on {{event_date}} — about {{days_before}} day(s) away. A short prep note:\n- Start time & address: please confirm both.\n- Run-of-day: a rough timeline of the key moments.\n- Setup space: a small corner for gear if indoors.\n- Anything specific: people to prioritise, things to avoid, dress code, surprises.\n\nIf anything has changed, just hit reply.\n\nSee you soon,\n{{business_name}}`,
|
||||
body_text: 'Hi {{customer_name}},\n\n{{event_name}} is on {{event_date}} — about {{days_before}} day(s) away. A short prep note:\n- Start time & address: please confirm both.\n- Run-of-day: a rough timeline of the key moments.\n- Setup space: a small corner for gear if indoors.\n- Anything specific: people to prioritise, things to avoid, dress code, surprises.\n\nIf anything has changed, just hit reply.\n\nSee you soon,\n{{business_name}}',
|
||||
},
|
||||
de: {
|
||||
subject: '{{event_name}} am {{event_date}} — Vorbereitungs-Hinweise',
|
||||
@@ -200,7 +200,7 @@ ${SIGNATURE_EN}`,
|
||||
</ul>
|
||||
<p>Hat sich seit dem letzten Austausch etwas geändert? Einfach kurz antworten.</p>
|
||||
${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
|
||||
*/
|
||||
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { db } = require('../database/db');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const logger = require('../utils/logger');
|
||||
@@ -228,7 +228,7 @@ class EventRenameService {
|
||||
const event = await trx('events').where({ id: eventId }).first();
|
||||
|
||||
// Generate new share link
|
||||
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({
|
||||
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({
|
||||
slug: newSlug,
|
||||
shareToken: event.share_token
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ async function getById(id) {
|
||||
return row;
|
||||
}
|
||||
|
||||
async function create({ name, color, displayOrder }, adminId) {
|
||||
async function create({ name, color, displayOrder }, _adminId) {
|
||||
if (!name || !String(name).trim()) {
|
||||
throw new AppError('Category name is required', 400, 'NAME_REQUIRED');
|
||||
}
|
||||
|
||||
@@ -61,27 +61,23 @@ async function list(relativePath = '') {
|
||||
const targetDir = safePathJoin(root, relativePath || '.');
|
||||
|
||||
const entries = [];
|
||||
try {
|
||||
const dirents = await fs.readdir(targetDir, { withFileTypes: true });
|
||||
for (const d of dirents) {
|
||||
// Skip hidden files and directories
|
||||
if (d.name.startsWith('.')) continue;
|
||||
const full = path.join(targetDir, d.name);
|
||||
const stat = await fs.stat(full).catch(() => null);
|
||||
if (!stat) continue;
|
||||
// Errors propagate to the caller to handle (e.g. invalid path).
|
||||
const dirents = await fs.readdir(targetDir, { withFileTypes: true });
|
||||
for (const d of dirents) {
|
||||
// Skip hidden files and directories
|
||||
if (d.name.startsWith('.')) continue;
|
||||
const full = path.join(targetDir, d.name);
|
||||
const stat = await fs.stat(full).catch(() => null);
|
||||
if (!stat) continue;
|
||||
|
||||
if (d.isDirectory()) {
|
||||
entries.push({ name: d.name, type: 'dir' });
|
||||
} else if (d.isFile()) {
|
||||
const ext = path.extname(d.name).toLowerCase();
|
||||
if (['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) {
|
||||
entries.push({ name: d.name, type: 'file', size: stat.size, mtime: stat.mtime });
|
||||
}
|
||||
if (d.isDirectory()) {
|
||||
entries.push({ name: d.name, type: 'dir' });
|
||||
} else if (d.isFile()) {
|
||||
const ext = path.extname(d.name).toLowerCase();
|
||||
if (['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) {
|
||||
entries.push({ name: d.name, type: 'file', size: stat.size, mtime: stat.mtime });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Propagate errors for caller to handle (e.g., invalid path)
|
||||
throw e;
|
||||
}
|
||||
|
||||
const rootResolved = path.resolve(root);
|
||||
|
||||
@@ -1172,32 +1172,32 @@ class FeedbackService {
|
||||
if (!entry.guest_email && row.guest_email) entry.guest_email = row.guest_email;
|
||||
|
||||
switch (row.feedback_type) {
|
||||
case 'favorite':
|
||||
entry.is_favorited = true;
|
||||
break;
|
||||
case 'like':
|
||||
entry.is_liked = true;
|
||||
break;
|
||||
case 'rating':
|
||||
if (row.rating != null) entry.star_rating = row.rating;
|
||||
break;
|
||||
case 'comment':
|
||||
if (row.comment_text) {
|
||||
// Most recent comment wins. Older comments from the same guest
|
||||
// on the same photo are dropped — the export is "current state",
|
||||
// not the comment history.
|
||||
entry.comment = row.comment_text;
|
||||
}
|
||||
break;
|
||||
case 'reaction':
|
||||
if (row.reaction) entry.reaction = row.reaction;
|
||||
break;
|
||||
case 'color_label':
|
||||
if (row.color_label) entry.color_label = row.color_label;
|
||||
break;
|
||||
default:
|
||||
// Unknown feedback type — ignore so a future type doesn't break the export.
|
||||
break;
|
||||
case 'favorite':
|
||||
entry.is_favorited = true;
|
||||
break;
|
||||
case 'like':
|
||||
entry.is_liked = true;
|
||||
break;
|
||||
case 'rating':
|
||||
if (row.rating != null) entry.star_rating = row.rating;
|
||||
break;
|
||||
case 'comment':
|
||||
if (row.comment_text) {
|
||||
// Most recent comment wins. Older comments from the same guest
|
||||
// on the same photo are dropped — the export is "current state",
|
||||
// not the comment history.
|
||||
entry.comment = row.comment_text;
|
||||
}
|
||||
break;
|
||||
case 'reaction':
|
||||
if (row.reaction) entry.reaction = row.reaction;
|
||||
break;
|
||||
case 'color_label':
|
||||
if (row.color_label) entry.color_label = row.color_label;
|
||||
break;
|
||||
default:
|
||||
// Unknown feedback type — ignore so a future type doesn't break the export.
|
||||
break;
|
||||
}
|
||||
// Track the latest action timestamp across all feedback types.
|
||||
if (row.created_at && entry.latest_at && row.created_at > entry.latest_at) {
|
||||
|
||||
@@ -40,7 +40,7 @@ function startFileWatcher() {
|
||||
}
|
||||
|
||||
const watcher = chokidar.watch(WATCH_PATH(), {
|
||||
ignored: /(^|[\/\\])\../, // ignore dotfiles
|
||||
ignored: /(^|[/\\])\../, // ignore dotfiles
|
||||
persistent: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 2000,
|
||||
|
||||
@@ -166,7 +166,7 @@ async function scanRoot(rootAbs) {
|
||||
if (result.has(lc)) {
|
||||
logger.warn(
|
||||
`[fonts] Duplicate family ${family.family} within ${rootAbs}; ` +
|
||||
`keeping the first encountered folder`
|
||||
'keeping the first encountered folder'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -306,7 +306,7 @@ async function createInvoice(payload, adminId, trx = db) {
|
||||
await insertLineItemsHierarchical(trx, 'invoice_line_items', 'invoice_id', invoiceId, items);
|
||||
}
|
||||
|
||||
try { await logActivity('invoice_created', { invoiceId, invoiceNumber }, payload.eventId || null, `admin:${adminId}`, trx); } catch (_) {}
|
||||
try { await logActivity('invoice_created', { invoiceId, invoiceNumber }, payload.eventId || null, `admin:${adminId}`, trx); } catch (_) { /* non-fatal */ }
|
||||
return { invoiceIds: [invoiceId] };
|
||||
}
|
||||
|
||||
@@ -568,7 +568,7 @@ async function spawnInstallmentInvoices({ trx, eventId, quoteId, customer, curre
|
||||
// pool (this runs unattended from the booking flow's prepare_invoice).
|
||||
await logActivity('invoice_scheduled', { invoiceId, invoiceNumber, eventId, quoteId, scheduledSendAt },
|
||||
eventId, `admin:${adminId}`, trx);
|
||||
} catch (_) {}
|
||||
} catch (_) { /* non-fatal */ }
|
||||
invoiceIds.push(invoiceId);
|
||||
}
|
||||
return { invoiceIds };
|
||||
|
||||
@@ -221,7 +221,7 @@ async function appendToMonthlyDraft(payload, customer, adminId, trx) {
|
||||
await logActivity('monthly_billing_items_queued',
|
||||
{ invoiceId: draft.id, customerId: customer.id, itemsAdded: newItems.length },
|
||||
null, `admin:${adminId}`);
|
||||
} catch (_) {}
|
||||
} catch (_) { /* non-fatal */ }
|
||||
|
||||
return draft.id;
|
||||
}
|
||||
|
||||
@@ -347,7 +347,7 @@ async function updateInstallmentPlan({ trx, dealUuid, installments, adminId }) {
|
||||
await logActivity('invoice_scheduled', {
|
||||
invoiceId: newId, invoiceNumber, eventId: sample.event_id, source: 'plan_reshape',
|
||||
}, sample.event_id, `admin:${adminId}`);
|
||||
} catch (_) {}
|
||||
} catch (_) { /* non-fatal */ }
|
||||
|
||||
created.push(newId);
|
||||
}
|
||||
@@ -365,7 +365,7 @@ async function updateInstallmentPlan({ trx, dealUuid, installments, adminId }) {
|
||||
dealUuid, newCount,
|
||||
kept: kept.length, created: created.length, deleted: deleted.length,
|
||||
}, sample.event_id, `admin:${adminId}`);
|
||||
} catch (_) {}
|
||||
} catch (_) { /* non-fatal */ }
|
||||
|
||||
return {
|
||||
invoiceIds: [...kept, ...created],
|
||||
|
||||
@@ -88,7 +88,7 @@ async function markPaid(id, { amountMinor, paidAt, paymentMethod, reference, not
|
||||
|
||||
try { await logActivity(isFull ? 'invoice_paid' : 'invoice_partial_payment',
|
||||
{ invoiceId: id, amountMinor: amount, totalPaidMinor: total },
|
||||
invoice.event_id || null, `admin:${adminId}`); } catch (_) {}
|
||||
invoice.event_id || null, `admin:${adminId}`); } catch (_) { /* non-fatal */ }
|
||||
|
||||
// Migration 127 — admin payment-received notification. Fires only
|
||||
// on the transition into 'paid' so admins don't get duplicate
|
||||
@@ -134,7 +134,7 @@ async function markPaid(id, { amountMinor, paidAt, paymentMethod, reference, not
|
||||
paidTotalMinor: markResult.paidTotalMinor,
|
||||
},
|
||||
});
|
||||
} catch (_) {}
|
||||
} catch (_) { /* non-fatal */ }
|
||||
}
|
||||
return markResult;
|
||||
}
|
||||
@@ -203,7 +203,7 @@ async function queueInvoicePaidAdminNotification({
|
||||
try {
|
||||
await logActivity('invoice_paid_admin_notified', { invoiceId: invoice.id },
|
||||
invoice.event_id || null, 'system');
|
||||
} catch (_) {}
|
||||
} catch (_) { /* non-fatal */ }
|
||||
}
|
||||
|
||||
async function queuePaymentCheckEmail(invoiceId, { skipThrottle = false } = {}) {
|
||||
@@ -318,7 +318,7 @@ async function queuePaymentCheckEmail(invoiceId, { skipThrottle = false } = {})
|
||||
try {
|
||||
await logActivity('invoice_payment_check_sent', { invoiceId, token: token.slice(0, 8) },
|
||||
invoice.event_id || null, 'scheduler');
|
||||
} catch (_) {}
|
||||
} catch (_) { /* non-fatal */ }
|
||||
|
||||
return { token, sent: true };
|
||||
}
|
||||
@@ -449,7 +449,7 @@ async function recordPaymentCheckAction({ token, action, amountMinor, ip, adminI
|
||||
{ invoiceId: invoice.id, action, amountMinor: amountMinor || null },
|
||||
invoice.event_id || null,
|
||||
adminId ? `admin:${adminId}` : 'public:payment-check');
|
||||
} catch (_) {}
|
||||
} catch (_) { /* non-fatal */ }
|
||||
|
||||
// --- Apply the action -----------------------------------------
|
||||
if (action === 'paid_full') {
|
||||
|
||||
@@ -117,7 +117,7 @@ async function applyReminder(invoice, lineItems, level, adminId) {
|
||||
currency: invoice.currency,
|
||||
},
|
||||
});
|
||||
} catch (_) {}
|
||||
} catch (_) { /* non-fatal */ }
|
||||
|
||||
// Render the MAHNUNG (reminder letter). The original invoice PDF is left
|
||||
// UNTOUCHED (immutable). The Mahnung reuses the invoice layout via a
|
||||
@@ -180,7 +180,7 @@ async function applyReminder(invoice, lineItems, level, adminId) {
|
||||
try {
|
||||
await logActivity('invoice_reminder_sent', { invoiceId: invoice.id, level, lateFeeMinor: lateFeeGross },
|
||||
invoice.event_id || null, `admin:${adminId || 'system'}`);
|
||||
} catch (_) {}
|
||||
} catch (_) { /* non-fatal */ }
|
||||
|
||||
return { level, lateFeeMinor: lateFeeGross };
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ async function runScheduledTasks() {
|
||||
await logActivity('monthly_bill_skipped_empty',
|
||||
{ invoiceId: draft.id, customerId: draft.customer_account_id },
|
||||
null, 'scheduler');
|
||||
} catch (_) {}
|
||||
} catch (_) { /* non-fatal */ }
|
||||
continue;
|
||||
}
|
||||
// Arm for the flush pass: clear the draft flag, set the send
|
||||
@@ -95,7 +95,7 @@ async function runScheduledTasks() {
|
||||
{ invoiceId: draft.id, customerId: draft.customer_account_id,
|
||||
periodEnd: draft.monthly_period_end },
|
||||
null, 'scheduler');
|
||||
} catch (_) {}
|
||||
} catch (_) { /* non-fatal */ }
|
||||
} catch (err) {
|
||||
logger.error('Monthly bill issuance failed', { invoiceId: draft.id, err: err.message });
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ async function sendInvoice(id, adminId, options = {}) {
|
||||
attachments: invoiceAttachments,
|
||||
});
|
||||
|
||||
try { await logActivity('invoice_sent', { invoiceId: id }, invoice.event_id || null, `admin:${adminId}`); } catch (_) {}
|
||||
try { await logActivity('invoice_sent', { invoiceId: id }, invoice.event_id || null, `admin:${adminId}`); } catch (_) { /* non-fatal */ }
|
||||
|
||||
// Fire the workflow engine's invoice.sent trigger (after the row is updated +
|
||||
// the email queued). Idempotent per invoice id; no-op when the workflows flag
|
||||
@@ -180,7 +180,7 @@ async function sendInvoice(id, adminId, options = {}) {
|
||||
currency: invoice.currency,
|
||||
},
|
||||
});
|
||||
} catch (_) {}
|
||||
} catch (_) { /* non-fatal */ }
|
||||
|
||||
return { sent: true, pdfPath };
|
||||
}
|
||||
@@ -355,7 +355,7 @@ async function createStorno(originalId, adminId, trx = db) {
|
||||
await logActivity('invoice_cancelled_via_storno',
|
||||
{ invoiceId: originalId, stornoId, stornoNumber },
|
||||
original.event_id || null, `admin:${adminId}`, trx);
|
||||
} catch (_) {}
|
||||
} catch (_) { /* non-fatal */ }
|
||||
|
||||
return stornoId;
|
||||
}
|
||||
@@ -430,7 +430,7 @@ async function sendStorno(stornoId, adminId) {
|
||||
await logActivity('storno_sent',
|
||||
{ stornoId, stornoNumber: storno.invoice_number, originalInvoiceId: storno.cancels_invoice_id || null },
|
||||
storno.event_id || null, `admin:${adminId || 'system'}`);
|
||||
} catch (_) {}
|
||||
} catch (_) { /* non-fatal */ }
|
||||
|
||||
return { status: 'sent', stornoId };
|
||||
}
|
||||
@@ -558,7 +558,7 @@ async function reissueInvoice(id, adminId) {
|
||||
await logActivity('invoice_reissued',
|
||||
{ originalInvoiceId: id, newInvoiceId: newId, stornoId },
|
||||
original.event_id || null, `admin:${adminId}`, trx);
|
||||
} catch (_) {}
|
||||
} catch (_) { /* non-fatal */ }
|
||||
|
||||
return { id: newId, replaces: id, stornoId };
|
||||
});
|
||||
@@ -592,7 +592,7 @@ async function releaseForDelivery(id, adminId) {
|
||||
});
|
||||
try {
|
||||
await logActivity('invoice_released_for_delivery', { invoiceId: id }, invoice.event_id || null, `admin:${adminId}`);
|
||||
} catch (_) {}
|
||||
} catch (_) { /* non-fatal */ }
|
||||
// Fire immediately rather than waiting for the next scheduler
|
||||
// tick — admin clicked the button because they want it out now.
|
||||
return await sendInvoice(id, adminId);
|
||||
@@ -646,7 +646,7 @@ async function cancelInvoice(id, adminId) {
|
||||
await logActivity('invoice_cancelled',
|
||||
{ invoiceId: id, viaStorno: false },
|
||||
invoice.event_id || null, `admin:${adminId}`);
|
||||
} catch (_) {}
|
||||
} catch (_) { /* non-fatal */ }
|
||||
return { cancelled: true, stornoId: null };
|
||||
}
|
||||
|
||||
@@ -690,7 +690,7 @@ async function triggerMonthlyBillNow(customerId, adminId) {
|
||||
await logActivity('monthly_bill_triggered_manually',
|
||||
{ invoiceId: draft.id, customerId, periodEnd: draft.monthly_period_end },
|
||||
null, `admin:${adminId}`);
|
||||
} catch (_) {}
|
||||
} catch (_) { /* non-fatal */ }
|
||||
|
||||
// Inline send so admin gets immediate feedback (PDF stored, status
|
||||
// flipped to 'sent', email queued). A failure here doesn't roll
|
||||
|
||||
+405
-414
@@ -527,15 +527,6 @@ function drawTitle(doc, title, x, y) {
|
||||
return doc.y + 8;
|
||||
}
|
||||
|
||||
function drawDate(doc, label, value, x, y, width) {
|
||||
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#000');
|
||||
const right = x + width;
|
||||
const labelWidth = 80;
|
||||
doc.text(`${label}:`, right - labelWidth - 80, y, { width: 80, align: 'right' });
|
||||
doc.text(value, right - 80, y, { width: 80, align: 'right' });
|
||||
return doc.y + 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the line-items table via swissqrbill's Table helper. We supply
|
||||
* widths in points; the helper draws the borderless layout the
|
||||
@@ -667,20 +658,20 @@ function drawLineItems(doc, ctx) {
|
||||
borderWidth: [0, 0, 0, 0],
|
||||
columns: showDiscount
|
||||
? [
|
||||
{ text: posLabel, width: widths[0], align: 'left' },
|
||||
{ text: descText, width: widths[1], align: 'left', color: numericColor },
|
||||
{ text: stripTrailingZeros(li.quantity), width: widths[2], align: 'right', color: numericColor },
|
||||
{ text: subItemPriceless ? '' : `${stripTrailingZeros(li.discountPercent)}%`, width: widths[3], align: 'right', color: numericColor },
|
||||
{ text: unitText, width: widths[4], align: 'right', color: numericColor },
|
||||
{ text: lineTotalText, width: widths[5], align: 'right', color: numericColor },
|
||||
]
|
||||
{ text: posLabel, width: widths[0], align: 'left' },
|
||||
{ text: descText, width: widths[1], align: 'left', color: numericColor },
|
||||
{ text: stripTrailingZeros(li.quantity), width: widths[2], align: 'right', color: numericColor },
|
||||
{ text: subItemPriceless ? '' : `${stripTrailingZeros(li.discountPercent)}%`, width: widths[3], align: 'right', color: numericColor },
|
||||
{ text: unitText, width: widths[4], align: 'right', color: numericColor },
|
||||
{ text: lineTotalText, width: widths[5], align: 'right', color: numericColor },
|
||||
]
|
||||
: [
|
||||
{ text: posLabel, width: widths[0], align: 'left' },
|
||||
{ text: descText, width: widths[1], align: 'left', color: numericColor },
|
||||
{ text: stripTrailingZeros(li.quantity), width: widths[2], align: 'right', color: numericColor },
|
||||
{ text: unitText, width: widths[3], align: 'right', color: numericColor },
|
||||
{ text: lineTotalText, width: widths[4], align: 'right', color: numericColor },
|
||||
],
|
||||
{ text: posLabel, width: widths[0], align: 'left' },
|
||||
{ text: descText, width: widths[1], align: 'left', color: numericColor },
|
||||
{ text: stripTrailingZeros(li.quantity), width: widths[2], align: 'right', color: numericColor },
|
||||
{ text: unitText, width: widths[3], align: 'right', color: numericColor },
|
||||
{ text: lineTotalText, width: widths[4], align: 'right', color: numericColor },
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -697,20 +688,20 @@ function drawLineItems(doc, ctx) {
|
||||
borderWidth: [0, 0, 0, 0],
|
||||
columns: showDiscount
|
||||
? [
|
||||
{ text: '', width: widths[0], align: 'left' },
|
||||
{ text, width: widths[1], align: 'left', color: '#666', fontName: 'Helvetica-Oblique' },
|
||||
{ text: '', width: widths[2], align: 'right' },
|
||||
{ text: '', width: widths[3], align: 'right' },
|
||||
{ text: '', width: widths[4], align: 'right' },
|
||||
{ text: '', width: widths[5], align: 'right' },
|
||||
]
|
||||
{ text: '', width: widths[0], align: 'left' },
|
||||
{ text, width: widths[1], align: 'left', color: '#666', fontName: 'Helvetica-Oblique' },
|
||||
{ text: '', width: widths[2], align: 'right' },
|
||||
{ text: '', width: widths[3], align: 'right' },
|
||||
{ text: '', width: widths[4], align: 'right' },
|
||||
{ text: '', width: widths[5], align: 'right' },
|
||||
]
|
||||
: [
|
||||
{ text: '', width: widths[0], align: 'left' },
|
||||
{ text, width: widths[1], align: 'left', color: '#666', fontName: 'Helvetica-Oblique' },
|
||||
{ text: '', width: widths[2], align: 'right' },
|
||||
{ text: '', width: widths[3], align: 'right' },
|
||||
{ text: '', width: widths[4], align: 'right' },
|
||||
],
|
||||
{ text: '', width: widths[0], align: 'left' },
|
||||
{ text, width: widths[1], align: 'left', color: '#666', fontName: 'Helvetica-Oblique' },
|
||||
{ text: '', width: widths[2], align: 'right' },
|
||||
{ text: '', width: widths[3], align: 'right' },
|
||||
{ text: '', width: widths[4], align: 'right' },
|
||||
],
|
||||
});
|
||||
|
||||
const headerRow = {
|
||||
@@ -724,20 +715,20 @@ function drawLineItems(doc, ctx) {
|
||||
header: true,
|
||||
columns: showDiscount
|
||||
? [
|
||||
{ text: labels.pos, width: widths[0], align: 'left' },
|
||||
{ text: labels.desc, width: widths[1], align: 'left' },
|
||||
{ text: labels.qty, width: widths[2], align: 'right' },
|
||||
{ text: labels.disc, width: widths[3], align: 'right' },
|
||||
{ text: labels.unit, width: widths[4], align: 'right' },
|
||||
{ text: labels.total, width: widths[5], align: 'right' },
|
||||
]
|
||||
{ text: labels.pos, width: widths[0], align: 'left' },
|
||||
{ text: labels.desc, width: widths[1], align: 'left' },
|
||||
{ text: labels.qty, width: widths[2], align: 'right' },
|
||||
{ text: labels.disc, width: widths[3], align: 'right' },
|
||||
{ text: labels.unit, width: widths[4], align: 'right' },
|
||||
{ text: labels.total, width: widths[5], align: 'right' },
|
||||
]
|
||||
: [
|
||||
{ text: labels.pos, width: widths[0], align: 'left' },
|
||||
{ text: labels.desc, width: widths[1], align: 'left' },
|
||||
{ text: labels.qty, width: widths[2], align: 'right' },
|
||||
{ text: labels.unit, width: widths[3], align: 'right' },
|
||||
{ text: labels.total, width: widths[4], align: 'right' },
|
||||
],
|
||||
{ text: labels.pos, width: widths[0], align: 'left' },
|
||||
{ text: labels.desc, width: widths[1], align: 'left' },
|
||||
{ text: labels.qty, width: widths[2], align: 'right' },
|
||||
{ text: labels.unit, width: widths[3], align: 'right' },
|
||||
{ text: labels.total, width: widths[4], align: 'right' },
|
||||
],
|
||||
};
|
||||
|
||||
// Group rows so a parent + its sub-items + every involved details_text
|
||||
@@ -1427,388 +1418,388 @@ function renderDocument(type, context) {
|
||||
// Errors from the IIFE bubble up via reject(); the doc 'end'
|
||||
// event still resolves the outer Promise once writes flush.
|
||||
(async () => {
|
||||
try {
|
||||
const ctx = normaliseContext(type, context);
|
||||
const doc = new PDFDocument({
|
||||
size: 'A4',
|
||||
// bufferPages: true keeps every page open in memory after
|
||||
// they're emitted so we can switch back and stamp the page
|
||||
// numbers ("Page 1 of N" / "Seite 1 von N") once we know how
|
||||
// many pages the document ended up with. Without buffering,
|
||||
// PDFKit flushes each page as soon as the next one starts,
|
||||
// so we couldn't know N until it was too late.
|
||||
bufferPages: true,
|
||||
margins: {
|
||||
top: PAGE.marginTop, bottom: PAGE.marginBottom,
|
||||
left: PAGE.marginLeft, right: PAGE.marginRight,
|
||||
},
|
||||
info: {
|
||||
try {
|
||||
const ctx = normaliseContext(type, context);
|
||||
const doc = new PDFDocument({
|
||||
size: 'A4',
|
||||
// bufferPages: true keeps every page open in memory after
|
||||
// they're emitted so we can switch back and stamp the page
|
||||
// numbers ("Page 1 of N" / "Seite 1 von N") once we know how
|
||||
// many pages the document ended up with. Without buffering,
|
||||
// PDFKit flushes each page as soon as the next one starts,
|
||||
// so we couldn't know N until it was too late.
|
||||
bufferPages: true,
|
||||
margins: {
|
||||
top: PAGE.marginTop, bottom: PAGE.marginBottom,
|
||||
left: PAGE.marginLeft, right: PAGE.marginRight,
|
||||
},
|
||||
info: {
|
||||
// Chrome's built-in PDF viewer uses this Title metadata
|
||||
// as the default save name when the PDF is served from a
|
||||
// blob URL (where the original HTTP Content-Disposition
|
||||
// header can't propagate). Format mirrors the filename
|
||||
// we set on the HTTP response: "<number>_<customerLabel>"
|
||||
// so saved files have a meaningful name in either path.
|
||||
Title: (() => {
|
||||
const docNumber = ctx.doc.invoiceNumber || ctx.doc.quoteNumber
|
||||
Title: (() => {
|
||||
const docNumber = ctx.doc.invoiceNumber || ctx.doc.quoteNumber
|
||||
|| (type === 'quote' ? 'Quote' : 'Invoice');
|
||||
// Prefer the recipient (customer) for the label —
|
||||
// matches how admins typically file invoices.
|
||||
const recipient = ctx.recipient?.companyName || '';
|
||||
return recipient ? `${docNumber}_${recipient}` : String(docNumber);
|
||||
})(),
|
||||
Author: ctx.issuer.companyName || 'picpeak',
|
||||
},
|
||||
});
|
||||
// Prefer the recipient (customer) for the label —
|
||||
// matches how admins typically file invoices.
|
||||
const recipient = ctx.recipient?.companyName || '';
|
||||
return recipient ? `${docNumber}_${recipient}` : String(docNumber);
|
||||
})(),
|
||||
Author: ctx.issuer.companyName || 'picpeak',
|
||||
},
|
||||
});
|
||||
|
||||
const chunks = [];
|
||||
doc.on('data', (c) => chunks.push(c));
|
||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
doc.on('error', reject);
|
||||
const chunks = [];
|
||||
doc.on('data', (c) => chunks.push(c));
|
||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
doc.on('error', reject);
|
||||
|
||||
// Font registration. Same resolution priority as
|
||||
// createBaseDocument: pdfFontTtfPath (legacy override) →
|
||||
// pdfFontFamily (bundled dropdown) → Helvetica. Helpers below
|
||||
// read `doc._fonts` (one extra word per doc) so we don't have
|
||||
// to thread the font names through every drawing function or
|
||||
// fork the helpers per branding.
|
||||
doc._fonts = { body: FONT_BODY, bold: FONT_BOLD };
|
||||
ctx.fonts = doc._fonts;
|
||||
const registered = registerCustomFonts(doc, ctx.issuer);
|
||||
if (registered) {
|
||||
doc._fonts = registered;
|
||||
ctx.fonts = registered;
|
||||
}
|
||||
// Font registration. Same resolution priority as
|
||||
// createBaseDocument: pdfFontTtfPath (legacy override) →
|
||||
// pdfFontFamily (bundled dropdown) → Helvetica. Helpers below
|
||||
// read `doc._fonts` (one extra word per doc) so we don't have
|
||||
// to thread the font names through every drawing function or
|
||||
// fork the helpers per branding.
|
||||
doc._fonts = { body: FONT_BODY, bold: FONT_BOLD };
|
||||
ctx.fonts = doc._fonts;
|
||||
const registered = registerCustomFonts(doc, ctx.issuer);
|
||||
if (registered) {
|
||||
doc._fonts = registered;
|
||||
ctx.fonts = registered;
|
||||
}
|
||||
|
||||
// ---- header layout (DIN 5008 Form B) -------------------------
|
||||
// - recipient block in the address window (top-left,
|
||||
// 45mm from top, 20mm from left, 85×45mm)
|
||||
// - issuer block top-right (logo + company + address +
|
||||
// contact) sized to NOT overlap the address window
|
||||
//
|
||||
// The two blocks are positioned absolutely; we keep a `y`
|
||||
// cursor for the body content that starts BELOW both blocks.
|
||||
const leftX = PAGE.marginLeft;
|
||||
// Sender block: narrower (180pt vs 220pt), further right, and
|
||||
// nudged down by 16pt so it doesn't crowd the very top of the
|
||||
// page. Leaves more breathing room for the logo + name banner.
|
||||
const issuerWidth = 180;
|
||||
const issuerX = PAGE.width - PAGE.marginRight - issuerWidth;
|
||||
const issuerY = PAGE.marginTop + 16;
|
||||
// ---- header layout (DIN 5008 Form B) -------------------------
|
||||
// - recipient block in the address window (top-left,
|
||||
// 45mm from top, 20mm from left, 85×45mm)
|
||||
// - issuer block top-right (logo + company + address +
|
||||
// contact) sized to NOT overlap the address window
|
||||
//
|
||||
// The two blocks are positioned absolutely; we keep a `y`
|
||||
// cursor for the body content that starts BELOW both blocks.
|
||||
const leftX = PAGE.marginLeft;
|
||||
// Sender block: narrower (180pt vs 220pt), further right, and
|
||||
// nudged down by 16pt so it doesn't crowd the very top of the
|
||||
// page. Leaves more breathing room for the logo + name banner.
|
||||
const issuerWidth = 180;
|
||||
const issuerX = PAGE.width - PAGE.marginRight - issuerWidth;
|
||||
const issuerY = PAGE.marginTop + 16;
|
||||
|
||||
const issuerEndY = drawIssuerBlock(doc, ctx.issuer, issuerX, issuerY, issuerWidth, ctx.locale);
|
||||
const recipientEndY = drawRecipientBlock(doc, ctx.recipient, ctx.locale);
|
||||
// Start the body content below the header blocks AND the
|
||||
// address-window bottom edge — never let the date/title row
|
||||
// cut through the window region. The title position isn't
|
||||
// dictated by DIN 5008 (the spec only fixes the address window
|
||||
// position), so we pull it tight against the window's bottom
|
||||
// edge to give the body more vertical room.
|
||||
let y = Math.max(issuerEndY, recipientEndY, ADDR_WINDOW.top + ADDR_WINDOW.height) + 6;
|
||||
const issuerEndY = drawIssuerBlock(doc, ctx.issuer, issuerX, issuerY, issuerWidth, ctx.locale);
|
||||
const recipientEndY = drawRecipientBlock(doc, ctx.recipient, ctx.locale);
|
||||
// Start the body content below the header blocks AND the
|
||||
// address-window bottom edge — never let the date/title row
|
||||
// cut through the window region. The title position isn't
|
||||
// dictated by DIN 5008 (the spec only fixes the address window
|
||||
// position), so we pull it tight against the window's bottom
|
||||
// edge to give the body more vertical room.
|
||||
let y = Math.max(issuerEndY, recipientEndY, ADDR_WINDOW.top + ADDR_WINDOW.height) + 6;
|
||||
|
||||
// Storno discriminator. Drives:
|
||||
// - page title swap ("Stornorechnung" instead of "Rechnung")
|
||||
// - mandatory reference line under the title
|
||||
// - sign flip on line totals (row-level totals are already
|
||||
// stored negative in the DB, so drawTotals renders them
|
||||
// naturally — see drawLineItems for the per-item flip)
|
||||
// - suppression of payment terms / IBAN / QR-bill blocks
|
||||
// `type === 'invoice'` is preserved as the outer document
|
||||
// family — Storni share the invoice renderer surface, only
|
||||
// the cosmetic + accounting-sign branches differ.
|
||||
const isStorno = type === 'invoice' && ctx.doc.kind === 'storno';
|
||||
// Mahnung (reminder letter) reuses the invoice surface: same line items +
|
||||
// a Mahngebühr row + the new grand total, but a "Mahnung" title and NO
|
||||
// QR (the QR would encode the original amount, not the new total).
|
||||
const isMahnung = type === 'invoice' && ctx.doc.kind === 'mahnung';
|
||||
// Storno discriminator. Drives:
|
||||
// - page title swap ("Stornorechnung" instead of "Rechnung")
|
||||
// - mandatory reference line under the title
|
||||
// - sign flip on line totals (row-level totals are already
|
||||
// stored negative in the DB, so drawTotals renders them
|
||||
// naturally — see drawLineItems for the per-item flip)
|
||||
// - suppression of payment terms / IBAN / QR-bill blocks
|
||||
// `type === 'invoice'` is preserved as the outer document
|
||||
// family — Storni share the invoice renderer surface, only
|
||||
// the cosmetic + accounting-sign branches differ.
|
||||
const isStorno = type === 'invoice' && ctx.doc.kind === 'storno';
|
||||
// Mahnung (reminder letter) reuses the invoice surface: same line items +
|
||||
// a Mahngebühr row + the new grand total, but a "Mahnung" title and NO
|
||||
// QR (the QR would encode the original amount, not the new total).
|
||||
const isMahnung = type === 'invoice' && ctx.doc.kind === 'mahnung';
|
||||
|
||||
// ---- document number (above) + date (below), both right-aligned
|
||||
// The number sits directly under the sender address block so the
|
||||
// customer + accountant find the invoice/quote/Storno reference
|
||||
// exactly where DACH letter convention puts it. The date follows
|
||||
// on its own row with the same right-anchored column structure so
|
||||
// both label-and-value pairs align to the same right edge.
|
||||
const docNumberForDisplay = ctx.doc.invoiceNumber || ctx.doc.quoteNumber || '';
|
||||
const numberLabelKey = type === 'quote' ? 'quote_number_label' : 'invoice_number_label';
|
||||
const metaRight = leftX + PAGE.contentWidth;
|
||||
const metaLabelW = 110; // wider than the date label so "Rechnungsnummer" fits without wrap
|
||||
const metaValueW = 110;
|
||||
if (docNumberForDisplay) {
|
||||
// ---- document number (above) + date (below), both right-aligned
|
||||
// The number sits directly under the sender address block so the
|
||||
// customer + accountant find the invoice/quote/Storno reference
|
||||
// exactly where DACH letter convention puts it. The date follows
|
||||
// on its own row with the same right-anchored column structure so
|
||||
// both label-and-value pairs align to the same right edge.
|
||||
const docNumberForDisplay = ctx.doc.invoiceNumber || ctx.doc.quoteNumber || '';
|
||||
const numberLabelKey = type === 'quote' ? 'quote_number_label' : 'invoice_number_label';
|
||||
const metaRight = leftX + PAGE.contentWidth;
|
||||
const metaLabelW = 110; // wider than the date label so "Rechnungsnummer" fits without wrap
|
||||
const metaValueW = 110;
|
||||
if (docNumberForDisplay) {
|
||||
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#000');
|
||||
doc.text(`${t(ctx.locale, numberLabelKey)}:`,
|
||||
metaRight - metaValueW - metaLabelW, y,
|
||||
{ width: metaLabelW, align: 'right', lineBreak: false });
|
||||
doc.text(docNumberForDisplay, metaRight - metaValueW, y,
|
||||
{ width: metaValueW, align: 'right', lineBreak: false });
|
||||
y += 14;
|
||||
}
|
||||
// Date row — same right-anchored layout so the two values stack
|
||||
// visually as a single meta block. Replaces the previous
|
||||
// drawDate() call, which lived below the title and used a
|
||||
// tighter column spec.
|
||||
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#000');
|
||||
doc.text(`${t(ctx.locale, numberLabelKey)}:`,
|
||||
doc.text(`${t(ctx.locale, 'date')}:`,
|
||||
metaRight - metaValueW - metaLabelW, y,
|
||||
{ width: metaLabelW, align: 'right', lineBreak: false });
|
||||
doc.text(docNumberForDisplay, metaRight - metaValueW, y,
|
||||
doc.text(formatDate(ctx.doc.issueDate, ctx.dateFormat),
|
||||
metaRight - metaValueW, y,
|
||||
{ width: metaValueW, align: 'right', lineBreak: false });
|
||||
y += 14;
|
||||
}
|
||||
// Date row — same right-anchored layout so the two values stack
|
||||
// visually as a single meta block. Replaces the previous
|
||||
// drawDate() call, which lived below the title and used a
|
||||
// tighter column spec.
|
||||
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#000');
|
||||
doc.text(`${t(ctx.locale, 'date')}:`,
|
||||
metaRight - metaValueW - metaLabelW, y,
|
||||
{ width: metaLabelW, align: 'right', lineBreak: false });
|
||||
doc.text(formatDate(ctx.doc.issueDate, ctx.dateFormat),
|
||||
metaRight - metaValueW, y,
|
||||
{ width: metaValueW, align: 'right', lineBreak: false });
|
||||
y += 18; // line height + cushion before the title
|
||||
y += 18; // line height + cushion before the title
|
||||
|
||||
// ---- title ----------------------------------------------------
|
||||
const title = type === 'quote'
|
||||
? t(ctx.locale, 'quote_title')
|
||||
: isStorno
|
||||
? t(ctx.locale, 'storno_title')
|
||||
: isMahnung
|
||||
? t(ctx.locale, 'mahnung_title')
|
||||
: t(ctx.locale, 'invoice_title');
|
||||
y = drawTitle(doc, title, leftX, y + 2);
|
||||
// ---- title ----------------------------------------------------
|
||||
const title = type === 'quote'
|
||||
? t(ctx.locale, 'quote_title')
|
||||
: isStorno
|
||||
? t(ctx.locale, 'storno_title')
|
||||
: isMahnung
|
||||
? t(ctx.locale, 'mahnung_title')
|
||||
: t(ctx.locale, 'invoice_title');
|
||||
y = drawTitle(doc, title, leftX, y + 2);
|
||||
|
||||
// Mandatory Storno reference line — "Bezug: Storno zu Rechnung
|
||||
// R-XXXX vom DATE". This is the §14c-defensible link from the
|
||||
// cancellation document to the invoice it reverses; readers
|
||||
// and Finanzamt auditors need both numbers + the original
|
||||
// issue date to reconstruct the chain from the documents
|
||||
// alone. Stamped FIRST (before sourceQuote / replaces) so
|
||||
// it's the prominent reference on a Storno.
|
||||
if (isStorno && ctx.doc.cancelsInvoice) {
|
||||
const { number, issueDate } = ctx.doc.cancelsInvoice;
|
||||
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#666');
|
||||
const datePart = issueDate ? ` ${t(ctx.locale, 'reference_dated', { date: formatDate(issueDate, ctx.dateFormat) })}` : '';
|
||||
doc.text(
|
||||
`${t(ctx.locale, 'reference_label')}: ${t(ctx.locale, 'reference_cancels')} ${t(ctx.locale, 'invoice_title')} ${number}${datePart}`,
|
||||
leftX, y, { width: PAGE.contentWidth }
|
||||
);
|
||||
y = doc.y + 6;
|
||||
doc.fillColor('#000');
|
||||
}
|
||||
|
||||
// Invoice → source quote cross-reference. We deliberately keep
|
||||
// invoice numbers on a strict monotonic sequence (R-YYYY-NNNN)
|
||||
// for tax-compliance reasons (CH/LI/DE/AT require
|
||||
// "lückenlose Rechnungsnummern") — instead of mirroring the
|
||||
// quote number on the invoice, we surface the link as a small
|
||||
// "Bezug: Angebot Q-…" line under the title. Readers see the
|
||||
// provenance without breaking the numbering scheme. Only
|
||||
// rendered for invoices that came from a quote; no-op for
|
||||
// standalone invoices and Storni (which don't reference quotes).
|
||||
if (type === 'invoice' && !isStorno && ctx.doc.sourceQuoteNumber) {
|
||||
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#666');
|
||||
doc.text(
|
||||
`${t(ctx.locale, 'reference_label')}: ${t(ctx.locale, 'quote_title')} ${ctx.doc.sourceQuoteNumber}`,
|
||||
leftX, y, { width: PAGE.contentWidth }
|
||||
);
|
||||
y = doc.y + 6;
|
||||
doc.fillColor('#000');
|
||||
}
|
||||
// Cancel + reissue trail (migration 114) — when this invoice
|
||||
// replaces an earlier (cancelled) one, surface "Bezug: Ersetzt
|
||||
// Rechnung R-XXXX vom DATE" so the customer (and auditors) can
|
||||
// trace the chain. Rendered in the same grey-666 small-print
|
||||
// style as the quote-source reference above. Suppressed on
|
||||
// Storni (which carry their own cancelsInvoice reference).
|
||||
if (type === 'invoice' && !isStorno && ctx.doc.replacesInvoice) {
|
||||
const { number, issueDate } = ctx.doc.replacesInvoice;
|
||||
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#666');
|
||||
const datePart = issueDate ? ` ${t(ctx.locale, 'reference_dated', { date: formatDate(issueDate, ctx.dateFormat) })}` : '';
|
||||
doc.text(
|
||||
`${t(ctx.locale, 'reference_label')}: ${t(ctx.locale, 'reference_replaces')} ${t(ctx.locale, 'invoice_title')} ${number}${datePart}`,
|
||||
leftX, y, { width: PAGE.contentWidth }
|
||||
);
|
||||
y = doc.y + 6;
|
||||
doc.fillColor('#000');
|
||||
}
|
||||
|
||||
// ---- salutation + lead-in ------------------------------------
|
||||
// Personalised greeting when the customer record has an
|
||||
// honorific + last name on file ("Sehr geehrter Herr Bresch,"),
|
||||
// otherwise the generic locale-specific opening from the i18n
|
||||
// dictionary ("Sehr geehrte Damen und Herren,").
|
||||
const greeting = personalSalutation(ctx.locale, ctx.recipient?.salutation, ctx.recipient?.lastName)
|
||||
|| t(ctx.locale, 'salutation');
|
||||
doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).fontSize(10).fillColor('#000');
|
||||
doc.text(greeting, leftX, y, { width: PAGE.contentWidth });
|
||||
y = doc.y + 4;
|
||||
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY);
|
||||
const leadIn = type === 'quote'
|
||||
? t(ctx.locale, 'lead_in_quote')
|
||||
: t(ctx.locale, 'lead_in_invoice');
|
||||
doc.text(leadIn, leftX, y, { width: PAGE.contentWidth });
|
||||
y = doc.y + 16;
|
||||
|
||||
// ---- intro text override (admin-customisable) -----------------
|
||||
if (ctx.doc.introText) {
|
||||
doc.text(ctx.doc.introText, leftX, y, { width: PAGE.contentWidth });
|
||||
y = doc.y + 12;
|
||||
}
|
||||
|
||||
// ---- line items table ----------------------------------------
|
||||
// Small top padding — tight against the lead-in text since the
|
||||
// maintainer wants the items right under the greeting/intro.
|
||||
y += 8;
|
||||
doc.y = y;
|
||||
doc.x = leftX;
|
||||
|
||||
// Let the items table paginate with the document's NORMAL
|
||||
// margins so each page fills to the bottom. The header row is
|
||||
// marked `header: true` so it auto-repeats on every
|
||||
// continuation page. Totals/payment placement is handled below:
|
||||
// they're pinned to a fixed anchor near the page bottom, and if
|
||||
// the last item row spilled past that anchor we advance to a
|
||||
// fresh page before drawing them (see the desiredTotalsY check).
|
||||
//
|
||||
// We deliberately do NOT inflate the bottom margin here to
|
||||
// "reserve" the totals zone on every page. That older approach
|
||||
// shortened the usable area on EVERY page (not just the last),
|
||||
// so a long invoice broke far too early — only a handful of
|
||||
// line items rendered on page 1 with a large blank gap beneath.
|
||||
// Worse, the inflated margin was set on the page active when the
|
||||
// table started but restored on whichever page the table ended,
|
||||
// leaving page 1 permanently short: the page-number stamp later
|
||||
// landed below that page's phantom bottom margin and spawned a
|
||||
// stray blank trailing page (which then desynced "Seite X von Y").
|
||||
drawLineItems(doc, ctx);
|
||||
// y after the table — used only to detect whether the items
|
||||
// overflowed past the totals anchor below. We don't use it as
|
||||
// the totals position directly because the totals block is
|
||||
// pinned to a fixed offset from the page bottom regardless of
|
||||
// how many items rendered.
|
||||
y = doc.y;
|
||||
|
||||
// ---- pin totals + payment block to footer ---------------------
|
||||
// The totals box + payment block ALWAYS render at the same
|
||||
// distance from the page bottom regardless of how many line
|
||||
// items rendered. Reserves below are conservative-but-tight:
|
||||
// they reflect the actual measured block heights, with just
|
||||
// enough breathing room that a wrapped line or extra Skonto
|
||||
// row doesn't crash into the footer.
|
||||
// FOOTER_RESERVE = 30 (one footer line ~12pt + ~18pt gap)
|
||||
// PAYMENT_BLOCK_HEIGHT = 80 with paymentTerm, 50 without
|
||||
// (header + 3-4 rows including the
|
||||
// skonto + skonto_amount lines)
|
||||
// TOTALS_BLOCK_HEIGHT = 90 (top divider + Net + Shipping +
|
||||
// VAT + middle divider + Total)
|
||||
const FOOTER_RESERVE = 30;
|
||||
const PAYMENT_BLOCK_HEIGHT = ctx.paymentTerm ? 80 : 50;
|
||||
let TOTALS_BLOCK_HEIGHT = 90;
|
||||
// A free-text VAT note (#794) adds a wrapped row under the MwSt. line —
|
||||
// grow the reserved totals height by its measured height so a long note
|
||||
// can't push the grand total / payment block into the footer.
|
||||
if (ctx.vatNote) {
|
||||
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8);
|
||||
const noteWidth = PAGE.contentWidth - ((PAGE.contentWidth - 20) / 2 + 20);
|
||||
TOTALS_BLOCK_HEIGHT += doc.heightOfString(ctx.vatNote, { width: noteWidth }) + 4;
|
||||
doc.fontSize(10);
|
||||
}
|
||||
const desiredPaymentY = PAGE.height - PAGE.marginBottom - FOOTER_RESERVE - PAYMENT_BLOCK_HEIGHT;
|
||||
const desiredTotalsY = desiredPaymentY - 12 - TOTALS_BLOCK_HEIGHT;
|
||||
|
||||
// If line items used more space than the totals anchor allows,
|
||||
// advance to a new page before drawing totals — keeps the
|
||||
// bottom block at a CONSTANT position from the footer on
|
||||
// whatever page it lands on.
|
||||
if (y > desiredTotalsY) {
|
||||
doc.addPage();
|
||||
}
|
||||
// Always reset to the fixed anchor — independent of where the
|
||||
// table ended on the page.
|
||||
y = desiredTotalsY;
|
||||
|
||||
// ---- totals box (right-aligned) -------------------------------
|
||||
y = drawTotals(doc, ctx, leftX, y, PAGE.contentWidth);
|
||||
|
||||
// ---- outro text -----------------------------------------------
|
||||
if (ctx.doc.outroText) {
|
||||
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#000');
|
||||
doc.text(ctx.doc.outroText, leftX, y, { width: PAGE.contentWidth });
|
||||
y = doc.y + 12;
|
||||
}
|
||||
|
||||
// ---- payment conditions + IBAN block --------------------------
|
||||
// Pin the payment block to the fixed anchor too — the totals
|
||||
// box can end short of it (e.g. when only Net + Total render
|
||||
// with no shipping/VAT), so we snap back unconditionally.
|
||||
// Suppressed on Stornorechnungen: a cancellation document is
|
||||
// not a payment instrument — no Zahlungsbedingungen, no IBAN,
|
||||
// no Skonto. Customers reading a Storno expect total clarity
|
||||
// that this is the REVERSAL of an obligation, not a new one.
|
||||
if (!isStorno) {
|
||||
y = desiredPaymentY;
|
||||
y = drawPaymentBlock(doc, ctx, leftX, y, PAGE.contentWidth);
|
||||
}
|
||||
|
||||
// ---- folding marks (left edge) --------------------------------
|
||||
drawFoldingMarks(doc, ctx.issuer?.foldingMarks);
|
||||
|
||||
// ---- footer ---------------------------------------------------
|
||||
drawFooter(doc, ctx.issuer, ctx.locale);
|
||||
|
||||
// ---- payment QR on fresh page (invoices only) -----------------
|
||||
// Two paths, mutually exclusive:
|
||||
// - 'swiss' → SwissQRBill payment slip (CHF / EUR within CH/LI)
|
||||
// - 'epc' → SEPA EPC069-12 QR code (EUR-only, every SEPA bank)
|
||||
// Both append a fresh page; 'none' is a no-op.
|
||||
// Suppressed on Stornorechnungen — negative-amount QR codes
|
||||
// aren't a defined construct in either spec.
|
||||
if (type === 'invoice' && !isStorno && !isMahnung) {
|
||||
if (ctx.qrFormat === 'swiss') {
|
||||
appendSwissQrBill(doc, ctx);
|
||||
} else if (ctx.qrFormat === 'epc') {
|
||||
await appendEpcQr(doc, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- page numbers ("Page 1 of N" / "Seite 1 von N") -----------
|
||||
// Stamped after everything else so we know the final page
|
||||
// count. bufferPages: true (on the PDFDocument options above)
|
||||
// keeps every page open for back-editing — bufferedPageRange()
|
||||
// returns {start, count}. We switchToPage() each one, draw the
|
||||
// pagination label in the bottom-right corner, then end.
|
||||
try {
|
||||
const range = doc.bufferedPageRange();
|
||||
const total = range.count;
|
||||
// Stamp on EVERY page including single-page documents. The
|
||||
// "Page 1 of 1" label is a tamper-evidence cue for the
|
||||
// recipient — if they receive page 1 of 3 in isolation,
|
||||
// they know pages are missing; conversely "1 of 1" lets a
|
||||
// single-page invoice confirm it's complete. The cost (one
|
||||
// grey line in the bottom corner) is negligible.
|
||||
for (let i = 0; i < total; i++) {
|
||||
doc.switchToPage(range.start + i);
|
||||
// Drop this page's bottom margin to 0 so writing the label INTO the
|
||||
// margin band (below the content area the line-item table fills) can't
|
||||
// trigger PDFKit's auto-page-break. Previously the label sat at
|
||||
// marginBottom-12 — INSIDE the content area — so on a full multi-page
|
||||
// invoice the table's last row overlapped the "Seite X von Y" stamp
|
||||
// (#794). The page is already fully laid out (buffered), so zeroing the
|
||||
// margin here is safe.
|
||||
doc.page.margins.bottom = 0;
|
||||
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8).fillColor('#888');
|
||||
const label = t(ctx.locale, 'page_of', {
|
||||
current: i + 1,
|
||||
total,
|
||||
});
|
||||
// Bottom-right corner, INSIDE the bottom margin (below the content
|
||||
// edge the table fills), so a full continuation page's last row can't
|
||||
// overlap it.
|
||||
const labelY = doc.page.height - PAGE.marginBottom + 8;
|
||||
const labelW = 120;
|
||||
const labelX = doc.page.width - PAGE.marginRight - labelW;
|
||||
doc.text(label, labelX, labelY, {
|
||||
width: labelW, align: 'right', lineBreak: false,
|
||||
});
|
||||
// Mandatory Storno reference line — "Bezug: Storno zu Rechnung
|
||||
// R-XXXX vom DATE". This is the §14c-defensible link from the
|
||||
// cancellation document to the invoice it reverses; readers
|
||||
// and Finanzamt auditors need both numbers + the original
|
||||
// issue date to reconstruct the chain from the documents
|
||||
// alone. Stamped FIRST (before sourceQuote / replaces) so
|
||||
// it's the prominent reference on a Storno.
|
||||
if (isStorno && ctx.doc.cancelsInvoice) {
|
||||
const { number, issueDate } = ctx.doc.cancelsInvoice;
|
||||
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#666');
|
||||
const datePart = issueDate ? ` ${t(ctx.locale, 'reference_dated', { date: formatDate(issueDate, ctx.dateFormat) })}` : '';
|
||||
doc.text(
|
||||
`${t(ctx.locale, 'reference_label')}: ${t(ctx.locale, 'reference_cancels')} ${t(ctx.locale, 'invoice_title')} ${number}${datePart}`,
|
||||
leftX, y, { width: PAGE.contentWidth }
|
||||
);
|
||||
y = doc.y + 6;
|
||||
doc.fillColor('#000');
|
||||
}
|
||||
} catch (err) {
|
||||
const logger = require('../utils/logger');
|
||||
logger.warn('Failed to stamp page numbers on PDF', { err: err.message });
|
||||
}
|
||||
|
||||
doc.end();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
// Invoice → source quote cross-reference. We deliberately keep
|
||||
// invoice numbers on a strict monotonic sequence (R-YYYY-NNNN)
|
||||
// for tax-compliance reasons (CH/LI/DE/AT require
|
||||
// "lückenlose Rechnungsnummern") — instead of mirroring the
|
||||
// quote number on the invoice, we surface the link as a small
|
||||
// "Bezug: Angebot Q-…" line under the title. Readers see the
|
||||
// provenance without breaking the numbering scheme. Only
|
||||
// rendered for invoices that came from a quote; no-op for
|
||||
// standalone invoices and Storni (which don't reference quotes).
|
||||
if (type === 'invoice' && !isStorno && ctx.doc.sourceQuoteNumber) {
|
||||
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#666');
|
||||
doc.text(
|
||||
`${t(ctx.locale, 'reference_label')}: ${t(ctx.locale, 'quote_title')} ${ctx.doc.sourceQuoteNumber}`,
|
||||
leftX, y, { width: PAGE.contentWidth }
|
||||
);
|
||||
y = doc.y + 6;
|
||||
doc.fillColor('#000');
|
||||
}
|
||||
// Cancel + reissue trail (migration 114) — when this invoice
|
||||
// replaces an earlier (cancelled) one, surface "Bezug: Ersetzt
|
||||
// Rechnung R-XXXX vom DATE" so the customer (and auditors) can
|
||||
// trace the chain. Rendered in the same grey-666 small-print
|
||||
// style as the quote-source reference above. Suppressed on
|
||||
// Storni (which carry their own cancelsInvoice reference).
|
||||
if (type === 'invoice' && !isStorno && ctx.doc.replacesInvoice) {
|
||||
const { number, issueDate } = ctx.doc.replacesInvoice;
|
||||
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#666');
|
||||
const datePart = issueDate ? ` ${t(ctx.locale, 'reference_dated', { date: formatDate(issueDate, ctx.dateFormat) })}` : '';
|
||||
doc.text(
|
||||
`${t(ctx.locale, 'reference_label')}: ${t(ctx.locale, 'reference_replaces')} ${t(ctx.locale, 'invoice_title')} ${number}${datePart}`,
|
||||
leftX, y, { width: PAGE.contentWidth }
|
||||
);
|
||||
y = doc.y + 6;
|
||||
doc.fillColor('#000');
|
||||
}
|
||||
|
||||
// ---- salutation + lead-in ------------------------------------
|
||||
// Personalised greeting when the customer record has an
|
||||
// honorific + last name on file ("Sehr geehrter Herr Bresch,"),
|
||||
// otherwise the generic locale-specific opening from the i18n
|
||||
// dictionary ("Sehr geehrte Damen und Herren,").
|
||||
const greeting = personalSalutation(ctx.locale, ctx.recipient?.salutation, ctx.recipient?.lastName)
|
||||
|| t(ctx.locale, 'salutation');
|
||||
doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).fontSize(10).fillColor('#000');
|
||||
doc.text(greeting, leftX, y, { width: PAGE.contentWidth });
|
||||
y = doc.y + 4;
|
||||
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY);
|
||||
const leadIn = type === 'quote'
|
||||
? t(ctx.locale, 'lead_in_quote')
|
||||
: t(ctx.locale, 'lead_in_invoice');
|
||||
doc.text(leadIn, leftX, y, { width: PAGE.contentWidth });
|
||||
y = doc.y + 16;
|
||||
|
||||
// ---- intro text override (admin-customisable) -----------------
|
||||
if (ctx.doc.introText) {
|
||||
doc.text(ctx.doc.introText, leftX, y, { width: PAGE.contentWidth });
|
||||
y = doc.y + 12;
|
||||
}
|
||||
|
||||
// ---- line items table ----------------------------------------
|
||||
// Small top padding — tight against the lead-in text since the
|
||||
// maintainer wants the items right under the greeting/intro.
|
||||
y += 8;
|
||||
doc.y = y;
|
||||
doc.x = leftX;
|
||||
|
||||
// Let the items table paginate with the document's NORMAL
|
||||
// margins so each page fills to the bottom. The header row is
|
||||
// marked `header: true` so it auto-repeats on every
|
||||
// continuation page. Totals/payment placement is handled below:
|
||||
// they're pinned to a fixed anchor near the page bottom, and if
|
||||
// the last item row spilled past that anchor we advance to a
|
||||
// fresh page before drawing them (see the desiredTotalsY check).
|
||||
//
|
||||
// We deliberately do NOT inflate the bottom margin here to
|
||||
// "reserve" the totals zone on every page. That older approach
|
||||
// shortened the usable area on EVERY page (not just the last),
|
||||
// so a long invoice broke far too early — only a handful of
|
||||
// line items rendered on page 1 with a large blank gap beneath.
|
||||
// Worse, the inflated margin was set on the page active when the
|
||||
// table started but restored on whichever page the table ended,
|
||||
// leaving page 1 permanently short: the page-number stamp later
|
||||
// landed below that page's phantom bottom margin and spawned a
|
||||
// stray blank trailing page (which then desynced "Seite X von Y").
|
||||
drawLineItems(doc, ctx);
|
||||
// y after the table — used only to detect whether the items
|
||||
// overflowed past the totals anchor below. We don't use it as
|
||||
// the totals position directly because the totals block is
|
||||
// pinned to a fixed offset from the page bottom regardless of
|
||||
// how many items rendered.
|
||||
y = doc.y;
|
||||
|
||||
// ---- pin totals + payment block to footer ---------------------
|
||||
// The totals box + payment block ALWAYS render at the same
|
||||
// distance from the page bottom regardless of how many line
|
||||
// items rendered. Reserves below are conservative-but-tight:
|
||||
// they reflect the actual measured block heights, with just
|
||||
// enough breathing room that a wrapped line or extra Skonto
|
||||
// row doesn't crash into the footer.
|
||||
// FOOTER_RESERVE = 30 (one footer line ~12pt + ~18pt gap)
|
||||
// PAYMENT_BLOCK_HEIGHT = 80 with paymentTerm, 50 without
|
||||
// (header + 3-4 rows including the
|
||||
// skonto + skonto_amount lines)
|
||||
// TOTALS_BLOCK_HEIGHT = 90 (top divider + Net + Shipping +
|
||||
// VAT + middle divider + Total)
|
||||
const FOOTER_RESERVE = 30;
|
||||
const PAYMENT_BLOCK_HEIGHT = ctx.paymentTerm ? 80 : 50;
|
||||
let TOTALS_BLOCK_HEIGHT = 90;
|
||||
// A free-text VAT note (#794) adds a wrapped row under the MwSt. line —
|
||||
// grow the reserved totals height by its measured height so a long note
|
||||
// can't push the grand total / payment block into the footer.
|
||||
if (ctx.vatNote) {
|
||||
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8);
|
||||
const noteWidth = PAGE.contentWidth - ((PAGE.contentWidth - 20) / 2 + 20);
|
||||
TOTALS_BLOCK_HEIGHT += doc.heightOfString(ctx.vatNote, { width: noteWidth }) + 4;
|
||||
doc.fontSize(10);
|
||||
}
|
||||
const desiredPaymentY = PAGE.height - PAGE.marginBottom - FOOTER_RESERVE - PAYMENT_BLOCK_HEIGHT;
|
||||
const desiredTotalsY = desiredPaymentY - 12 - TOTALS_BLOCK_HEIGHT;
|
||||
|
||||
// If line items used more space than the totals anchor allows,
|
||||
// advance to a new page before drawing totals — keeps the
|
||||
// bottom block at a CONSTANT position from the footer on
|
||||
// whatever page it lands on.
|
||||
if (y > desiredTotalsY) {
|
||||
doc.addPage();
|
||||
}
|
||||
// Always reset to the fixed anchor — independent of where the
|
||||
// table ended on the page.
|
||||
y = desiredTotalsY;
|
||||
|
||||
// ---- totals box (right-aligned) -------------------------------
|
||||
y = drawTotals(doc, ctx, leftX, y, PAGE.contentWidth);
|
||||
|
||||
// ---- outro text -----------------------------------------------
|
||||
if (ctx.doc.outroText) {
|
||||
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(10).fillColor('#000');
|
||||
doc.text(ctx.doc.outroText, leftX, y, { width: PAGE.contentWidth });
|
||||
y = doc.y + 12;
|
||||
}
|
||||
|
||||
// ---- payment conditions + IBAN block --------------------------
|
||||
// Pin the payment block to the fixed anchor too — the totals
|
||||
// box can end short of it (e.g. when only Net + Total render
|
||||
// with no shipping/VAT), so we snap back unconditionally.
|
||||
// Suppressed on Stornorechnungen: a cancellation document is
|
||||
// not a payment instrument — no Zahlungsbedingungen, no IBAN,
|
||||
// no Skonto. Customers reading a Storno expect total clarity
|
||||
// that this is the REVERSAL of an obligation, not a new one.
|
||||
if (!isStorno) {
|
||||
y = desiredPaymentY;
|
||||
y = drawPaymentBlock(doc, ctx, leftX, y, PAGE.contentWidth);
|
||||
}
|
||||
|
||||
// ---- folding marks (left edge) --------------------------------
|
||||
drawFoldingMarks(doc, ctx.issuer?.foldingMarks);
|
||||
|
||||
// ---- footer ---------------------------------------------------
|
||||
drawFooter(doc, ctx.issuer, ctx.locale);
|
||||
|
||||
// ---- payment QR on fresh page (invoices only) -----------------
|
||||
// Two paths, mutually exclusive:
|
||||
// - 'swiss' → SwissQRBill payment slip (CHF / EUR within CH/LI)
|
||||
// - 'epc' → SEPA EPC069-12 QR code (EUR-only, every SEPA bank)
|
||||
// Both append a fresh page; 'none' is a no-op.
|
||||
// Suppressed on Stornorechnungen — negative-amount QR codes
|
||||
// aren't a defined construct in either spec.
|
||||
if (type === 'invoice' && !isStorno && !isMahnung) {
|
||||
if (ctx.qrFormat === 'swiss') {
|
||||
appendSwissQrBill(doc, ctx);
|
||||
} else if (ctx.qrFormat === 'epc') {
|
||||
await appendEpcQr(doc, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- page numbers ("Page 1 of N" / "Seite 1 von N") -----------
|
||||
// Stamped after everything else so we know the final page
|
||||
// count. bufferPages: true (on the PDFDocument options above)
|
||||
// keeps every page open for back-editing — bufferedPageRange()
|
||||
// returns {start, count}. We switchToPage() each one, draw the
|
||||
// pagination label in the bottom-right corner, then end.
|
||||
try {
|
||||
const range = doc.bufferedPageRange();
|
||||
const total = range.count;
|
||||
// Stamp on EVERY page including single-page documents. The
|
||||
// "Page 1 of 1" label is a tamper-evidence cue for the
|
||||
// recipient — if they receive page 1 of 3 in isolation,
|
||||
// they know pages are missing; conversely "1 of 1" lets a
|
||||
// single-page invoice confirm it's complete. The cost (one
|
||||
// grey line in the bottom corner) is negligible.
|
||||
for (let i = 0; i < total; i++) {
|
||||
doc.switchToPage(range.start + i);
|
||||
// Drop this page's bottom margin to 0 so writing the label INTO the
|
||||
// margin band (below the content area the line-item table fills) can't
|
||||
// trigger PDFKit's auto-page-break. Previously the label sat at
|
||||
// marginBottom-12 — INSIDE the content area — so on a full multi-page
|
||||
// invoice the table's last row overlapped the "Seite X von Y" stamp
|
||||
// (#794). The page is already fully laid out (buffered), so zeroing the
|
||||
// margin here is safe.
|
||||
doc.page.margins.bottom = 0;
|
||||
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8).fillColor('#888');
|
||||
const label = t(ctx.locale, 'page_of', {
|
||||
current: i + 1,
|
||||
total,
|
||||
});
|
||||
// Bottom-right corner, INSIDE the bottom margin (below the content
|
||||
// edge the table fills), so a full continuation page's last row can't
|
||||
// overlap it.
|
||||
const labelY = doc.page.height - PAGE.marginBottom + 8;
|
||||
const labelW = 120;
|
||||
const labelX = doc.page.width - PAGE.marginRight - labelW;
|
||||
doc.text(label, labelX, labelY, {
|
||||
width: labelW, align: 'right', lineBreak: false,
|
||||
});
|
||||
doc.fillColor('#000');
|
||||
}
|
||||
} catch (err) {
|
||||
const logger = require('../utils/logger');
|
||||
logger.warn('Failed to stamp page numbers on PDF', { err: err.message });
|
||||
}
|
||||
|
||||
doc.end();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
@@ -1946,12 +1937,12 @@ function renderContractToBuffer(context) {
|
||||
|
||||
// ---- helper: ensure space before drawing, paginate if needed.
|
||||
const bottomLimit = PAGE.height - PAGE.marginBottom - 20;
|
||||
function ensureSpace(needed) {
|
||||
const ensureSpace = (needed) => {
|
||||
if (y + needed > bottomLimit) {
|
||||
doc.addPage();
|
||||
y = PAGE.marginTop;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ---- helper: render body text with inline **bold** support.
|
||||
// Splits on `**text**` markers, switches the font weight per
|
||||
@@ -1960,7 +1951,7 @@ function renderContractToBuffer(context) {
|
||||
// chunks continue from PDFKit's cursor so wrapping works
|
||||
// across font switches. After rendering, we read doc.y as
|
||||
// the new cursor.
|
||||
function renderBodyMarkdown(text, opts) {
|
||||
const renderBodyMarkdown = (text, opts) => {
|
||||
const parts = String(text || '').split(/(\*\*[^*]+\*\*)/g).filter((p) => p.length > 0);
|
||||
if (parts.length === 0) return;
|
||||
const last = parts.length - 1;
|
||||
@@ -1976,7 +1967,7 @@ function renderContractToBuffer(context) {
|
||||
doc.text(chunk, { ...opts, continued: i < last });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ---- intro text ---------------------------------------------
|
||||
if (ctx.doc?.introText) {
|
||||
@@ -2174,7 +2165,7 @@ function renderContractToBuffer(context) {
|
||||
// Two empty signature boxes — customer on the left, admin on
|
||||
// the right. drawn at fixed coordinates so the stamp service
|
||||
// can find them later by constant rather than runtime layout.
|
||||
function drawEmptySignaturePane(x, label, info) {
|
||||
const drawEmptySignaturePane = (x, label, info) => {
|
||||
doc.font(doc._fonts.bold).fontSize(10).fillColor('#000');
|
||||
doc.text(label, x, L.paneLabelY, { width: L.boxWidth });
|
||||
doc.strokeColor('#cccccc').lineWidth(0.5)
|
||||
@@ -2194,7 +2185,7 @@ function renderContractToBuffer(context) {
|
||||
`${t(locale, 'signed_label_date')}: ${info?.signedAt ? formatDate(info.signedAt, locale) : ''}`,
|
||||
x, captionY + 12, { width: L.boxWidth },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
drawEmptySignaturePane(L.customerX, t(locale, 'signature_customer'), ctx.signatures?.customer);
|
||||
drawEmptySignaturePane(L.adminX, t(locale, 'signature_admin'), ctx.signatures?.admin);
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const PDFKit = require('pdfkit');
|
||||
const { PDFDocument } = require('pdf-lib');
|
||||
@@ -84,7 +83,7 @@ function pdfkitToPdfLib(pageHeight, x, y, w, h) {
|
||||
* or the input file.
|
||||
*/
|
||||
async function stampSignature({ pdfBuffer, signaturePngPath, role, caption }) {
|
||||
const { L, FONT_BODY, FONT_BOLD, formatDate } = pdfConsts();
|
||||
const { L, formatDate } = pdfConsts();
|
||||
if (!Buffer.isBuffer(pdfBuffer)) {
|
||||
throw new Error('stampSignature: pdfBuffer must be a Buffer');
|
||||
}
|
||||
@@ -261,7 +260,7 @@ async function renderAuditCertificate({ contract, customer, admin, locale = 'de'
|
||||
|
||||
const labelW = 200;
|
||||
const valueW = PAGE.contentWidth - labelW;
|
||||
function row(labelKey, value) {
|
||||
const row = (labelKey, value) => {
|
||||
if (!value) return;
|
||||
doc.font(doc._fonts.bold).fontSize(9).fillColor('#444');
|
||||
doc.text(t(locale, labelKey), PAGE.marginLeft, y, {
|
||||
@@ -272,7 +271,7 @@ async function renderAuditCertificate({ contract, customer, admin, locale = 'de'
|
||||
width: valueW, align: 'left',
|
||||
});
|
||||
y = Math.max(y + 12, doc.y + 4);
|
||||
}
|
||||
};
|
||||
|
||||
row('audit_contract_number', contract.contract_number);
|
||||
row('audit_issued_at', contract.sent_at
|
||||
|
||||
@@ -12,7 +12,6 @@ const feedbackService = require('./feedbackService');
|
||||
const { neutralizeSpreadsheetFormula } = require('../utils/spreadsheetSafe');
|
||||
const { db } = require('../database/db');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
|
||||
/**
|
||||
* The name the camera gave the file, or null when nothing was recorded (#1229).
|
||||
@@ -128,16 +127,16 @@ class PhotoExportService {
|
||||
}
|
||||
|
||||
switch (format) {
|
||||
case 'txt':
|
||||
return this.exportAsTxt(photos, options);
|
||||
case 'csv':
|
||||
return this.exportAsCsv(photos, options);
|
||||
case 'xmp':
|
||||
return this.exportAsXmpZip(photos, options);
|
||||
case 'json':
|
||||
return this.exportAsJson(photos, eventId, options);
|
||||
default:
|
||||
throw new Error(`Unknown export format: ${format}`);
|
||||
case 'txt':
|
||||
return this.exportAsTxt(photos, options);
|
||||
case 'csv':
|
||||
return this.exportAsCsv(photos, options);
|
||||
case 'xmp':
|
||||
return this.exportAsXmpZip(photos, options);
|
||||
case 'json':
|
||||
return this.exportAsJson(photos, eventId, options);
|
||||
default:
|
||||
throw new Error(`Unknown export format: ${format}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,14 +167,14 @@ class PhotoExportService {
|
||||
|
||||
let content;
|
||||
switch (separator) {
|
||||
case 'comma':
|
||||
content = filenames.join(',');
|
||||
break;
|
||||
case 'semicolon':
|
||||
content = filenames.join(';');
|
||||
break;
|
||||
default:
|
||||
content = filenames.join('\n');
|
||||
case 'comma':
|
||||
content = filenames.join(',');
|
||||
break;
|
||||
case 'semicolon':
|
||||
content = filenames.join(';');
|
||||
break;
|
||||
default:
|
||||
content = filenames.join('\n');
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -289,7 +288,7 @@ class PhotoExportService {
|
||||
/**
|
||||
* Export as JSON metadata
|
||||
*/
|
||||
async exportAsJson(photos, eventId, options = {}) {
|
||||
async exportAsJson(photos, eventId, _options = {}) {
|
||||
// Get event info
|
||||
const event = await db('events')
|
||||
.where('id', eventId)
|
||||
|
||||
@@ -261,7 +261,7 @@ const PRESERVED_AUTH_FIELDS = [
|
||||
async function jsonColumnsFor(trx, table) {
|
||||
if (!isPostgres()) return new Set();
|
||||
const res = await trx.raw(
|
||||
"SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ? AND data_type IN ('json', 'jsonb')",
|
||||
'SELECT column_name FROM information_schema.columns WHERE table_schema = \'public\' AND table_name = ? AND data_type IN (\'json\', \'jsonb\')',
|
||||
[table]
|
||||
);
|
||||
return new Set(res.rows.map((r) => r.column_name));
|
||||
@@ -337,7 +337,7 @@ async function replaceAllTables(tables, dataDir, currentAdmin, roleSnapshot, { c
|
||||
await db.transaction(async (trx) => {
|
||||
if (isPostgres()) {
|
||||
try {
|
||||
await trx.raw("SET session_replication_role = 'replica'");
|
||||
await trx.raw('SET session_replication_role = \'replica\'');
|
||||
} catch (_) {
|
||||
// session_replication_role requires a Postgres SUPERUSER. The bundled
|
||||
// postgres image's role is one; managed Postgres (RDS / Cloud SQL / …)
|
||||
@@ -420,7 +420,7 @@ async function replaceAllTables(tables, dataDir, currentAdmin, roleSnapshot, { c
|
||||
}
|
||||
|
||||
// Reset the pg session flag BEFORE the connection returns to the pool.
|
||||
if (isPostgres()) await trx.raw("SET session_replication_role = 'origin'");
|
||||
if (isPostgres()) await trx.raw('SET session_replication_role = \'origin\'');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,10 @@ const { hasColumnCached } = require('../utils/schemaCache');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// NOTE: this transition table is currently never consulted — quote status
|
||||
// changes are not validated against it anywhere in the codebase. Kept as the
|
||||
// documented intent; wiring it up is tracked separately.
|
||||
// eslint-disable-next-line no-unused-vars -- unwired state machine, see note above
|
||||
const VALID_QUOTE_TRANSITIONS = {
|
||||
draft: new Set(['sent', 'declined']),
|
||||
sent: new Set(['draft', 'accepted', 'declined', 'expired']),
|
||||
@@ -620,7 +624,7 @@ async function createQuote(payload, adminId) {
|
||||
// Pass `trx` so the audit insert rides the transaction's connection —
|
||||
// the global db here deadlocks the single-connection SQLite pool.
|
||||
await logActivity('quote_created', { quoteId, quoteNumber, customerAccountId: payload.customerAccountId }, null, `admin:${adminId}`, trx);
|
||||
} catch (_) {}
|
||||
} catch (_) { /* non-fatal */ }
|
||||
|
||||
logger.info('Quote created', { adminId, quoteId, quoteNumber });
|
||||
return quoteId;
|
||||
@@ -760,7 +764,7 @@ async function updateQuote(id, payload, adminId) {
|
||||
|
||||
try {
|
||||
await logActivity('quote_updated', { quoteId: id }, null, `admin:${adminId}`);
|
||||
} catch (_) {}
|
||||
} catch (_) { /* non-fatal */ }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1033,7 +1037,7 @@ async function sendQuote(id, adminId) {
|
||||
// Do NOT log the raw bearer token — it grants quote actions and the
|
||||
// activity log is readable later (GHSA-prch). The quoteId is the audit key.
|
||||
await logActivity('quote_sent', { quoteId: id }, null, `admin:${adminId}`);
|
||||
} catch (_) {}
|
||||
} catch (_) { /* non-fatal */ }
|
||||
|
||||
// Fire the quote.sent workflow trigger (best-effort; emit is fail-closed when
|
||||
// the workflows flag is off). The accepted/declined emits already exist; this
|
||||
@@ -1259,7 +1263,7 @@ async function recordResponse({ token, action, ip, tosAccepted }) {
|
||||
try {
|
||||
// Raw bearer token must not reach the activity log (GHSA-prch).
|
||||
await logActivity(`quote_${newStatus}`, { quoteId: quote.id }, null, 'customer:public');
|
||||
} catch (_) {}
|
||||
} catch (_) { /* non-fatal */ }
|
||||
|
||||
// Defer the workflow emit until the 15-min toggle window locks — so accepting
|
||||
// (then converting) can't strip the customer's ability to decline. The
|
||||
@@ -1317,7 +1321,7 @@ async function adminAcceptQuote(id, adminId) {
|
||||
|
||||
try {
|
||||
await logActivity('quote_accepted_by_admin', { quoteId: id }, null, `admin:${adminId}`);
|
||||
} catch (_) {}
|
||||
} catch (_) { /* non-fatal */ }
|
||||
|
||||
// ---- customer confirmation email -------------------------------
|
||||
// Renders the quote PDF + queues a "quote accepted — on your
|
||||
@@ -1428,7 +1432,7 @@ async function adminDeclineQuote(id, adminId, reason = null) {
|
||||
|
||||
try {
|
||||
await logActivity('quote_declined_by_admin', { quoteId: id, reason: cleanReason }, null, `admin:${adminId}`);
|
||||
} catch (_) {}
|
||||
} catch (_) { /* non-fatal */ }
|
||||
|
||||
// Admin decline locks the window immediately (response_locked_at = now), so
|
||||
// this emits straight away (and stamps emitted) rather than deferring.
|
||||
@@ -1569,7 +1573,7 @@ async function convertToInvoiceOnly(quoteId, adminId, options = {}) {
|
||||
try {
|
||||
await logActivity('quote_converted_invoices_only', { quoteId: quote.id, installments: result.installmentsCreated },
|
||||
null, `admin:${adminId}`);
|
||||
} catch (_) {}
|
||||
} catch (_) { /* non-fatal */ }
|
||||
|
||||
logger.info('Quote converted to invoices only (no event)', { adminId, quoteId: quote.id, installments: result.installmentsCreated });
|
||||
return result;
|
||||
@@ -1750,7 +1754,7 @@ async function convertToEvent(quoteId, adminId, options = {}) {
|
||||
// (prepare_event runs this unattended from the booking flow).
|
||||
try {
|
||||
await logActivity('quote_converted', { quoteId: quote.id, eventId: result.eventId }, result.eventId, `admin:${adminId}`);
|
||||
} catch (_) {}
|
||||
} catch (_) { /* non-fatal */ }
|
||||
|
||||
logger.info('Quote converted to event', { adminId, quoteId: quote.id, eventId: result.eventId });
|
||||
return result;
|
||||
|
||||
@@ -96,7 +96,7 @@ function clearSettingsCache() {
|
||||
*/
|
||||
function isAuthenticated(req) {
|
||||
try {
|
||||
const slugMatch = req.path.match(/\/api\/(?:gallery|secure-images)\/([^\/]+)/);
|
||||
const slugMatch = req.path.match(/\/api\/(?:gallery|secure-images)\/([^/]+)/);
|
||||
const slug = slugMatch ? slugMatch[1] : req.requestedSlug;
|
||||
const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug);
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
@@ -771,7 +771,7 @@ class RestoreService {
|
||||
* Download backup from S3
|
||||
*/
|
||||
async downloadFromS3(s3Url, manifest, options) {
|
||||
const s3PathMatch = s3Url.match(/^s3:\/\/([^\/]+)\/(.+)$/);
|
||||
const s3PathMatch = s3Url.match(/^s3:\/\/([^/]+)\/(.+)$/);
|
||||
if (!s3PathMatch) {
|
||||
throw new Error('Invalid S3 URL format');
|
||||
}
|
||||
@@ -930,7 +930,7 @@ class RestoreService {
|
||||
/**
|
||||
* Perform database-only restore
|
||||
*/
|
||||
async performDatabaseRestore(backupPath, manifest, options) {
|
||||
async performDatabaseRestore(backupPath, manifest, _options) {
|
||||
this.updateProgress('Restoring database...');
|
||||
|
||||
const dbBackupFile = manifest.database.backup_file;
|
||||
@@ -1524,7 +1524,8 @@ END $$;`
|
||||
try {
|
||||
// Read backup manifest
|
||||
const manifestPath = path.join(preRestoreBackupPath, 'backup-manifest.json');
|
||||
const backupManifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
|
||||
// Parsed for its side effect: throws if the manifest is missing/corrupt.
|
||||
JSON.parse(await fs.readFile(manifestPath, 'utf8'));
|
||||
|
||||
// Restore database if backed up
|
||||
const dbBackupPath = path.join(preRestoreBackupPath, 'database.sql.gz');
|
||||
@@ -1622,7 +1623,7 @@ END $$;`
|
||||
* Download file from S3
|
||||
*/
|
||||
async downloadFileFromS3(s3Url, localPath, s3Config) {
|
||||
const s3PathMatch = s3Url.match(/^s3:\/\/([^\/]+)\/(.+)$/);
|
||||
const s3PathMatch = s3Url.match(/^s3:\/\/([^/]+)\/(.+)$/);
|
||||
if (!s3PathMatch) {
|
||||
throw new Error('Invalid S3 URL format');
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
const crypto = require('crypto');
|
||||
const sharp = require('sharp');
|
||||
const { db } = require('../database/db');
|
||||
const watermarkService = require('./watermarkService');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
|
||||
@@ -203,18 +203,18 @@ const parseSettingValue = (value, type) => {
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'boolean':
|
||||
return parseBooleanInput(value, false);
|
||||
case 'number':
|
||||
return parseNumberInput(value, 0);
|
||||
case 'json':
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return value;
|
||||
case 'boolean':
|
||||
return parseBooleanInput(value, false);
|
||||
case 'number':
|
||||
return parseNumberInput(value, 0);
|
||||
case 'json':
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -230,14 +230,14 @@ const serializeSettingValue = (value, type) => {
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'boolean':
|
||||
return String(value === true || value === 'true' || value === 1);
|
||||
case 'number':
|
||||
return String(value);
|
||||
case 'json':
|
||||
return JSON.stringify(value);
|
||||
default:
|
||||
return String(value);
|
||||
case 'boolean':
|
||||
return String(value === true || value === 'true' || value === 1);
|
||||
case 'number':
|
||||
return String(value);
|
||||
case 'json':
|
||||
return JSON.stringify(value);
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('S3StorageAdapter', () => {
|
||||
});
|
||||
|
||||
it('should configure for MinIO with path style', () => {
|
||||
const minioStorage = new S3StorageAdapter({
|
||||
new S3StorageAdapter({
|
||||
bucket: 'test-bucket',
|
||||
endpoint: 'http://localhost:9000',
|
||||
forcePathStyle: true,
|
||||
|
||||
@@ -64,7 +64,7 @@ process.on('uncaughtException', (error) => {
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
logger.error('Unhandled rejection in worker manager:', reason);
|
||||
});
|
||||
|
||||
|
||||
@@ -72,11 +72,11 @@ function matchFilter(filter, payload) {
|
||||
// Strict equality: a filter {value: 0} must NOT match false/''/null (loose ==
|
||||
// conflated them). Authors must therefore match the payload's actual type.
|
||||
switch (op) {
|
||||
case 'neq': return actual !== value;
|
||||
case 'truthy': return Boolean(actual);
|
||||
case 'falsy': return !actual;
|
||||
case 'eq':
|
||||
default: return actual === value;
|
||||
case 'neq': return actual !== value;
|
||||
case 'truthy': return Boolean(actual);
|
||||
case 'falsy': return !actual;
|
||||
case 'eq':
|
||||
default: return actual === value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,84 +125,84 @@ async function advanceRun(runId) {
|
||||
|
||||
try {
|
||||
switch (node.type) {
|
||||
case 'trigger': {
|
||||
case 'trigger': {
|
||||
const e = outEdge(edges, currentKey, null);
|
||||
nextKey = e ? e.to_node : null;
|
||||
await recordStep(runId, node, 'done', null);
|
||||
break;
|
||||
}
|
||||
case 'condition':
|
||||
case 'branch': {
|
||||
const cond = registry.getCondition(node.config?.condition || 'expr');
|
||||
const result = cond ? await cond(ctx) : false;
|
||||
const handle = result ? (node.config?.trueHandle || 'yes') : (node.config?.falseHandle || 'no');
|
||||
const e = outEdge(edges, currentKey, handle) || outEdge(edges, currentKey, result ? 'true' : 'false');
|
||||
nextKey = e ? e.to_node : null;
|
||||
await recordStep(runId, node, 'done', { result, handle });
|
||||
break;
|
||||
}
|
||||
case 'loop': {
|
||||
const counterKey = `__loop_${node.node_key}`;
|
||||
const count = (Number(context.vars[counterKey]) || 0) + 1;
|
||||
context.vars[counterKey] = count;
|
||||
const max = Number(node.config?.maxIterations ?? node.config?.max ?? 3);
|
||||
const handle = count > max ? (node.config?.exitHandle || 'exit') : (node.config?.loopHandle || 'loop');
|
||||
const e = outEdge(edges, currentKey, handle);
|
||||
nextKey = e ? e.to_node : null;
|
||||
await recordStep(runId, node, 'done', { count, max, handle });
|
||||
break;
|
||||
}
|
||||
case 'wait': {
|
||||
// Dry-run (test-fire): don't park — pass straight through so the whole
|
||||
// flow runs in one shot, recording what it WOULD have waited for.
|
||||
if (context.vars.__dryRun) {
|
||||
const e = outEdge(edges, currentKey, null);
|
||||
nextKey = e ? e.to_node : null;
|
||||
await recordStep(runId, node, 'done', null);
|
||||
await recordStep(runId, node, 'skipped', { dryRun: true, wouldWaitUntil: computeWakeAt(node.config, context.vars) });
|
||||
break;
|
||||
}
|
||||
case 'condition':
|
||||
case 'branch': {
|
||||
const cond = registry.getCondition(node.config?.condition || 'expr');
|
||||
const result = cond ? await cond(ctx) : false;
|
||||
const handle = result ? (node.config?.trueHandle || 'yes') : (node.config?.falseHandle || 'no');
|
||||
const e = outEdge(edges, currentKey, handle) || outEdge(edges, currentKey, result ? 'true' : 'false');
|
||||
const wakeAt = computeWakeAt(node.config, context.vars);
|
||||
await db('workflow_runs').where({ id: runId })
|
||||
.update({ status: 'waiting', wake_at: wakeAt, current_node: currentKey, context: JSON.stringify(context) });
|
||||
await recordStep(runId, node, 'waiting', { wake_at: wakeAt });
|
||||
return; // paused — scheduler resumes when wake_at passes
|
||||
}
|
||||
case 'gate': {
|
||||
// Dry-run (test-fire): auto-take the 'confirm' path so the escalation
|
||||
// is exercised end-to-end, without creating an approval / emailing.
|
||||
if (context.vars.__dryRun) {
|
||||
const e = outEdge(edges, currentKey, 'confirm') || outEdge(edges, currentKey, null);
|
||||
nextKey = e ? e.to_node : null;
|
||||
await recordStep(runId, node, 'done', { result, handle });
|
||||
await recordStep(runId, node, 'skipped', { dryRun: true, gateAutoConfirm: true });
|
||||
break;
|
||||
}
|
||||
case 'loop': {
|
||||
const counterKey = `__loop_${node.node_key}`;
|
||||
const count = (Number(context.vars[counterKey]) || 0) + 1;
|
||||
context.vars[counterKey] = count;
|
||||
const max = Number(node.config?.maxIterations ?? node.config?.max ?? 3);
|
||||
const handle = count > max ? (node.config?.exitHandle || 'exit') : (node.config?.loopHandle || 'loop');
|
||||
const e = outEdge(edges, currentKey, handle);
|
||||
nextKey = e ? e.to_node : null;
|
||||
await recordStep(runId, node, 'done', { count, max, handle });
|
||||
break;
|
||||
}
|
||||
case 'wait': {
|
||||
// Dry-run (test-fire): don't park — pass straight through so the whole
|
||||
// flow runs in one shot, recording what it WOULD have waited for.
|
||||
if (context.vars.__dryRun) {
|
||||
const e = outEdge(edges, currentKey, null);
|
||||
nextKey = e ? e.to_node : null;
|
||||
await recordStep(runId, node, 'skipped', { dryRun: true, wouldWaitUntil: computeWakeAt(node.config, context.vars) });
|
||||
break;
|
||||
}
|
||||
const wakeAt = computeWakeAt(node.config, context.vars);
|
||||
await db('workflow_runs').where({ id: runId })
|
||||
.update({ status: 'waiting', wake_at: wakeAt, current_node: currentKey, context: JSON.stringify(context) });
|
||||
await recordStep(runId, node, 'waiting', { wake_at: wakeAt });
|
||||
return; // paused — scheduler resumes when wake_at passes
|
||||
}
|
||||
case 'gate': {
|
||||
// Dry-run (test-fire): auto-take the 'confirm' path so the escalation
|
||||
// is exercised end-to-end, without creating an approval / emailing.
|
||||
if (context.vars.__dryRun) {
|
||||
const e = outEdge(edges, currentKey, 'confirm') || outEdge(edges, currentKey, null);
|
||||
nextKey = e ? e.to_node : null;
|
||||
await recordStep(runId, node, 'skipped', { dryRun: true, gateAutoConfirm: true });
|
||||
break;
|
||||
}
|
||||
await db('workflow_runs').where({ id: runId })
|
||||
.update({ status: 'waiting', wake_at: gateTimeout(node.config), current_node: currentKey, context: JSON.stringify(context) });
|
||||
await recordStep(runId, node, 'waiting', { gate: true });
|
||||
// Optional setup hook (create approval + send admin email) — registered
|
||||
// by the approval phase. Engine still pauses cleanly without it.
|
||||
const setup = registry.getAction('gate_setup');
|
||||
if (setup) {
|
||||
try { await setup(ctx); } catch (e) { logger.error('[workflow] gate setup failed', { runId, error: e.message }); }
|
||||
}
|
||||
return; // paused — an approval (email or inbox) resumes via resumeRun
|
||||
}
|
||||
case 'action':
|
||||
case 'webhook': {
|
||||
const actionKey = node.config?.action || (node.type === 'webhook' ? 'webhook' : 'noop');
|
||||
const action = registry.getAction(actionKey);
|
||||
const result = action ? (await action(ctx)) || {} : { skipped: true, reason: `unknown action ${actionKey}` };
|
||||
if (result.set && typeof result.set === 'object') Object.assign(context.vars, result.set);
|
||||
const e = outEdge(edges, currentKey, null);
|
||||
nextKey = e ? e.to_node : null;
|
||||
await recordStep(runId, node, result.skipped ? 'skipped' : 'done', result);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
await recordStep(runId, node, 'skipped', { reason: `unknown node type ${node.type}` });
|
||||
const e = outEdge(edges, currentKey, null);
|
||||
nextKey = e ? e.to_node : null;
|
||||
await db('workflow_runs').where({ id: runId })
|
||||
.update({ status: 'waiting', wake_at: gateTimeout(node.config), current_node: currentKey, context: JSON.stringify(context) });
|
||||
await recordStep(runId, node, 'waiting', { gate: true });
|
||||
// Optional setup hook (create approval + send admin email) — registered
|
||||
// by the approval phase. Engine still pauses cleanly without it.
|
||||
const setup = registry.getAction('gate_setup');
|
||||
if (setup) {
|
||||
try { await setup(ctx); } catch (e) { logger.error('[workflow] gate setup failed', { runId, error: e.message }); }
|
||||
}
|
||||
return; // paused — an approval (email or inbox) resumes via resumeRun
|
||||
}
|
||||
case 'action':
|
||||
case 'webhook': {
|
||||
const actionKey = node.config?.action || (node.type === 'webhook' ? 'webhook' : 'noop');
|
||||
const action = registry.getAction(actionKey);
|
||||
const result = action ? (await action(ctx)) || {} : { skipped: true, reason: `unknown action ${actionKey}` };
|
||||
if (result.set && typeof result.set === 'object') Object.assign(context.vars, result.set);
|
||||
const e = outEdge(edges, currentKey, null);
|
||||
nextKey = e ? e.to_node : null;
|
||||
await recordStep(runId, node, result.skipped ? 'skipped' : 'done', result);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
await recordStep(runId, node, 'skipped', { reason: `unknown node type ${node.type}` });
|
||||
const e = outEdge(edges, currentKey, null);
|
||||
nextKey = e ? e.to_node : null;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
await recordStep(runId, node, 'failed', null, err.message);
|
||||
|
||||
@@ -28,15 +28,15 @@ registerCondition('expr', async (ctx) => {
|
||||
const { field, op = 'truthy', value } = ctx.node.config || {};
|
||||
const actual = field != null ? ctx.vars[field] : undefined;
|
||||
switch (op) {
|
||||
case 'eq': return actual == value; // eslint-disable-line eqeqeq
|
||||
case 'neq': return actual != value; // eslint-disable-line eqeqeq
|
||||
case 'gt': return Number(actual) > Number(value);
|
||||
case 'gte': return Number(actual) >= Number(value);
|
||||
case 'lt': return Number(actual) < Number(value);
|
||||
case 'lte': return Number(actual) <= Number(value);
|
||||
case 'falsy': return !actual;
|
||||
case 'truthy':
|
||||
default: return Boolean(actual);
|
||||
case 'eq': return actual == value; // eslint-disable-line eqeqeq
|
||||
case 'neq': return actual != value; // eslint-disable-line eqeqeq
|
||||
case 'gt': return Number(actual) > Number(value);
|
||||
case 'gte': return Number(actual) >= Number(value);
|
||||
case 'lt': return Number(actual) < Number(value);
|
||||
case 'lte': return Number(actual) <= Number(value);
|
||||
case 'falsy': return !actual;
|
||||
case 'truthy':
|
||||
default: return Boolean(actual);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user