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.
182 lines
9.7 KiB
JavaScript
182 lines
9.7 KiB
JavaScript
/**
|
||
* 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 & 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 & 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,
|
||
};
|