Files
picpeak/backend/src/services/_workflowSeedBoot.js
T
Luca 10d091b55e feat(workflows): pre-event reminder picks the template GROUP on the block, type stays automatic
The reminder template family (prefix) is now chosen on the notify_pre_event
block via config.templateGroup (default 'event_reminder'); within that group the
exact template is still auto-resolved per event type:
  <group>_<eventType> if authored  →  else  <group>_default
So an admin can point a flow at a different reminder family, while wedding/
birthday/… routing and the catch-all fallback stay automatic. resolveTemplateKey
now takes (eventType, group) and tolerates a trailing "_" on the group.

Editor: notify_pre_event (+ the gallery notify actions) added to the action
dropdown, with a "Reminder template group" field and hint. Seed sets
templateGroup='event_reminder' on the built-in (v4). EN/DE strings. Tests cover
the per-type / group-default resolution.
2026-06-24 11:26:02 +02:00

432 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Boot-time seed for built-in workflows.
*
* Seeds the reminder/booking ladders as EDITABLE built-in flows, so the canvas
* has real content and admins can see (and tweak) their processes as blocks.
*
* IMPORTANT — every built-in is seeded DISABLED. Live behaviour is UNCHANGED
* until an admin enables a flow: the hardcoded reminder ladder still runs, and
* the booking document actions (prepare_quote/contract/event/invoice) are still
* stubs that record an observable `skipped` step rather than firing. The
* cutover (drive each process through the engine + stop the hardcoded path) is a
* deliberate follow-up so we never double-act. Enabling a flow before its
* cutover is safe — at worst it records skipped steps — but the dunning flow in
* particular auto-suppresses the hardcoded ladder while enabled so the two never
* double-send.
*
* Idempotent: keyed on builtin_key. Once seeded, admin edits are preserved (we
* never overwrite an enabled built-in, and re-seed a disabled one only when its
* SEED_VERSION moves on). Self-heal pattern per [[feedback_self_heal_pattern]].
*/
const { getAppSetting } = require('../utils/appSettings');
const DUNNING_KEY = 'invoice_dunning';
function buildDunningGraph({ firstDays, gapDays, maxReminders }) {
// Delegation model: the payment-check email IS the admin gate (it drives the
// existing confirm + reminder_level + Mahngebühr state machine), so the flow
// just decides WHEN to fire it. After due date + grace, loop up to
// maxReminders times: if still unpaid, queue a payment-check, wait the gap,
// repeat; stop early once paid. After the loop exhausts → collections handoff.
const nodes = [
{ node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 },
{ node_key: 'waitDue', type: 'wait', config: { untilVar: 'dueDate' }, pos_x: 240, pos_y: 110 },
{ node_key: 'waitGrace', type: 'wait', config: { delayDays: firstDays }, pos_x: 240, pos_y: 220 },
{ node_key: 'loop', type: 'loop', config: { maxIterations: maxReminders }, pos_x: 240, pos_y: 330 },
{ node_key: 'checkPaid', type: 'condition', config: { condition: 'invoice_paid' }, pos_x: 240, pos_y: 440 },
{ node_key: 'paymentCheck', type: 'action', config: { action: 'queue_payment_check' }, pos_x: 240, pos_y: 550 },
{ node_key: 'waitGap', type: 'wait', config: { delayDays: gapDays }, pos_x: 240, pos_y: 660 },
{ node_key: 'donePaid', type: 'action', config: { action: 'noop' }, pos_x: 520, pos_y: 440 },
{ node_key: 'collections', type: 'action', config: { action: 'escalate_to_collections' }, pos_x: 520, pos_y: 250 },
{ node_key: 'doneEnd', type: 'action', config: { action: 'noop' }, pos_x: 760, pos_y: 250 },
];
const edges = [
{ from_node: 't', to_node: 'waitDue' },
{ from_node: 'waitDue', to_node: 'waitGrace' },
{ from_node: 'waitGrace', to_node: 'loop' },
{ from_node: 'loop', from_handle: 'loop', to_node: 'checkPaid' },
{ from_node: 'loop', from_handle: 'exit', to_node: 'collections' },
{ from_node: 'collections', to_node: 'doneEnd' },
{ from_node: 'checkPaid', from_handle: 'yes', to_node: 'donePaid' },
{ from_node: 'checkPaid', from_handle: 'no', to_node: 'paymentCheck' },
{ from_node: 'paymentCheck', to_node: 'waitGap' },
{ from_node: 'waitGap', to_node: 'loop', loop_back: true },
];
return { nodes, edges };
}
// Booking — quote accepted → prepare contract → ADMIN REVIEW GATE → send
// contract → admin gate "signed?" → create the event/gallery → wait to the
// event date → prepare invoice → ADMIN REVIEW GATE → send invoice.
//
// A document is never sent without an explicit admin OK: prepare_* creates a
// DRAFT, the admin adjusts line items / terms in the CRM, then confirms the
// review gate, and only then does send_document fire. The "signed?" gate models
// the external signing step (no e-sign webhook yet). The document actions are
// stubs until the booking cutover, so an enabled run records observable skipped
// steps rather than acting.
function buildBookingFullGraph() {
const nodes = [
{ node_key: 't', type: 'trigger', config: {}, pos_x: 320, pos_y: 0 },
{ node_key: 'prepContract', type: 'action', config: { action: 'prepare_contract' }, pos_x: 320, pos_y: 110 },
{ node_key: 'reviewContract', type: 'gate', config: { label: 'Review contract before sending' }, pos_x: 320, pos_y: 220 },
{ node_key: 'sendContract', type: 'action', config: { action: 'send_document', document: 'contract', recipient: 'customer' }, pos_x: 320, pos_y: 330 },
{ node_key: 'gateSigned', type: 'gate', config: { label: 'Contract signed?' }, pos_x: 320, pos_y: 440 },
{ node_key: 'prepEvent', type: 'action', config: { action: 'prepare_event' }, pos_x: 320, pos_y: 550 },
{ node_key: 'prepInvoice', type: 'action', config: { action: 'prepare_invoice' }, pos_x: 320, pos_y: 660 },
{ node_key: 'reviewInvoice', type: 'gate', config: { label: 'Review invoice (early — dispatch waits for the event)' }, pos_x: 320, pos_y: 770 },
{ node_key: 'waitEvent', type: 'wait', config: { untilVar: 'eventDate' }, pos_x: 320, pos_y: 880 },
{ node_key: 'sendInvoice', type: 'action', config: { action: 'send_document', document: 'invoice', recipient: 'customer' }, pos_x: 320, pos_y: 990 },
{ node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 320, pos_y: 1100 },
{ node_key: 'cancelContract', type: 'action', config: { action: 'noop' }, pos_x: 620, pos_y: 220 },
{ node_key: 'declined', type: 'action', config: { action: 'noop' }, pos_x: 620, pos_y: 440 },
{ node_key: 'cancelInvoice', type: 'action', config: { action: 'noop' }, pos_x: 620, pos_y: 770 },
];
const edges = [
{ from_node: 't', to_node: 'prepContract' },
{ from_node: 'prepContract', to_node: 'reviewContract' },
{ from_node: 'reviewContract', from_handle: 'confirm', to_node: 'sendContract' },
{ from_node: 'reviewContract', from_handle: 'deny', to_node: 'cancelContract' },
{ from_node: 'sendContract', to_node: 'gateSigned' },
{ from_node: 'gateSigned', from_handle: 'confirm', to_node: 'prepEvent' },
{ from_node: 'gateSigned', from_handle: 'deny', to_node: 'declined' },
// Prepare + approve the invoice EARLY (admin can adjust line items now);
// then the wait holds dispatch until the event date, and it sends itself.
{ from_node: 'prepEvent', to_node: 'prepInvoice' },
{ from_node: 'prepInvoice', to_node: 'reviewInvoice' },
{ from_node: 'reviewInvoice', from_handle: 'confirm', to_node: 'waitEvent' },
{ from_node: 'reviewInvoice', from_handle: 'deny', to_node: 'cancelInvoice' },
{ from_node: 'waitEvent', to_node: 'sendInvoice' },
{ from_node: 'sendInvoice', to_node: 'done' },
];
return { nodes, edges };
}
// Booking — quote accepted → create the event/gallery → wait to the event date
// → prepare invoice → ADMIN REVIEW GATE → send invoice. The no-contract path
// (e.g. small shoots). Same review-before-send rule and stub caveat as the full
// booking flow.
function buildBookingSimpleGraph() {
const nodes = [
{ node_key: 't', type: 'trigger', config: {}, pos_x: 320, pos_y: 0 },
{ node_key: 'prepEvent', type: 'action', config: { action: 'prepare_event' }, pos_x: 320, pos_y: 110 },
{ node_key: 'prepInvoice', type: 'action', config: { action: 'prepare_invoice' }, pos_x: 320, pos_y: 220 },
{ node_key: 'reviewInvoice', type: 'gate', config: { label: 'Review invoice (early — dispatch waits for the event)' }, pos_x: 320, pos_y: 330 },
{ node_key: 'waitEvent', type: 'wait', config: { untilVar: 'eventDate' }, pos_x: 320, pos_y: 440 },
{ node_key: 'sendInvoice', type: 'action', config: { action: 'send_document', document: 'invoice', recipient: 'customer' }, pos_x: 320, pos_y: 550 },
{ node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 320, pos_y: 660 },
{ node_key: 'cancelInvoice', type: 'action', config: { action: 'noop' }, pos_x: 620, pos_y: 330 },
];
const edges = [
{ from_node: 't', to_node: 'prepEvent' },
// Prepare + approve the invoice early; the wait holds dispatch to the event date.
{ from_node: 'prepEvent', to_node: 'prepInvoice' },
{ from_node: 'prepInvoice', to_node: 'reviewInvoice' },
{ from_node: 'reviewInvoice', from_handle: 'confirm', to_node: 'waitEvent' },
{ from_node: 'reviewInvoice', from_handle: 'deny', to_node: 'cancelInvoice' },
{ from_node: 'waitEvent', to_node: 'sendInvoice' },
{ from_node: 'sendInvoice', to_node: 'done' },
];
return { nodes, edges };
}
// Booking — quote accepted → prepare invoice → admin review gate → send. No
// event/gallery and no wait: the invoice goes out as soon as the admin approves
// it. For shoots billed without a delivered online gallery. Same stub caveat as
// the other booking flows (prepare_invoice/send_document not yet wired).
function buildBookingInvoiceOnlyGraph() {
const nodes = [
{ node_key: 't', type: 'trigger', config: {}, pos_x: 320, pos_y: 0 },
{ node_key: 'prepInvoice', type: 'action', config: { action: 'prepare_invoice' }, pos_x: 320, pos_y: 110 },
{ node_key: 'reviewInvoice', type: 'gate', config: { label: 'Review invoice before sending' }, pos_x: 320, pos_y: 220 },
{ node_key: 'sendInvoice', type: 'action', config: { action: 'send_document', document: 'invoice', recipient: 'customer' }, pos_x: 320, pos_y: 330 },
{ node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 320, pos_y: 440 },
{ node_key: 'cancelInvoice', type: 'action', config: { action: 'noop' }, pos_x: 620, pos_y: 220 },
];
const edges = [
{ from_node: 't', to_node: 'prepInvoice' },
{ from_node: 'prepInvoice', to_node: 'reviewInvoice' },
{ from_node: 'reviewInvoice', from_handle: 'confirm', to_node: 'sendInvoice' },
{ from_node: 'reviewInvoice', from_handle: 'deny', to_node: 'cancelInvoice' },
{ from_node: 'sendInvoice', to_node: 'done' },
];
return { nodes, edges };
}
// Pre-event reminder — fired by the scheduler at event_date daysBefore (see
// emitDueEventReminders). The notify_pre_event action DELEGATES to
// eventReminderService.sendReminderForEvent, so the email is byte-identical to
// the legacy pass (per-type template, per-event override, sent_at idempotency).
// This is the live replacement for that pass (mutual-exclusion guard there).
function buildPreEventEmailGraph() {
const nodes = [
{ node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 },
{ node_key: 'notify', type: 'action', config: { action: 'notify_pre_event', templateGroup: 'event_reminder' }, pos_x: 240, pos_y: 110 },
{ node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 240, pos_y: 220 },
];
const edges = [
{ from_node: 't', to_node: 'notify' },
{ from_node: 'notify', to_node: 'done' },
];
return { nodes, edges };
}
// Gallery expiring — fired by the expiration checker `daysBefore` expiry. The
// notify_gallery_expiring action delegates to the checker's queueExpirationWarning
// so the warning email is identical. Live replacement for the legacy warning
// email (mutual-exclusion guard in the checker).
function buildGalleryExpiringGraph() {
const nodes = [
{ node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 },
{ node_key: 'notify', type: 'action', config: { action: 'notify_gallery_expiring' }, pos_x: 240, pos_y: 110 },
{ node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 240, pos_y: 220 },
];
const edges = [
{ from_node: 't', to_node: 'notify' },
{ from_node: 'notify', to_node: 'done' },
];
return { nodes, edges };
}
// Gallery expired — fired when a gallery passes its expiry. The
// notify_gallery_expired action delegates to the checker's sendGalleryExpiredEmails.
// Live replacement for the legacy expired email (mutual-exclusion guard in the checker).
function buildGalleryExpiredGraph() {
const nodes = [
{ node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 },
{ node_key: 'notify', type: 'action', config: { action: 'notify_gallery_expired' }, pos_x: 240, pos_y: 110 },
{ node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 240, pos_y: 220 },
];
const edges = [
{ from_node: 't', to_node: 'notify' },
{ from_node: 'notify', to_node: 'done' },
];
return { nodes, edges };
}
// Built-in registry. `version` is the SEED_VERSION — bump when a graph changes
// (or to re-assert the default `enabled` state) so a never-admin-touched copy is
// re-seeded on boot. `enabled` is the seed default.
//
// FIRST-BETA POSTURE (review feedback): all built-ins ship DISABLED. The legacy
// hardcoded paths keep running by default (the mutual-exclusion guards are
// enabled-based, so they only stand down once the admin ENABLES the matching
// built-in — a deliberate, per-install cutover). Enabling reverts to legacy.
// Once the prefetch-safe approval interstitial has soaked in beta, flip the
// three notification built-ins back to enabled-by-default in a follow-up.
// invoice_dunning v6 = ship disabled (was v5 enabled-by-default).
const BUILTINS = [
{
key: DUNNING_KEY,
version: 6,
enabled: false,
name: 'Invoice dunning (built-in)',
trigger_type: 'invoice.sent',
trigger_config: {},
description:
'Drives overdue dunning through the engine: wait to the due date, then up to '
+ 'three payment-check cycles. Each cycle fires the existing admin confirm-payment '
+ 'email (the gate), which applies reminders + Mahngebühr via the proven payment-check '
+ 'flow; after the cycles exhaust it hands the case to collections. DISABLED by default — '
+ 'the hardcoded reminder ladder keeps running until you enable this; enabling cuts over to '
+ 'the engine (the ladder then stands down so the two never double-send), disabling reverts.',
build: async () => {
const firstDays = Number(await getAppSetting('crm_invoices_reminder_first_days')) || 14;
const secondDays = Number(await getAppSetting('crm_invoices_reminder_second_days')) || 30;
const gapDays = Math.max(1, secondDays - firstDays);
return buildDunningGraph({ firstDays, gapDays, maxReminders: 3 });
},
},
{
key: 'gallery_expiring',
version: 2,
enabled: false,
name: 'Gallery expiring (built-in)',
trigger_type: 'gallery.expiring',
trigger_config: {},
description:
'When a gallery is approaching its expiry date, email the customer the expiration warning. '
+ 'DISABLED by default; the hourly expiration checker keeps sending the warning until you '
+ 'enable this, which delegates to the identical email and stands the legacy send down. '
+ 'Edit or extend it here (e.g. add a final-download nudge).',
build: async () => buildGalleryExpiringGraph(),
},
{
key: 'gallery_expired',
version: 2,
enabled: false,
name: 'Gallery expired (built-in)',
trigger_type: 'gallery.expired',
trigger_config: {},
description:
'When a gallery passes its expiry, email the customer (and admin) that it has expired. '
+ 'DISABLED by default; the expiration checker keeps sending it until you enable this, which '
+ 'delegates to the identical email and stands the legacy send down. The gallery is still '
+ 'archived automatically regardless of this flow.',
build: async () => buildGalleryExpiredGraph(),
},
{
key: 'pre_event_email',
version: 4,
enabled: false,
name: 'Pre-event reminder (built-in)',
trigger_type: 'event.date_approaching',
// daysBefore seeds the scheduler emitter from the current global setting so
// enabling preserves timing; per-event offset overrides still win.
trigger_config: async () => {
const d = Number(await getAppSetting('crm_event_reminders_days_before'));
return { daysBefore: Number.isFinite(d) && d >= 0 ? d : 2 };
},
description:
'A few days before the event date, send the customer the pre-event reminder. DISABLED by '
+ 'default; the legacy reminder pass keeps running until you enable this, which delegates to '
+ 'the proven reminder logic (per-type template, per-event override, send-once) and stands '
+ 'the legacy pass down. Lead time = daysBefore in the trigger config (seeded from your old '
+ 'global setting); per-event overrides on the event page still apply.',
build: async () => buildPreEventEmailGraph(),
},
{
key: 'booking_full',
version: 3,
enabled: false,
name: 'Booking — quote → contract → event → invoice (built-in)',
trigger_type: 'quote.accepted',
trigger_config: {},
description:
'On quote acceptance: prepare the contract, let the admin review it (adjust line items / '
+ 'terms) and confirm before it is sent, wait for the admin to confirm it is signed, then '
+ 'create the event/gallery and prepare the invoice EARLY so the admin can adjust it. The '
+ 'admin approves the invoice at the review gate whenever they like; dispatch then waits '
+ 'until the event date and sends itself. No document is sent without an explicit admin OK. '
+ 'Disabled by default — the document actions are stubs until the booking cutover, so an '
+ 'enabled run just records observable skipped steps. A starting point to edit.',
build: async () => buildBookingFullGraph(),
},
{
key: 'booking_simple',
version: 3,
enabled: false,
name: 'Booking — quote → event → invoice (built-in)',
trigger_type: 'quote.accepted',
trigger_config: {},
description:
'The no-contract booking path: on quote acceptance create the event/gallery and prepare the '
+ 'invoice early. The admin approves it at the review gate ahead of time; dispatch then waits '
+ 'until the event date and sends itself. Same review-before-send rule and stub caveat as the '
+ 'full booking flow; disabled by default.',
build: async () => buildBookingSimpleGraph(),
},
{
key: 'booking_invoice_only',
version: 1,
enabled: false,
name: 'Booking — quote → invoice, no gallery (built-in)',
trigger_type: 'quote.accepted',
trigger_config: {},
description:
'For shoots billed without an online gallery: on quote acceptance prepare the invoice, the '
+ 'admin reviews + approves it, and it is sent right away (no event/gallery, no wait). Pick '
+ 'this flow per quote via the booking-workflow selector. Same review-before-send rule and '
+ 'stub caveat as the other booking flows; disabled by default.',
build: async () => buildBookingInvoiceOnlyGraph(),
},
];
let booted = false;
function parseSeedConfig(raw) {
if (raw == null) return {};
if (typeof raw === 'object') return raw;
try { return JSON.parse(raw) || {}; } catch (e) { return {}; }
}
async function writeGraph(trx, workflowId, version, nodes, edges) {
for (const n of nodes) {
await trx('workflow_nodes').insert({
workflow_id: workflowId, version, node_key: n.node_key, type: n.type,
config: JSON.stringify(n.config || {}), pos_x: n.pos_x || 0, pos_y: n.pos_y || 0,
});
}
for (const e of edges) {
await trx('workflow_edges').insert({
workflow_id: workflowId, version, from_node: e.from_node, from_handle: e.from_handle || null,
to_node: e.to_node, label: e.label || null, loop_back: !!e.loop_back,
});
}
}
async function seedOneBuiltin(db, logger, def) {
const { nodes, edges } = await def.build();
const baseCfg = typeof def.trigger_config === 'function'
? (await def.trigger_config()) || {}
: (def.trigger_config || {});
const triggerConfig = { ...baseCfg, seedVersion: def.version };
const defEnabled = def.enabled === true;
const existing = await db('workflows').where({ builtin_key: def.key }).first();
if (existing) {
// Never touch a built-in the admin has taken ownership of (enabled/disabled
// or edited it) — admin_toggled_at is the sentinel (migration 148). For a
// never-touched copy, re-seed on a SEED_VERSION bump and (re-)apply the seed
// default `enabled`, so a shipped default flip (e.g. enabled→disabled for
// first beta) propagates to installs the admin hasn't customised.
const storedVersion = Number(parseSeedConfig(existing.trigger_config).seedVersion) || 0;
const adminOwned = !!existing.admin_toggled_at;
if (adminOwned || storedVersion >= def.version) return;
const newVersion = (existing.version || 1) + 1;
await db.transaction(async (trx) => {
await trx('workflows').where({ id: existing.id }).update({
name: def.name,
description: def.description,
trigger_type: def.trigger_type,
trigger_config: JSON.stringify(triggerConfig),
enabled: defEnabled,
version: newVersion,
updated_at: trx.fn.now(),
});
await writeGraph(trx, existing.id, newVersion, nodes, edges);
});
logger?.info?.(`Re-seeded built-in workflow: ${def.key} (v${def.version}, enabled=${defEnabled})`);
return;
}
await db.transaction(async (trx) => {
const ins = await trx('workflows').insert({
name: def.name,
description: def.description,
enabled: defEnabled,
version: 1,
trigger_type: def.trigger_type,
trigger_config: JSON.stringify(triggerConfig),
is_builtin: true,
builtin_key: def.key,
}).returning('id');
// Postgres returns [] without `.returning`, so ins[0] would be undefined and
// the child node inserts would roll back on NOT NULL. Normalise the {id}
// (pg) vs bare-id (sqlite) shapes.
const workflowId = ins[0]?.id ?? ins[0];
await writeGraph(trx, workflowId, 1, nodes, edges);
});
logger?.info?.(`Seeded built-in workflow: ${def.key} (enabled=${defEnabled})`);
}
async function seedBuiltinWorkflowsAtBoot(db, logger) {
try {
if (!(await db.schema.hasTable('workflows'))) return;
for (const def of BUILTINS) {
try {
await seedOneBuiltin(db, logger, def);
} catch (err) {
logger?.warn?.(`Built-in workflow seed failed for ${def.key}:`, err.message);
}
}
booted = true;
} catch (err) {
logger?.warn?.('Built-in workflow seed failed at boot:', err.message);
}
}
module.exports = { seedBuiltinWorkflowsAtBoot, buildDunningGraph, DUNNING_KEY, BUILTINS };