chore(migrations): consolidate CRM migrations 102-143 + extract email-template seeds to self-heal services

Replaces what would have been 42 individual in-flight migrations
(102→143 on feat/crm) with one consolidated migration that creates
every CRM table in its final shape — no ALTER chains. Coexists with
upstream's pre-existing 102-106 by filename suffix; the runner sorts
within same-number groups.

Tables consolidated:
  - business_profile + business_bank_accounts (issuer block, fonts,
    PDF layout knobs, tax_id, timezone)
  - payment_term_templates (legacy) + payment_net_days_templates +
    payment_timing_templates (124's split)
  - quotes / quote_line_items / quote_line_item_presets / quote_action_tokens
  - invoices / invoice_line_items / invoice_payment_log /
    invoice_payment_check_tokens
  - contracts / contract_blocks (13 system blocks seeded) /
    contract_block_inclusions / contract_action_tokens
  - event_payment_plans, customer_hour_entries, document_sequences

ALTER on upstream tables (hasColumn-guarded):
  - events: quote_id, calendar columns (event_time_*, is_full_day),
    event_reminder_*
  - customer_accounts: billing_cadence/cycle_day, country_name,
    feature_hours_logging, hourly_rate_minor

Seeds:
  - RBAC perms (quotes/bills/contracts .view/.manage) + customers.create
    split into edit + events (mig 134)
  - Feature flags (quotes, bills, contracts, hoursLogging, taxReport,
    calendar, calendarBooking, reminderEmails, crmDevelopment, messaging
    — all default OFF)
  - 30+ CRM app_settings rows (skonto/QR/reminder windows, payment
    defaults, installment defaults, ToS, event reminder defaults)
  - 4 + 5 + 4 payment-term system rows across the legacy + split tables

Email-template content moves out of the schema diff into three
runtime self-heal service files that idempotently create missing
rows + backfill empty translations on first access (per the maintainer's
"never ship compensation migrations" rule):

  - backend/src/services/crmEmailTemplates.js (NEW) — quote_sent,
    quote_accepted_*, quote_declined_admin, invoice_sent,
    invoice_reminder_first/second, invoice_paid_receipt,
    invoice_cancelled, invoice_payment_check,
    invoice_paid_admin_notification, storno_issued
  - backend/src/services/contractEmailTemplates.js — contract_sent,
    contract_fully_signed, contract_signed_admin_notification
  - backend/src/services/eventReminderTemplates.js — event_reminder_default
    + per-event-type variants

Smoke-tested on fresh sqlite DB: 84 migrations apply cleanly,
all CRM tables present, seeds populated.
This commit is contained in:
Luca
2026-05-26 18:18:15 +02:00
parent b5e7f9cec1
commit 60abe8c76d
4 changed files with 2811 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,181 @@
/**
* Contract email template definitions, extracted from migration 130
* so both the migration AND a runtime seeder can read from the same
* source. The runtime seeder is needed because an admin who ran
* migration 130 BEFORE we added contract_fully_signed to it won't
* have that template in their email_templates table — yet the
* dual-party send in contractService.recordAdminCountersignature
* depends on it. Per the maintainer's "never ship compensation
* migrations" rule, we self-heal at runtime instead.
*
* `ensureContractEmailTemplatesSeeded()` is idempotent — call it as
* often as you like, only missing rows get inserted. Module-level
* boolean caches the "all templates verified" state so the check
* is free after the first call in a process.
*/
const CONTRACT_EMAIL_TEMPLATES = {
contract_sent: {
category: 'contracts', feature_flag: 'contracts',
variables: ['contract_number', 'customer_name', 'response_url', 'title', 'event_name', 'valid_until'],
en: {
subject: 'Contract {{contract_number}} ready for your signature',
body_html: `<h2>Contract {{contract_number}}</h2>
<p>Dear {{customer_name}},</p>
<p>Please find the contract {{contract_number}}{{#if title}} — "{{title}}"{{/if}}{{#if event_name}} for "{{event_name}}"{{/if}} attached.</p>
<p>You can review and sign the contract directly in your browser via the link below:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{{response_url}}" class="button">Review &amp; sign contract</a>
</p>
<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}}`,
},
de: {
subject: 'Vertrag {{contract_number}} zur Unterzeichnung bereit',
body_html: `<h2>Vertrag {{contract_number}}</h2>
<p>Sehr geehrte/r {{customer_name}},</p>
<p>im Anhang finden Sie den Vertrag {{contract_number}}{{#if title}} „{{title}}"{{/if}}{{#if event_name}} für „{{event_name}}"{{/if}}.</p>
<p>Sie können den Vertrag direkt online prüfen und unterzeichnen:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{{response_url}}" class="button">Vertrag prüfen &amp; unterzeichnen</a>
</p>
<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}}`,
},
},
contract_fully_signed: {
category: 'contracts', feature_flag: 'contracts',
variables: ['contract_number', 'customer_name', 'title'],
en: {
subject: 'Contract {{contract_number}} fully signed',
body_html: `<h2>Contract {{contract_number}} — fully signed</h2>
<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.`,
},
de: {
subject: 'Vertrag {{contract_number}} vollständig unterzeichnet',
body_html: `<h2>Vertrag {{contract_number}} vollständig unterzeichnet</h2>
<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.`,
},
},
contract_signed_admin_notification: {
category: 'contracts', feature_flag: 'contracts',
variables: ['contract_number', 'customer_email', 'signed_customer_name', 'admin_dashboard_url'],
en: {
subject: 'Contract {{contract_number}} signed by {{customer_email}}',
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}}`,
},
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}}`,
},
},
};
// Cache the "all-seeded" state so the check is free after the first
// successful run. Reset to false on insertion failure so subsequent
// calls retry.
let _seeded = false;
/**
* Insert any missing contract email templates into email_templates +
* email_template_translations. Safe to call concurrently — each
* row's existence check happens inline before insert.
*
* Returns the list of templateKeys that were newly inserted (for
* logging / diagnostics). Empty array = all templates already exist.
*/
async function ensureContractEmailTemplatesSeeded(db, logger) {
if (_seeded) return [];
if (!(await db.schema.hasTable('email_templates'))) return [];
const cols = await db('email_templates').columnInfo();
const hasTranslationsTable = await db.schema.hasTable('email_template_translations');
const newlyInserted = [];
for (const [templateKey, def] of Object.entries(CONTRACT_EMAIL_TEMPLATES)) {
const existing = await db('email_templates').where({ template_key: templateKey }).first();
if (existing) continue;
const enContent = def.en;
const masterRow = {
template_key: templateKey,
variables: JSON.stringify(def.variables),
};
if ('category' in cols) masterRow.category = def.category;
if ('subcategory' in cols) masterRow.subcategory = null;
if ('feature_flag' in cols) masterRow.feature_flag = def.feature_flag;
if ('created_at' in cols) masterRow.created_at = new Date();
if ('updated_at' in cols) masterRow.updated_at = new Date();
// Fill any subject_<lang> / body_html_<lang> / body_text_<lang>
// shaped columns the install happens to have (legacy variants vs
// the modern email_template_translations table).
for (const colName of Object.keys(cols)) {
if (colName === 'subject' || /^subject_[a-z]{2,3}$/i.test(colName)) {
masterRow[colName] = enContent.subject;
} else if (colName === 'body_html' || /^body_html_[a-z]{2,3}$/i.test(colName)) {
masterRow[colName] = enContent.body_html;
} else if (colName === 'body_text' || /^body_text_[a-z]{2,3}$/i.test(colName)) {
masterRow[colName] = enContent.body_text;
}
}
try {
const inserted = await db('email_templates').insert(masterRow).returning('id');
const templateId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
if (hasTranslationsTable && templateId) {
for (const lang of ['en', 'de']) {
const content = def[lang];
if (!content) continue;
await db('email_template_translations').insert({
template_id: templateId,
language: lang,
subject: content.subject,
body_html: content.body_html,
body_text: content.body_text,
created_at: new Date(),
updated_at: new Date(),
});
}
}
newlyInserted.push(templateKey);
if (logger) {
logger.info(`Self-healed missing contract email template at runtime: ${templateKey}`);
}
} catch (err) {
// Keep _seeded=false so the next call retries. Don't throw —
// the caller (queueEmail upstream) will surface its own error
// if the template still can't be looked up.
if (logger) {
logger.error(`Failed to seed contract email template ${templateKey}`, {
message: err.message,
});
}
return newlyInserted;
}
}
_seeded = true;
return newlyInserted;
}
module.exports = {
CONTRACT_EMAIL_TEMPLATES,
ensureContractEmailTemplatesSeeded,
};
+452
View File
@@ -0,0 +1,452 @@
/**
* CRM email template definitions (quotes / invoices / Storno / payment-check
* / paid-admin-notification) — runtime self-heal seeder.
*
* Original sources: migrations 102 (8 templates), 112 (quote_accepted_customer),
* 116 (invoice_payment_check), 122 (storno_issued), 127 (invoice_paid_admin_notification).
*
* The consolidated migration (107_crm_consolidated.js) owns SCHEMA only;
* this service file owns CONTENT. `ensureCrmEmailTemplatesSeeded()` is
* idempotent — call it from server boot, GET /admin/email/templates,
* and any code path about to send one of these templates. Missing
* rows get inserted; existing rows are LEFT ALONE so admin edits are
* never overwritten.
*
* Same pattern as contractEmailTemplates.js + eventReminderTemplates.js
* — per the maintainer's "never ship compensation migrations" rule,
* we self-heal at runtime instead of bolting content into the schema diff.
*
* Translations: en + de hand-translated; fr/nl/pt/ru intentionally
* absent. Renderer falls through to en until admin overrides via the
* Templates UI. Flag for native review in the PR description.
*/
const CRM_EMAIL_TEMPLATES = {
quote_sent: {
category: 'quotes', feature_flag: 'quotes',
variables: ['quote_number', 'customer_name', 'response_url', 'accept_url', 'decline_url',
'valid_until', 'event_name', 'total_amount'],
en: {
subject: 'Your quote {{quote_number}} is ready',
body_html: `<h2>Quote {{quote_number}}</h2>
<p>Dear {{customer_name}},</p>
<p>Please find the attached quote {{quote_number}}{{#if event_name}} for "{{event_name}}"{{/if}}. Total amount: <strong>{{total_amount}}</strong>.</p>
<p>You can accept or decline this quote directly via the buttons below:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{{accept_url}}" class="button">Accept quote</a>
&nbsp;
<a href="{{decline_url}}" style="display:inline-block;padding:10px 20px;color:#666;text-decoration:underline;">Decline</a>
</p>
<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}}`,
},
de: {
subject: 'Ihr Angebot {{quote_number}} ist bereit',
body_html: `<h2>Angebot {{quote_number}}</h2>
<p>Sehr geehrte/r {{customer_name}},</p>
<p>im Anhang finden Sie das Angebot {{quote_number}}{{#if event_name}} für "{{event_name}}"{{/if}}. Gesamtbetrag: <strong>{{total_amount}}</strong>.</p>
<p>Sie können das Angebot direkt über die Schaltflächen unten annehmen oder ablehnen:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{{accept_url}}" class="button">Angebot annehmen</a>
&nbsp;
<a href="{{decline_url}}" style="display:inline-block;padding:10px 20px;color:#666;text-decoration:underline;">Ablehnen</a>
</p>
<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}}`,
},
},
quote_accepted_admin: {
category: 'quotes', feature_flag: 'quotes',
variables: ['quote_number', 'customer_email', 'event_name', 'total_amount', 'admin_dashboard_url'],
en: {
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}}`,
},
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}}`,
},
},
quote_declined_admin: {
category: 'quotes', feature_flag: 'quotes',
variables: ['quote_number', 'customer_email', 'event_name', 'admin_dashboard_url'],
en: {
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}}`,
},
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}}`,
},
},
invoice_sent: {
category: 'billing', feature_flag: 'bills',
variables: ['invoice_number', 'customer_name', 'event_name', 'total_amount', 'due_date',
'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}}.`,
},
de: {
subject: 'Rechnung {{invoice_number}} — {{total_amount}}',
body_html: `<h2>Rechnung {{invoice_number}}</h2><p>Sehr geehrte/r {{customer_name}},</p>
<p>im Anhang finden Sie die Rechnung {{invoice_number}}{{#if event_name}} für "{{event_name}}"{{/if}}.</p>
<p><strong>Betrag:</strong> {{total_amount}}<br><strong>Fällig:</strong> {{due_date}}{{#if installment_label}}<br><strong>Teilzahlung:</strong> {{installment_label}} ({{installment_index}}/{{installment_total}}){{/if}}</p>
<p>Die Zahlungsdetails und IBAN finden Sie auf dem beigefügten PDF.</p>`,
body_text: `Rechnung {{invoice_number}}: {{total_amount}}, fällig {{due_date}}.`,
},
},
invoice_reminder_first: {
category: 'billing', feature_flag: 'bills',
variables: ['invoice_number', 'customer_name', 'total_amount', 'due_date', 'days_overdue'],
en: {
subject: 'Reminder: invoice {{invoice_number}} is overdue',
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}}.`,
},
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}}.`,
},
},
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'],
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}}.`,
},
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}}.`,
},
},
invoice_paid_receipt: {
category: 'billing', feature_flag: 'bills',
variables: ['invoice_number', 'customer_name', 'paid_amount', 'paid_at'],
en: {
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}}.`,
},
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}}.`,
},
},
invoice_cancelled: {
category: 'billing', feature_flag: 'bills',
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.`,
},
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.`,
},
},
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>
<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}},
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>
<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}},
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>
<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;">
<tr>
<td style="padding: 0 6px;">
<a href="{{paid_url}}" style="background: #16a34a; color: #fff; padding: 10px 18px; border-radius: 6px; text-decoration: none; font-weight: 600; display: inline-block;">Paid in full</a>
</td>
{{#if has_skonto}}<td style="padding: 0 6px;">
<a href="{{skonto_url}}" style="background: #0d9488; color: #fff; padding: 10px 18px; border-radius: 6px; text-decoration: none; font-weight: 600; display: inline-block;">Paid with Skonto ({{skonto_amount}})</a>
</td>{{/if}}
<td style="padding: 0 6px;">
<a href="{{partial_url}}" style="background: #2563eb; color: #fff; padding: 10px 18px; border-radius: 6px; text-decoration: none; font-weight: 600; display: inline-block;">Partially paid</a>
</td>
<td style="padding: 0 6px;">
<a href="{{unpaid_url}}" style="background: #dc2626; color: #fff; padding: 10px 18px; border-radius: 6px; text-decoration: none; font-weight: 600; display: inline-block;">Not paid yet</a>
</td>
</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
Invoice {{invoice_number}} for {{customer_name}}{{#if event_name}} ({{event_name}}){{/if}} was due on {{due_date}}. Total: {{total_amount}}.
Confirm what was received:
Paid in full: {{paid_url}}{{#if has_skonto}}
Paid with Skonto ({{skonto_amount}}): {{skonto_url}}{{/if}}
Partial: {{partial_url}}
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>
<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;">
<tr>
<td style="padding: 0 6px;">
<a href="{{paid_url}}" style="background: #16a34a; color: #fff; padding: 10px 18px; border-radius: 6px; text-decoration: none; font-weight: 600; display: inline-block;">Vollständig bezahlt</a>
</td>
{{#if has_skonto}}<td style="padding: 0 6px;">
<a href="{{skonto_url}}" style="background: #0d9488; color: #fff; padding: 10px 18px; border-radius: 6px; text-decoration: none; font-weight: 600; display: inline-block;">Mit Skonto bezahlt ({{skonto_amount}})</a>
</td>{{/if}}
<td style="padding: 0 6px;">
<a href="{{partial_url}}" style="background: #2563eb; color: #fff; padding: 10px 18px; border-radius: 6px; text-decoration: none; font-weight: 600; display: inline-block;">Teilweise bezahlt</a>
</td>
<td style="padding: 0 6px;">
<a href="{{unpaid_url}}" style="background: #dc2626; color: #fff; padding: 10px 18px; border-radius: 6px; text-decoration: none; font-weight: 600; display: inline-block;">Nicht bezahlt</a>
</td>
</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
Rechnung {{invoice_number}} für {{customer_name}}{{#if event_name}} ({{event_name}}){{/if}} war am {{due_date}} fällig. Gesamtbetrag: {{total_amount}}.
Bitte bestätigen:
Vollständig bezahlt: {{paid_url}}{{#if has_skonto}}
Mit Skonto bezahlt ({{skonto_amount}}): {{skonto_url}}{{/if}}
Teilweise: {{partial_url}}
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>
<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.`,
},
de: {
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.`,
},
},
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>
<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>
<tr><td style="color: #666;">Paid total</td><td><strong>{{paid_amount}}</strong></td></tr>
{{#if skonto_applied}}<tr><td style="color: #0d9488;">Paid with Skonto ({{skonto_percent}}%)</td><td style="color: #0d9488;"><strong>{{skonto_discount_amount}}</strong></td></tr>{{/if}}
{{#if payment_method}}<tr><td style="color: #666;">Payment method</td><td>{{payment_method}}</td></tr>{{/if}}
{{#if payment_reference}}<tr><td style="color: #666;">Reference</td><td>{{payment_reference}}</td></tr>{{/if}}
<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
Invoice {{invoice_number}} for {{customer_name}}{{#if event_name}} ({{event_name}}){{/if}} has been marked as fully paid.
Total invoice amount: {{total_amount}}
Paid total: {{paid_amount}}{{#if skonto_applied}}
Paid with Skonto ({{skonto_percent}}%): -{{skonto_discount_amount}}{{/if}}{{#if payment_method}}
Payment method: {{payment_method}}{{/if}}{{#if payment_reference}}
Reference: {{payment_reference}}{{/if}}
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>
<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>
<tr><td style="color: #666;">Eingezahlt</td><td><strong>{{paid_amount}}</strong></td></tr>
{{#if skonto_applied}}<tr><td style="color: #0d9488;">Mit Skonto bezahlt ({{skonto_percent}}%)</td><td style="color: #0d9488;"><strong>{{skonto_discount_amount}}</strong></td></tr>{{/if}}
{{#if payment_method}}<tr><td style="color: #666;">Zahlungsart</td><td>{{payment_method}}</td></tr>{{/if}}
{{#if payment_reference}}<tr><td style="color: #666;">Referenz</td><td>{{payment_reference}}</td></tr>{{/if}}
<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
Rechnung {{invoice_number}} für {{customer_name}}{{#if event_name}} ({{event_name}}){{/if}} wurde als vollständig bezahlt markiert.
Rechnungsbetrag: {{total_amount}}
Eingezahlt: {{paid_amount}}{{#if skonto_applied}}
Mit Skonto bezahlt ({{skonto_percent}}%): -{{skonto_discount_amount}}{{/if}}{{#if payment_method}}
Zahlungsart: {{payment_method}}{{/if}}{{#if payment_reference}}
Referenz: {{payment_reference}}{{/if}}
Erfasst am: {{paid_at}}
Automatische Benachrichtigung — keine Aktion erforderlich.`,
},
},
};
let _seeded = false;
/**
* Insert any missing CRM email templates into email_templates +
* email_template_translations. Idempotent: existing template_keys are
* left alone so admin customisations are never clobbered.
*
* Returns the list of templateKeys newly inserted (for logging).
*/
async function ensureCrmEmailTemplatesSeeded(db, logger) {
if (_seeded) return [];
if (!(await db.schema.hasTable('email_templates'))) return [];
const cols = await db('email_templates').columnInfo();
const hasTranslationsTable = await db.schema.hasTable('email_template_translations');
const newlyInserted = [];
for (const [templateKey, def] of Object.entries(CRM_EMAIL_TEMPLATES)) {
const existing = await db('email_templates').where({ template_key: templateKey }).first();
if (existing) continue;
const enContent = def.en;
const masterRow = {
template_key: templateKey,
variables: JSON.stringify(def.variables),
};
if ('category' in cols) masterRow.category = def.category;
if ('subcategory' in cols) masterRow.subcategory = null;
if ('feature_flag' in cols) masterRow.feature_flag = def.feature_flag;
if ('created_at' in cols) masterRow.created_at = new Date();
if ('updated_at' in cols) masterRow.updated_at = new Date();
// Fill legacy subject_<lang> / body_html_<lang> / body_text_<lang>
// columns when present (the modern translations table is populated
// below regardless).
for (const colName of Object.keys(cols)) {
if (colName === 'subject' || /^subject_[a-z]{2,3}$/i.test(colName)) {
masterRow[colName] = enContent.subject;
} else if (colName === 'body_html' || /^body_html_[a-z]{2,3}$/i.test(colName)) {
masterRow[colName] = enContent.body_html;
} else if (colName === 'body_text' || /^body_text_[a-z]{2,3}$/i.test(colName)) {
masterRow[colName] = enContent.body_text;
}
}
try {
const inserted = await db('email_templates').insert(masterRow).returning('id');
const templateId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
if (hasTranslationsTable && templateId) {
for (const lang of ['en', 'de']) {
const content = def[lang];
if (!content) continue;
await db('email_template_translations').insert({
template_id: templateId,
language: lang,
subject: content.subject,
body_html: content.body_html,
body_text: content.body_text,
created_at: new Date(),
updated_at: new Date(),
});
}
}
newlyInserted.push(templateKey);
if (logger) {
logger.info(`Self-healed missing CRM email template at runtime: ${templateKey}`);
}
} catch (err) {
// Keep _seeded=false so the next call retries. Don't throw —
// caller surfaces its own error if the template still can't be
// looked up.
if (logger) {
logger.error(`Failed to seed CRM email template ${templateKey}`, {
message: err.message,
});
}
return newlyInserted;
}
}
_seeded = true;
return newlyInserted;
}
module.exports = {
CRM_EMAIL_TEMPLATES,
ensureCrmEmailTemplatesSeeded,
};
@@ -0,0 +1,333 @@
/**
* Pre-event customer reminder templates — definitions + runtime self-heal.
*
* Migration 143 originally seeded an empty `event_reminder_default` row.
* That left admins staring at a blank editor and had no per-event-type
* variants. Per the maintainer's "never ship compensation migrations" rule
* (see contractEmailTemplates.js for the same pattern), we self-heal at
* runtime instead of bolting on a follow-up migration.
*
* `ensureEventReminderTemplatesSeeded(db, logger)` is idempotent — call
* it as often as you like:
* - Missing template_keys get inserted with EN+DE example content.
* - Existing template_keys whose EN translation is entirely empty
* (the legacy migration-143 case) are backfilled with the example
* content. Translations that already have any subject/body content
* are LEFT ALONE so an admin's customisations never get clobbered.
*
* Process-level boolean caches the "all templates verified" state once
* we've made one successful pass, so the cron's hourly retick is free.
*
* Variables expected on every template: customer_name, event_name,
* event_date, event_type, days_before, business_name. Keep this list in
* sync with eventReminderService.composePayload.
*
* Per-type template keys (`event_reminder_<slug_prefix>`) are seeded for
* the four SYSTEM event_types from migration 061: wedding, birthday,
* corporate, other. Admins who add custom event_types via the Event
* Types settings page get no seeded body — they author their own via
* the Reminder Emails tab (the "Default" pill on the sidebar makes
* obvious which types are still riding the catch-all).
*/
const VARIABLES = [
'customer_name', 'event_name', 'event_date',
'event_type', 'days_before', 'business_name',
];
// 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 EVENT_REMINDER_TEMPLATES = {
event_reminder_default: {
en: {
subject: 'Reminder: {{event_name}} in {{days_before}} day(s)',
body_html: `<p>Hi {{customer_name}},</p>
<p>Just a quick reminder that <strong>{{event_name}}</strong> is coming up on <strong>{{event_date}}</strong> — about {{days_before}} day(s) from now.</p>
<p>A few things that help us hit the ground running on the day:</p>
<ul>
<li>Confirm the exact start time and address (a what3words pin works great).</li>
<li>Let us know if there is anything we should keep an eye on — VIPs, surprise moments, restricted areas.</li>
<li>Indoor venues: a small corner for equipment setup is a huge help.</li>
</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}}`,
},
de: {
subject: 'Erinnerung: {{event_name}} in {{days_before}} Tag(en)',
body_html: `<p>Hallo {{customer_name}},</p>
<p>kurze Erinnerung: <strong>{{event_name}}</strong> findet am <strong>{{event_date}}</strong> statt — in etwa {{days_before}} Tag(en).</p>
<p>Damit wir am Tag selbst sofort loslegen können, helfen uns folgende Punkte sehr:</p>
<ul>
<li>Genaue Startzeit und Adresse bestätigen (gerne auch ein what3words-Pin).</li>
<li>Kurz Bescheid geben, falls etwas besonders zu beachten ist — VIPs, Überraschungsmomente, abgesperrte Bereiche.</li>
<li>Bei Innen-Locations: eine kleine Ecke für den Equipment-Aufbau ist Gold wert.</li>
</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}}`,
},
},
event_reminder_wedding: {
en: {
subject: 'Your wedding on {{event_date}} — last details',
body_html: `<p>Dear {{customer_name}},</p>
<p>Your wedding day is almost here — <strong>{{event_date}}</strong>, in about {{days_before}} day(s). We are very much looking forward to it.</p>
<p>A short pre-day checklist so the photo coverage flows smoothly:</p>
<ul>
<li><strong>Timeline:</strong> a rough hour-by-hour run-of-day (getting ready → ceremony → portraits → reception → party) helps us anticipate every moment.</li>
<li><strong>Family shots:</strong> a short list of must-have group photos (with names) keeps the formals quick and stress-free.</li>
<li><strong>Getting-ready space:</strong> a room with natural light (window-side) makes a real difference.</li>
<li><strong>Surprises:</strong> let us know about any surprises so we are in the right place at the right moment — and won't accidentally spoil them.</li>
<li><strong>Logistics:</strong> ceremony start time, venue address, parking notes, and contact number for the day-of coordinator.</li>
</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}}`,
},
de: {
subject: 'Eure Hochzeit am {{event_date}} — letzte Details',
body_html: `<p>Liebe/r {{customer_name}},</p>
<p>euer grosser Tag steht fast vor der Tür — <strong>{{event_date}}</strong>, in etwa {{days_before}} Tag(en). Wir freuen uns sehr darauf.</p>
<p>Eine kurze Checkliste vor dem Tag, damit die fotografische Begleitung reibungslos läuft:</p>
<ul>
<li><strong>Ablauf:</strong> ein grober Stunden-Ablauf (Getting-Ready → Trauung → Portraits → Empfang → Party) hilft uns enorm, jeden Moment einzuplanen.</li>
<li><strong>Familienbilder:</strong> eine kurze Liste der Wunsch-Gruppenbilder (mit Namen) hält die Formalitäten knapp und entspannt.</li>
<li><strong>Getting-Ready-Raum:</strong> ein Zimmer mit Tageslicht (Fensterseite) macht einen riesigen Unterschied.</li>
<li><strong>Überraschungen:</strong> kurz Bescheid geben, damit wir zur richtigen Zeit am richtigen Ort sind — und nichts versehentlich verraten.</li>
<li><strong>Logistik:</strong> Beginn der Trauung, Adresse, Parkhinweise, Telefonnummer der Tages-Koordination.</li>
</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}}`,
},
},
event_reminder_birthday: {
en: {
subject: '{{event_name}} on {{event_date}} — quick check-in',
body_html: `<p>Hi {{customer_name}},</p>
<p>{{event_name}} is coming up on <strong>{{event_date}}</strong> — about {{days_before}} day(s) away. Quick check-in before the day:</p>
<ul>
<li><strong>Headcount:</strong> roughly how many guests should we expect? Helps us plan group shots and candid coverage.</li>
<li><strong>Schedule:</strong> when is the cake/song moment? We always want to be ready for that one.</li>
<li><strong>Theme or dress code:</strong> if there is one, let us know so we can match the vibe.</li>
<li><strong>Surprises:</strong> any surprise guests or moments we should keep quiet about?</li>
</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}}`,
},
de: {
subject: '{{event_name}} am {{event_date}} — kurze Rückfrage',
body_html: `<p>Hallo {{customer_name}},</p>
<p>{{event_name}} steht am <strong>{{event_date}}</strong> an — in etwa {{days_before}} Tag(en). Kurze Rückfrage vor dem Tag:</p>
<ul>
<li><strong>Personenzahl:</strong> wie viele Gäste werden in etwa kommen? Hilft uns bei Gruppenbildern und der Candid-Strecke.</li>
<li><strong>Ablauf:</strong> wann ist der Torten-/Ständchen-Moment? Den möchten wir auf keinen Fall verpassen.</li>
<li><strong>Motto oder Dresscode:</strong> falls vorhanden, gerne kurz Bescheid geben, damit wir die Stimmung treffen.</li>
<li><strong>Überraschungen:</strong> Gäste oder Momente, über die wir nicht reden sollten?</li>
</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}}`,
},
},
event_reminder_corporate: {
en: {
subject: 'Coverage prep: {{event_name}} on {{event_date}}',
body_html: `<p>Dear {{customer_name}},</p>
<p>{{event_name}} is on <strong>{{event_date}}</strong> — about {{days_before}} day(s) away. To make sure the coverage matches your goals, a few items to confirm:</p>
<ul>
<li><strong>Shot brief:</strong> what is the photography for — internal comms, press kit, social, website? It affects framing and crops.</li>
<li><strong>Agenda / run-of-show:</strong> who is speaking when, plus any moments worth flagging (awards, panels, Q&amp;A).</li>
<li><strong>VIPs &amp; brand:</strong> a short list of names to prioritise, plus the logo/colour direction so we keep the deck consistent.</li>
<li><strong>Access:</strong> entrance, loading dock if any, on-site contact for the morning. Photo IDs or accreditation needed?</li>
<li><strong>Confidentiality:</strong> any sessions that are strictly internal / no-photo?</li>
<li><strong>Delivery:</strong> rough turnaround you need (24h press selects, full gallery later)?</li>
</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}}`,
},
de: {
subject: 'Vorbereitung Bildbegleitung: {{event_name}} am {{event_date}}',
body_html: `<p>Sehr geehrte/r {{customer_name}},</p>
<p>{{event_name}} findet am <strong>{{event_date}}</strong> statt — in etwa {{days_before}} Tag(en). Damit die Bildstrecke euren Zielen entspricht, kurz folgende Punkte abstimmen:</p>
<ul>
<li><strong>Briefing:</strong> wofür sind die Bilder — interne Kommunikation, Pressekit, Social, Website? Hat Einfluss auf Bildausschnitt und Format.</li>
<li><strong>Agenda / Ablauf:</strong> wer spricht wann, sowie besondere Momente (Awards, Panels, Q&amp;A).</li>
<li><strong>VIPs &amp; Brand:</strong> kurze Liste der zu priorisierenden Personen, plus Logo-/Farbvorgaben für eine konsistente Bildsprache.</li>
<li><strong>Zugang:</strong> Eingang, ggf. Anlieferung, Ansprechperson am Morgen. Lichtbildausweis oder Akkreditierung nötig?</li>
<li><strong>Vertraulichkeit:</strong> Sessions, die ausschliesslich intern sind / kein Foto?</li>
<li><strong>Lieferung:</strong> grobe Vorgabe zur Turnaround-Zeit (24h Press-Selects, vollständige Galerie später)?</li>
</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}}`,
},
},
event_reminder_other: {
en: {
subject: '{{event_name}} on {{event_date}} — prep notes',
body_html: `<p>Hi {{customer_name}},</p>
<p>{{event_name}} is on <strong>{{event_date}}</strong> — about {{days_before}} day(s) away. A short prep note:</p>
<ul>
<li><strong>Start time &amp; address:</strong> please confirm both — even small changes matter for arrival/setup.</li>
<li><strong>Run-of-day:</strong> a rough timeline of the key moments (start, highlights, end) helps us be in the right place.</li>
<li><strong>Setup space:</strong> if indoors, a small corner for gear makes a real difference.</li>
<li><strong>Anything specific:</strong> people to prioritise, things to avoid, dress code, surprises — just let us know.</li>
</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}}`,
},
de: {
subject: '{{event_name}} am {{event_date}} — Vorbereitungs-Hinweise',
body_html: `<p>Hallo {{customer_name}},</p>
<p>{{event_name}} findet am <strong>{{event_date}}</strong> statt — in etwa {{days_before}} Tag(en). Kurz zur Vorbereitung:</p>
<ul>
<li><strong>Startzeit &amp; Adresse:</strong> bitte beides kurz bestätigen — auch kleine Änderungen sind für Anreise/Aufbau wichtig.</li>
<li><strong>Ablauf:</strong> ein grober Zeitplan der Schlüsselmomente (Start, Highlights, Ende) hilft uns bei der Positionierung.</li>
<li><strong>Aufbauplatz:</strong> bei Innen-Locations ist eine kleine Ecke fürs Equipment Gold wert.</li>
<li><strong>Besonderheiten:</strong> Personen, die im Fokus stehen sollen, Dinge, die vermieden werden sollen, Dresscode, Überraschungen — gerne kurz Bescheid geben.</li>
</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}}`,
},
},
};
let _seeded = false;
/**
* Idempotent seed/backfill for every entry in EVENT_REMINDER_TEMPLATES.
* Safe to call repeatedly — both at boot and inside the cron tick.
*
* Rules:
* - Missing template_key → insert master row + translations.
* - Existing template_key whose EN translation is entirely empty
* (subject + body_html + body_text all blank) → backfill EN+DE.
* This matches the legacy migration-143 "empty seed" case without
* ever touching admin-customised content.
* - Existing template_key with non-empty EN translation → leave alone.
*
* Returns array of template_keys touched (inserted or backfilled) for
* diagnostic logging.
*/
async function ensureEventReminderTemplatesSeeded(db, logger) {
if (_seeded) return [];
if (!(await db.schema.hasTable('email_templates'))) return [];
const cols = await db('email_templates').columnInfo();
const hasTranslationsTable = await db.schema.hasTable('email_template_translations');
const touched = [];
const isEmpty = (tr) => {
if (!tr) return true;
const s = (tr.subject || '').trim();
const h = (tr.body_html || '').trim();
const t = (tr.body_text || '').trim();
return !s && !h && !t;
};
const upsertTranslation = async (templateId, language, content) => {
if (!hasTranslationsTable) return;
const existing = await db('email_template_translations')
.where({ template_id: templateId, language })
.first();
if (existing && !isEmpty(existing)) return; // never overwrite admin edits
if (existing) {
await db('email_template_translations')
.where({ id: existing.id })
.update({
subject: content.subject,
body_html: content.body_html,
body_text: content.body_text,
updated_at: new Date(),
});
} else {
await db('email_template_translations').insert({
template_id: templateId,
language,
subject: content.subject,
body_html: content.body_html,
body_text: content.body_text,
created_at: new Date(),
updated_at: new Date(),
});
}
};
for (const [templateKey, def] of Object.entries(EVENT_REMINDER_TEMPLATES)) {
try {
let existing = await db('email_templates').where({ template_key: templateKey }).first();
if (!existing) {
const en = def.en;
const masterRow = {
template_key: templateKey,
variables: JSON.stringify(VARIABLES),
};
if ('category' in cols) masterRow.category = 'crm';
if ('subcategory' in cols) masterRow.subcategory = 'event_reminder';
if ('feature_flag' in cols) masterRow.feature_flag = 'crm_event_reminders_enabled';
if ('created_at' in cols) masterRow.created_at = new Date();
if ('updated_at' in cols) masterRow.updated_at = new Date();
// Fill legacy subject_<lang>/body_html_<lang> columns if present.
for (const colName of Object.keys(cols)) {
if (colName === 'subject' || /^subject_[a-z]{2,3}$/i.test(colName)) {
masterRow[colName] = en.subject;
} else if (colName === 'body_html' || /^body_html_[a-z]{2,3}$/i.test(colName)) {
masterRow[colName] = en.body_html;
} else if (colName === 'body_text' || /^body_text_[a-z]{2,3}$/i.test(colName)) {
masterRow[colName] = en.body_text;
}
}
const inserted = await db('email_templates').insert(masterRow).returning('id');
const templateId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
await upsertTranslation(templateId, 'en', def.en);
await upsertTranslation(templateId, 'de', def.de);
touched.push(templateKey);
if (logger) logger.info(`Self-healed event reminder template: ${templateKey}`);
continue;
}
// Template exists — backfill empty translations only.
if (hasTranslationsTable) {
const en = await db('email_template_translations')
.where({ template_id: existing.id, language: 'en' })
.first();
if (isEmpty(en)) {
await upsertTranslation(existing.id, 'en', def.en);
await upsertTranslation(existing.id, 'de', def.de);
touched.push(templateKey);
if (logger) logger.info(`Self-healed empty event reminder translations: ${templateKey}`);
}
}
} catch (err) {
if (logger) {
logger.error(`Failed to seed event reminder template ${templateKey}`, {
message: err.message,
});
}
// Keep _seeded=false so the next pass retries.
return touched;
}
}
_seeded = true;
return touched;
}
module.exports = {
EVENT_REMINDER_TEMPLATES,
ensureEventReminderTemplatesSeeded,
};