`;
+ return page('Confirm your response', body);
+}
+
+// GET — render the interstitial. READ-ONLY: never mutates / resumes.
+router.get('/:token/:action', async (req, res) => {
+ const { token, action } = req.params;
+ if (!['confirm', 'deny'].includes(action)) {
+ return res.status(400).send(page('Invalid link', 'This confirmation link is not valid.'));
+ }
+ try {
+ const info = await peekApproval(token);
+ if (!info.found) {
+ return res.status(404).send(page('Link not found', 'This confirmation link is invalid or has been revoked.'));
+ }
+ if (info.status !== 'pending') {
+ return res.send(page('Already recorded', `This request was already ${esc(info.status)}.`));
+ }
+ if (info.expired) {
+ return res.status(410).send(page('Link expired', 'This confirmation link has expired. Use the workflow inbox in the admin panel instead.'));
+ }
+ // Relative form actions resolve against the current path's directory; the
+ // emphasis just highlights the button matching the link they clicked.
+ return res.send(decisionPage(token, action, info.prompt));
+ } catch (e) {
+ return res.status(500).send(page('Something went wrong', 'Please try again or use the admin panel.'));
+ }
+});
+
+// POST — the actual decision. Only a human (or an explicit form submit) reaches
+// here; prefetchers issue GET, not POST.
+router.post('/:token/:action', async (req, res) => {
+ const { token, action } = req.params;
+ if (!['confirm', 'deny'].includes(action)) {
+ return res.status(400).send(page('Invalid link', 'This confirmation link is not valid.'));
+ }
+ try {
+ const result = await actByToken(token, action);
+ if (!result.ok && result.reason === 'not_found') {
+ return res.status(404).send(page('Link not found', 'This confirmation link is invalid or has been revoked.'));
+ }
+ if (!result.ok && result.reason === 'expired') {
+ return res.status(410).send(page('Link expired', 'This confirmation link has expired. Use the workflow inbox in the admin panel instead.'));
+ }
+ if (result.already) {
+ return res.send(page('Already recorded', `This request was already ${esc(result.status)}.`));
+ }
+ return res.send(page(
+ 'Thank you',
+ action === 'confirm'
+ ? 'Confirmed — the workflow will continue.'
+ : 'Recorded — the workflow has been told there is no payment / to stop.',
+ ));
+ } catch (e) {
+ return res.status(500).send(page('Something went wrong', 'We could not record your response. Please try again or use the admin panel.'));
+ }
+});
+
+module.exports = router;
diff --git a/backend/src/services/_workflowSeedBoot.js b/backend/src/services/_workflowSeedBoot.js
new file mode 100644
index 00000000..1f0f1d98
--- /dev/null
+++ b/backend/src/services/_workflowSeedBoot.js
@@ -0,0 +1,431 @@
+/**
+ * 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 };
diff --git a/backend/src/services/contractService.js b/backend/src/services/contractService.js
index 54af7e41..31a959ea 100644
--- a/backend/src/services/contractService.js
+++ b/backend/src/services/contractService.js
@@ -87,6 +87,36 @@ function customerPublicActor() {
return { type: 'customer', name: 'Customer (public link)' };
}
+/**
+ * Fire a contract lifecycle event for the workflow engine. Best-effort:
+ * resolves the customer email (so send_email actions have a recipient) and
+ * never throws into the caller. No-op when the workflows flag is off (emit
+ * fails closed). Mirrors quoteService.emitQuoteEvent.
+ */
+async function emitContractEvent(contract, status) {
+ try {
+ let customerEmail = null;
+ if (contract.customer_account_id) {
+ const c = await db('customer_accounts').where({ id: contract.customer_account_id }).first();
+ customerEmail = c?.email || null;
+ }
+ await require('./workflows').emitWorkflowEvent(`contract.${status}`, {
+ entityType: 'contract',
+ entityId: contract.id,
+ payload: {
+ contractId: contract.id,
+ contractNumber: contract.contract_number,
+ customerAccountId: contract.customer_account_id || null,
+ customerEmail,
+ eventName: contract.event_name || null,
+ title: contract.title || null,
+ },
+ });
+ } catch (err) {
+ logger.warn('Failed to emit contract workflow event', { contractId: contract.id, status, error: err.message });
+ }
+}
+
/**
* Privacy gate for the customer/admin IP captured at signing time.
* The `crm_contracts_store_ip` setting (default true) controls
@@ -1065,6 +1095,8 @@ async function sendContract(id, adminId) {
await logActivity('contract_sent', { contractId: id, token }, null, await adminActor(adminId));
} catch (_) { /* logging is best-effort */ }
+ await emitContractEvent(contract, 'sent');
+
logger.info('Contract sent', { adminId, contractId: id });
return { token, pdfPath };
}
@@ -1460,6 +1492,10 @@ async function recordAdminCountersignature(contractId, { name, ip, signatureData
await logActivity(`contract_${newStatus}`, { contractId: contract.id }, null, await adminActor(adminId));
} catch (_) { /* logging is best-effort */ }
+ // The binding moment — fire contract.signed once the contract is fully signed
+ // (matches the editor's trigger). Best-effort / fail-closed.
+ if (newStatus === 'fully_signed') await emitContractEvent(contract, 'signed');
+
return { status: newStatus, signedAt: now };
}
@@ -1565,6 +1601,8 @@ async function attachSignedPdfUpload(contractId, filePath, uploaderRole) {
uploaderRole === 'admin' ? { type: 'admin', name: 'Admin (PDF upload)' } : customerPublicActor());
} catch (_) { /* logging is best-effort */ }
+ await emitContractEvent(contract, 'signed');
+
return { status: 'fully_signed', signedPdfPath: filePath };
}
diff --git a/backend/src/services/crmEmailTemplates.js b/backend/src/services/crmEmailTemplates.js
index 2114fdb2..84572e24 100644
--- a/backend/src/services/crmEmailTemplates.js
+++ b/backend/src/services/crmEmailTemplates.js
@@ -358,6 +358,70 @@ Rechnung {{invoice_number}} für {{customer_name}}{{#if event_name}} ({{event_na
Erfasst am: {{paid_at}}
Automatische Benachrichtigung — keine Aktion erforderlich.`,
+},
+ },
+ invoice_collections_handoff: {
+ category: 'billing', feature_flag: 'bills',
+ variables: ['invoice_number', 'customer_name', 'customer_email', 'customer_address', 'event_name', 'original_amount', 'late_fee_amount', 'paid_amount', 'outstanding_amount', 'due_date', 'reminder_level'],
+ en: {
+ subject: 'Collections handoff: invoice {{invoice_number}} still unpaid after dunning',
+ body_html: `
Ready to hand to collections
+
Invoice {{invoice_number}}{{#if event_name}} ({{event_name}}){{/if}} is still unpaid after {{reminder_level}} reminders. The invoice PDF is attached for forwarding.
+
+
Customer
{{customer_name}}
+ {{#if customer_email}}
Email
{{customer_email}}
{{/if}}
+ {{#if customer_address}}
Address
{{customer_address}}
{{/if}}
+
Due date
{{due_date}}
+
Original amount
{{original_amount}}
+ {{#if late_fee_amount}}
Late fees
{{late_fee_amount}}
{{/if}}
+
Paid
{{paid_amount}}
+
Outstanding
{{outstanding_amount}}
+
+
Forward to your collections agency / for Betreibung. Automatic notification.
`,
+ body_text: `Ready to hand to collections
+
+Invoice {{invoice_number}}{{#if event_name}} ({{event_name}}){{/if}} is still unpaid after {{reminder_level}} reminders. The invoice PDF is attached.
+
+ Customer: {{customer_name}}{{#if customer_email}}
+ Email: {{customer_email}}{{/if}}{{#if customer_address}}
+ Address: {{customer_address}}{{/if}}
+ Due date: {{due_date}}
+ Original amount: {{original_amount}}{{#if late_fee_amount}}
+ Late fees: {{late_fee_amount}}{{/if}}
+ Paid: {{paid_amount}}
+ Outstanding: {{outstanding_amount}}
+
+Forward to your collections agency / for Betreibung.`,
+},
+ de: {
+ subject: 'Inkasso-Übergabe: Rechnung {{invoice_number}} trotz Mahnungen offen',
+ body_html: `
Bereit zur Inkasso-Übergabe
+
Rechnung {{invoice_number}}{{#if event_name}} ({{event_name}}){{/if}} ist nach {{reminder_level}} Mahnungen weiterhin offen. Das Rechnungs-PDF ist zur Weiterleitung angehängt.
+
+
Kunde
{{customer_name}}
+ {{#if customer_email}}
E-Mail
{{customer_email}}
{{/if}}
+ {{#if customer_address}}
Adresse
{{customer_address}}
{{/if}}
+
Fälligkeit
{{due_date}}
+
Rechnungsbetrag
{{original_amount}}
+ {{#if late_fee_amount}}
Mahngebühren
{{late_fee_amount}}
{{/if}}
+
Bezahlt
{{paid_amount}}
+
Offen
{{outstanding_amount}}
+
+
Zur Weiterleitung an das Inkasso / für die Betreibung. Automatische Benachrichtigung.
`,
+ body_text: `Bereit zur Inkasso-Übergabe
+
+Rechnung {{invoice_number}}{{#if event_name}} ({{event_name}}){{/if}} ist nach {{reminder_level}} Mahnungen weiterhin offen. Das Rechnungs-PDF ist angehängt.
+
+ Kunde: {{customer_name}}{{#if customer_email}}
+ E-Mail: {{customer_email}}{{/if}}{{#if customer_address}}
+ Adresse: {{customer_address}}{{/if}}
+ Fälligkeit: {{due_date}}
+ Rechnungsbetrag: {{original_amount}}{{#if late_fee_amount}}
+ Mahngebühren: {{late_fee_amount}}{{/if}}
+ Bezahlt: {{paid_amount}}
+ Offen: {{outstanding_amount}}
+
+Zur Weiterleitung an das Inkasso / für die Betreibung.`,
},
},
};
diff --git a/backend/src/services/customerAccountsService.js b/backend/src/services/customerAccountsService.js
index 041790e0..8b8061d1 100644
--- a/backend/src/services/customerAccountsService.js
+++ b/backend/src/services/customerAccountsService.js
@@ -22,6 +22,23 @@ const { ConflictError, NotFoundError, ValidationError } = require('../utils/erro
const INVITATION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days, matches admin invites
+/**
+ * Fire customer.created for the workflow engine, from every creation path
+ * (direct add + invitation accept). Best-effort / fail-closed; never throws
+ * into the caller.
+ */
+async function emitCustomerCreated(id, email) {
+ try {
+ await require('./workflows').emitWorkflowEvent('customer.created', {
+ entityType: 'customer',
+ entityId: id,
+ payload: { customerAccountId: id, customerEmail: email || null },
+ });
+ } catch (err) {
+ logger.warn('Failed to emit customer.created workflow event', { customerId: id, error: err.message });
+ }
+}
+
/**
* Whitelist of customer profile fields the admin is allowed to pre-fill on
* an invitation (and that the customer can then edit on accept). Centralised
@@ -252,6 +269,7 @@ async function createDirect({ email, prefill, createdByAdminId }) {
);
logger.info('Passive customer created', { id, email: normalisedEmail, createdByAdminId });
+ await emitCustomerCreated(id, normalisedEmail);
return { id };
}
@@ -420,6 +438,7 @@ async function acceptInvitation({ token, name, password, profile }) {
);
logger.info('Customer invitation accepted', { customerId, email: invitation.email });
+ await emitCustomerCreated(customerId, invitation.email);
return { customerId, email: invitation.email };
}
diff --git a/backend/src/services/eventReminderService.js b/backend/src/services/eventReminderService.js
index 74e4601b..c22c3f62 100644
--- a/backend/src/services/eventReminderService.js
+++ b/backend/src/services/eventReminderService.js
@@ -61,6 +61,7 @@ const logger = require('../utils/logger');
const { ensureEventReminderTemplatesSeeded } = require('./eventReminderTemplates');
const DEFAULT_DAYS_BEFORE = 2;
+const DEFAULT_TEMPLATE_GROUP = 'event_reminder';
const TEMPLATE_KEY_DEFAULT = 'event_reminder_default';
const TEMPLATE_KEY_PREFIX = 'event_reminder_';
@@ -71,31 +72,36 @@ const TEMPLATE_KEY_PREFIX = 'event_reminder_';
let schemaWarnLogged = false;
/**
- * Lookup the most specific available template for an event_type slug.
- * Returns the template_key string. The email_processor handles missing
- * template rows by failing the send; we don't fetch the row body here
- * because emailProcessor.queueEmail does that lookup itself.
+ * Resolve the reminder template within a GROUP (template-key prefix). The group
+ * is chosen on the flow block (defaults to `event_reminder`); within it the pick
+ * is automatic and per-event-type:
+ * `_` if a template exists → else `_default`
+ * So an exact wedding/birthday/… template wins; otherwise the group's catch-all.
+ * emailProcessor handles a missing template row itself, so we only return a key.
*/
-async function resolveTemplateKey(eventType) {
+async function resolveTemplateKey(eventType, group = DEFAULT_TEMPLATE_GROUP) {
+ const g = String(group || DEFAULT_TEMPLATE_GROUP).replace(/_+$/, ''); // tolerate a trailing "_"
if (eventType) {
- const perType = `${TEMPLATE_KEY_PREFIX}${eventType}`;
+ const perType = `${g}_${eventType}`;
const exists = await db('email_templates')
.where({ template_key: perType })
.first('id');
if (exists) return perType;
}
- return TEMPLATE_KEY_DEFAULT;
+ return `${g}_default`;
}
/**
* Build the variables payload the template engine substitutes. Keep
* the keys in sync with the seeded template's `variables` JSON.
*/
-function composePayload({ event, customer, daysBefore, businessName }) {
- const customerName = customer.company_name
- || [customer.first_name, customer.last_name].filter(Boolean).join(' ')
- || customer.display_name
- || customer.email
+function composePayload({ event, recipientEmail, daysBefore, businessName }) {
+ // Recipient identity comes from the EVENT row (events.customer_name /
+ // host_name), not a customer_accounts join — events store the recipient
+ // inline (customer_email / host_email), there is no events.customer_account_id.
+ const customerName = event.customer_name
+ || event.host_name
+ || recipientEmail
|| '';
// Event date formatted DD.MM.YYYY here for simplicity; the rendered
// email may further re-locale via the template engine when locale-
@@ -128,6 +134,17 @@ async function runEventReminderPass() {
return { scanned: 0, sent: 0, skipped: 0, disabled: true };
}
+ // Mutual exclusion with the workflow engine: the legacy pass stands down only
+ // when the pre_event_email built-in is ENABLED (then the engine sends via the
+ // notify_pre_event action). If the flow is disabled, this legacy pass keeps
+ // running — so the built-ins can ship disabled without going dark, and
+ // disabling a built-in cleanly reverts to the legacy path. Fails closed.
+ try {
+ if (await require('./workflows').isBuiltinFlowActive('pre_event_email')) {
+ return { scanned: 0, sent: 0, skipped: 0, byWorkflow: true };
+ }
+ } catch (_) { /* workflow subsystem down → keep the legacy pass running */ }
+
// Column-existence guards — pre-migration installs return early
// instead of throwing.
const hasCols = await hasColumnCached('events', 'event_reminder_sent_at');
@@ -156,40 +173,29 @@ async function runEventReminderPass() {
const profile = await db('business_profile').where({ id: 1 }).first('company_name');
const businessName = profile?.company_name || '';
- // Candidate set: events with a customer, event_date in the future,
- // not yet sent, not disabled per-event. We don't filter on
- // event_date - days_before <= NOW() in SQL because per-event
- // override `event_reminder_offset_days` may shift the trigger
- // window — easier to filter in JS.
+ // Candidate set: active events with a date in the future, not yet sent, not
+ // disabled per-event. Recipient comes from the event row itself (customer_email
+ // / host_email) — events have no customer_account_id. `events.*` so the
+ // customer_email column (newer; absent on very old installs) is read safely.
const now = new Date();
const rows = await db('events')
- .leftJoin('customer_accounts', 'customer_accounts.id', 'events.customer_account_id')
- .whereNotNull('events.customer_account_id')
.whereNotNull('events.event_date')
.where('events.is_active', true)
.where('events.is_archived', false)
.where('events.event_reminder_disabled', false)
.whereNull('events.event_reminder_sent_at')
.where('events.event_date', '>=', now.toISOString().slice(0, 10))
- .select(
- 'events.id', 'events.event_name', 'events.event_type', 'events.event_date',
- 'events.event_reminder_offset_days',
- 'events.event_reminder_body_override',
- 'events.customer_account_id',
- 'customer_accounts.email as customer_email',
- 'customer_accounts.first_name as customer_first_name',
- 'customer_accounts.last_name as customer_last_name',
- 'customer_accounts.display_name as customer_display_name',
- 'customer_accounts.company_name as customer_company_name',
- );
+ .select('events.*');
let sent = 0;
let skipped = 0;
for (const row of rows) {
try {
- if (!row.customer_email) { skipped += 1; continue; }
- const offsetDays = Number.isFinite(Number(row.event_reminder_offset_days))
- ? Number(row.event_reminder_offset_days)
+ const recipientEmail = row.customer_email || row.host_email;
+ if (!recipientEmail) { skipped += 1; continue; }
+ const rawOffset = row.event_reminder_offset_days;
+ const offsetDays = (rawOffset != null && rawOffset !== '' && Number.isFinite(Number(rawOffset)))
+ ? Number(rawOffset)
: daysBeforeDefault;
// Trigger window: NOW >= event_date - offset_days.
const ed = row.event_date instanceof Date ? row.event_date : new Date(row.event_date);
@@ -197,15 +203,8 @@ async function runEventReminderPass() {
if (now < triggerAt) { skipped += 1; continue; }
const templateKey = await resolveTemplateKey(row.event_type);
- const customer = {
- email: row.customer_email,
- first_name: row.customer_first_name,
- last_name: row.customer_last_name,
- display_name: row.customer_display_name,
- company_name: row.customer_company_name,
- };
const payload = composePayload({
- event: row, customer, daysBefore: offsetDays, businessName,
+ event: row, recipientEmail, daysBefore: offsetDays, businessName,
});
// Per-event body override: when present, append as a synthetic
// `body_override` field. The template engine should branch on it
@@ -217,7 +216,7 @@ async function runEventReminderPass() {
payload.body_override = row.event_reminder_body_override;
}
- await emailProcessor.queueEmail(row.id, customer.email, templateKey, payload);
+ await emailProcessor.queueEmail(row.id, recipientEmail, templateKey, payload);
// Stamp sent_at immediately so a same-pass-re-entrancy (or a
// crash between queueEmail and the update) doesn't double-send
@@ -254,8 +253,63 @@ async function runEventReminderPass() {
return { scanned: rows.length, sent, skipped };
}
+/**
+ * Send the pre-event reminder for ONE event — the per-event body of
+ * runEventReminderPass, reused by the workflow `notify_pre_event` action so the
+ * engine path is byte-identical to the legacy pass (same template resolution,
+ * per-event body override, recipient rule and `event_reminder_sent_at` idempotency).
+ *
+ * Returns { sent, skipped, reason? }. Never throws on a business skip (no email,
+ * disabled, already sent, no template-eligible recipient); only DB/queue errors
+ * propagate so the caller can surface them.
+ */
+async function sendReminderForEvent(eventId, { templateGroup = null } = {}) {
+ const hasCols = await hasColumnCached('events', 'event_reminder_sent_at');
+ if (!hasCols) return { sent: 0, skipped: 1, reason: 'schema_not_migrated' };
+
+ // Self-heal templates (idempotent, process-cached) — same as the pass.
+ try { await ensureEventReminderTemplatesSeeded(db, logger); } catch (err) {
+ logger.error('Event reminder template self-heal failed', { message: err.message });
+ }
+
+ // Recipient comes from the event row (customer_email / host_email) — events
+ // have no customer_account_id. `events.*` reads customer_email safely even on
+ // installs predating that column.
+ const row = await db('events').where('id', eventId).select('events.*').first();
+
+ if (!row) return { sent: 0, skipped: 1, reason: 'not_found' };
+ if (row.event_reminder_disabled) return { sent: 0, skipped: 1, reason: 'disabled' };
+ if (row.event_reminder_sent_at) return { sent: 0, skipped: 1, reason: 'already_sent' };
+ if (row.is_active === false || row.is_active === 0 || row.is_archived === true || row.is_archived === 1) {
+ return { sent: 0, skipped: 1, reason: 'inactive' };
+ }
+ const recipientEmail = row.customer_email || row.host_email;
+ if (!recipientEmail) return { sent: 0, skipped: 1, reason: 'no_recipient' };
+
+ const globalDaysBefore = Number(await getAppSetting('crm_event_reminders_days_before'));
+ const daysBeforeDefault = Number.isFinite(globalDaysBefore) && globalDaysBefore >= 0
+ ? globalDaysBefore : DEFAULT_DAYS_BEFORE;
+ const rawOffset = row.event_reminder_offset_days;
+ const offsetDays = (rawOffset != null && rawOffset !== '' && Number.isFinite(Number(rawOffset)))
+ ? Number(rawOffset) : daysBeforeDefault;
+
+ const profile = await db('business_profile').where({ id: 1 }).first('company_name');
+ const businessName = profile?.company_name || '';
+
+ // The flow block chooses the template GROUP (blank → the default group); the
+ // exact template is still auto-picked by event type within that group.
+ const templateKey = await resolveTemplateKey(row.event_type, templateGroup || DEFAULT_TEMPLATE_GROUP);
+ const payload = composePayload({ event: row, recipientEmail, daysBefore: offsetDays, businessName });
+ if (row.event_reminder_body_override) payload.body_override = row.event_reminder_body_override;
+
+ await emailProcessor.queueEmail(row.id, recipientEmail, templateKey, payload);
+ await db('events').where({ id: row.id }).update({ event_reminder_sent_at: new Date() });
+ return { sent: 1, skipped: 0, offsetDays };
+}
+
module.exports = {
runEventReminderPass,
+ sendReminderForEvent,
// exported for tests
_internal: {
resolveTemplateKey,
diff --git a/backend/src/services/eventService.js b/backend/src/services/eventService.js
index 3f7faea7..bb732247 100644
--- a/backend/src/services/eventService.js
+++ b/backend/src/services/eventService.js
@@ -10,6 +10,7 @@ const crypto = require('crypto');
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
+const logger = require('../utils/logger');
const { formatBoolean } = require('../utils/dbCompat');
const { hasColumnCached } = require('../utils/schemaCache');
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
@@ -283,6 +284,28 @@ const createEvent = async (eventData) => {
const insertResult = await db('events').insert(insertData).returning('id');
const eventId = insertResult[0]?.id || insertResult[0];
+ // Fire gallery.published — a gallery goes live the moment it's created (active
+ // + share link). Best-effort; emit is fail-closed when the workflows flag is
+ // off and never throws into the create path.
+ try {
+ await require('./workflows').emitWorkflowEvent('gallery.published', {
+ entityType: 'event',
+ entityId: eventId,
+ payload: {
+ eventId,
+ slug,
+ eventName: event_name,
+ eventDate: event_date,
+ customerEmail: customer_email || null,
+ adminEmail: admin_email || null,
+ galleryLink: shareUrl,
+ expiresAt: expires_at,
+ },
+ });
+ } catch (err) {
+ logger.warn('Failed to emit gallery.published workflow event', { eventId, error: err.message });
+ }
+
return {
id: eventId,
slug,
diff --git a/backend/src/services/eventTypeService.js b/backend/src/services/eventTypeService.js
index 1dbd8308..fb6d701f 100644
--- a/backend/src/services/eventTypeService.js
+++ b/backend/src/services/eventTypeService.js
@@ -7,6 +7,8 @@
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
+const { hasColumnCached } = require('../utils/schemaCache');
+const logger = require('../utils/logger');
/**
* Get all event types
@@ -201,7 +203,47 @@ const updateEventType = async (id, updates) => {
updateData.updated_at = new Date();
- await db('event_types').where('id', id).update(updateData);
+ // A slug_prefix rename must CASCADE, or it silently orphans everything keyed on
+ // the old slug: existing events/quotes (their event_type), and the per-type
+ // pre-event reminder template (event_reminder_). Re-point them so a
+ // rename behaves like a rename, not a detach. Atomic.
+ const oldSlug = eventType.slug_prefix;
+ const newSlug = updateData.slug_prefix;
+ const slugChanged = newSlug !== undefined && newSlug !== oldSlug;
+
+ if (!slugChanged) {
+ await db('event_types').where('id', id).update(updateData);
+ return getEventTypeById(id);
+ }
+
+ // Resolve schema lookups BEFORE opening the transaction — hasColumnCached
+ // reads via the global db, and a global read inside a SQLite transaction
+ // (single connection) deadlocks.
+ const quotesHasEventType = await hasColumnCached('quotes', 'event_type');
+
+ await db.transaction(async (trx) => {
+ await trx('event_types').where('id', id).update(updateData);
+ // Re-point existing documents from the old slug to the new one.
+ const evCount = await trx('events').where('event_type', oldSlug).update({ event_type: newSlug });
+ let qCount = 0;
+ if (quotesHasEventType) {
+ qCount = await trx('quotes').where('event_type', oldSlug).update({ event_type: newSlug });
+ }
+ // Carry the authored per-type reminder template along (subject/body follow the
+ // rename). Guard: never clobber an existing target template for the new slug.
+ const oldKey = `event_reminder_${oldSlug}`;
+ const newKey = `event_reminder_${newSlug}`;
+ let tplMoved = false;
+ const src = await trx('email_templates').where({ template_key: oldKey }).first('id');
+ const dst = await trx('email_templates').where({ template_key: newKey }).first('id');
+ if (src && !dst) {
+ await trx('email_templates').where({ template_key: oldKey }).update({ template_key: newKey });
+ tplMoved = true;
+ }
+ logger.info('Event type slug renamed — cascaded references', {
+ id, oldSlug, newSlug, events: evCount, quotes: qCount, reminderTemplateMoved: tplMoved,
+ });
+ });
return getEventTypeById(id);
};
diff --git a/backend/src/services/expirationChecker.js b/backend/src/services/expirationChecker.js
index 1666e1d3..d9876383 100644
--- a/backend/src/services/expirationChecker.js
+++ b/backend/src/services/expirationChecker.js
@@ -11,7 +11,7 @@ function startExpirationChecker() {
cron.schedule('0 * * * *', async () => {
await checkExpirations();
});
-
+
logger.info('Expiration checker started');
}
@@ -19,7 +19,22 @@ async function checkExpirations() {
try {
const now = new Date();
const warningDate = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days from now
-
+
+ // Mutual exclusion with the workflow engine: when the matching built-in flow
+ // is enabled, the engine sends the email (via notify_gallery_* actions). We
+ // still EMIT the trigger every pass (for the built-in AND any custom flows),
+ // but skip the LEGACY email so the two never double-send. State transitions
+ // (is_active=false, archive) always run regardless — they're the expiry
+ // mechanic, not the notification.
+ // Enabled-based mutual exclusion: the legacy email stands down only when the
+ // matching built-in is ENABLED (then its action sends the identical mail). A
+ // disabled built-in leaves the legacy send running — so the flows can ship
+ // disabled without galleries going un-notified, and disabling a flow reverts
+ // to legacy. The trigger is still emitted regardless (for any custom flows).
+ const { isBuiltinFlowActive } = require('./workflows');
+ const warningFlowOwns = await isBuiltinFlowActive('gallery_expiring');
+ const expiredFlowOwns = await isBuiltinFlowActive('gallery_expired');
+
// Check for events needing warning emails
// Skip events with null expires_at (they never expire)
const eventsNeedingWarning = await db('events')
@@ -28,19 +43,14 @@ async function checkExpirations() {
.whereNotNull('expires_at')
.where('expires_at', '<=', warningDate)
.where('expires_at', '>', now);
-
+
for (const event of eventsNeedingWarning) {
- // Check if warning email already sent
- const existingWarning = await db('email_queue')
- .where('event_id', event.id)
- .where('email_type', 'expiration_warning')
- .first();
-
- if (!existingWarning) {
- await queueExpirationWarning(event);
+ await emitGalleryExpiring(event); // always — for the built-in + any custom flows
+ if (!warningFlowOwns) {
+ await queueExpirationWarning(event); // legacy email (self-dedupes)
}
}
-
+
// Check for expired events
// Skip events with null expires_at (they never expire)
const expiredEvents = await db('events')
@@ -48,17 +58,57 @@ async function checkExpirations() {
.where('is_archived', formatBoolean(false))
.whereNotNull('expires_at')
.where('expires_at', '<=', now);
-
+
for (const event of expiredEvents) {
- await handleExpiredEvent(event);
+ await handleExpiredEvent(event, { sendLegacyEmails: !expiredFlowOwns });
}
-
+
} catch (error) {
logger.error('Error checking expirations:', error);
}
}
+/**
+ * Emit gallery.expiring for the workflow engine. Best-effort / fail-closed;
+ * deduped per (workflow, event) by emitWorkflowEvent so the hourly sweep fires
+ * a flow at most once per gallery.
+ */
+async function emitGalleryExpiring(event) {
+ try {
+ const daysRemaining = Math.ceil((new Date(event.expires_at) - new Date()) / (1000 * 60 * 60 * 24));
+ const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
+ await require('./workflows').emitWorkflowEvent('gallery.expiring', {
+ entityType: 'event',
+ entityId: event.id,
+ payload: {
+ eventId: event.id,
+ slug: event.slug,
+ eventName: event.event_name,
+ eventDate: event.event_date,
+ expiresAt: event.expires_at,
+ daysRemaining,
+ customerEmail: event.customer_email || event.host_email || null,
+ adminEmail: event.admin_email || null,
+ galleryLink: shareUrl,
+ },
+ });
+ } catch (err) {
+ logger.warn('Failed to emit gallery.expiring workflow event', { eventId: event.id, error: err.message });
+ }
+}
+
+/**
+ * Queue the customer expiration-warning email. Self-dedupes on the
+ * (event_id, 'expiration_warning') email_queue row so both the legacy hourly
+ * loop and the workflow `notify_gallery_expiring` action are safe to call it.
+ */
async function queueExpirationWarning(event) {
+ const existingWarning = await db('email_queue')
+ .where('event_id', event.id)
+ .where('email_type', 'expiration_warning')
+ .first();
+ if (existingWarning) return;
+
const daysRemaining = Math.ceil((new Date(event.expires_at) - new Date()) / (1000 * 60 * 60 * 24));
const recipientEmail = event.customer_email || event.host_email;
@@ -91,7 +141,49 @@ async function queueExpirationWarning(event) {
logger.info(`Queued expiration warning for event ${event.slug}`);
}
-async function handleExpiredEvent(event) {
+/**
+ * Queue the gallery_expired emails (customer + optional admin). Self-dedupes on
+ * the (event_id, 'gallery_expired') email_queue row, so both the legacy expiry
+ * handler and the workflow `notify_gallery_expired` action are safe to call it.
+ */
+async function sendGalleryExpiredEmails(event) {
+ const existing = await db('email_queue')
+ .where('event_id', event.id)
+ .where('email_type', 'gallery_expired')
+ .first();
+ if (existing) return;
+
+ // The shipped templates (EN/DE in legacy 028, NL/PT/RU in core 075) reference
+ // {{host_name}}, {{event_date}}, {{expiry_date}} and {{support_email}} — fill
+ // them all here.
+ const recipientEmail = event.customer_email || event.host_email;
+ const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
+ const supportEmail = await getSupportEmail();
+
+ const customerVars = {
+ customer_name: recipientName,
+ customer_email: recipientEmail,
+ host_name: recipientName,
+ event_name: event.event_name,
+ event_date: event.event_date,
+ expiry_date: event.expires_at,
+ admin_email: event.admin_email,
+ support_email: supportEmail
+ };
+
+ if (recipientEmail) {
+ await queueEmail(event.id, recipientEmail, 'gallery_expired', customerVars);
+ }
+ // Also notify admin (when configured).
+ if (event.admin_email && event.admin_email !== recipientEmail) {
+ await queueEmail(event.id, event.admin_email, 'gallery_expired', {
+ ...customerVars,
+ host_name: 'Admin'
+ });
+ }
+}
+
+async function handleExpiredEvent(event, { sendLegacyEmails = true } = {}) {
try {
// Mark as inactive
await db('events').where('id', event.id).update({ is_active: formatBoolean(false) });
@@ -120,45 +212,45 @@ async function handleExpiredEvent(event) {
});
} catch (e) { /* non-fatal */ }
- // Queue expiration emails. The shipped templates (EN/DE in legacy 028,
- // NL/PT/RU in core 075) reference {{host_name}}, {{event_date}},
- // {{expiry_date}} and {{support_email}}. Without these, customers used
- // to literally see "Hello {{host_name}}, your gallery expired on
- // {{expiry_date}}…" — fill them all here.
- const recipientEmail = event.customer_email || event.host_email;
- const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
- const supportEmail = await getSupportEmail();
-
- const customerVars = {
- customer_name: recipientName,
- customer_email: recipientEmail,
- host_name: recipientName,
- event_name: event.event_name,
- event_date: event.event_date,
- expiry_date: event.expires_at,
- admin_email: event.admin_email,
- support_email: supportEmail
- };
-
- if (recipientEmail) {
- await queueEmail(event.id, recipientEmail, 'gallery_expired', customerVars);
- }
-
- // Also notify admin (when configured).
- if (event.admin_email && event.admin_email !== recipientEmail) {
- await queueEmail(event.id, event.admin_email, 'gallery_expired', {
- ...customerVars,
- host_name: 'Admin'
+ // Emit gallery.expired for the workflow engine (sibling to the event.expired
+ // webhook). Always emitted; deduped per (workflow, event).
+ try {
+ await require('./workflows').emitWorkflowEvent('gallery.expired', {
+ entityType: 'event',
+ entityId: event.id,
+ payload: {
+ eventId: event.id,
+ slug: event.slug,
+ eventName: event.event_name,
+ eventDate: event.event_date,
+ expiresAt: event.expires_at,
+ customerEmail: event.customer_email || event.host_email || null,
+ adminEmail: event.admin_email || null,
+ },
});
+ } catch (err) {
+ logger.warn('Failed to emit gallery.expired workflow event', { eventId: event.id, error: err.message });
}
-
- // Start archiving process
+
+ // Legacy notification — skipped when the gallery_expired built-in flow drives
+ // it (the flow's notify_gallery_expired action sends the same emails).
+ if (sendLegacyEmails) {
+ await sendGalleryExpiredEmails(event);
+ }
+
+ // Start archiving process (always — the expiry mechanic, not the email).
await archiveEvent(event);
-
+
logger.info(`Handled expiration for event ${event.slug}`);
} catch (error) {
logger.error(`Error handling expired event ${event.slug}:`, error);
}
}
-module.exports = { startExpirationChecker };
+module.exports = {
+ startExpirationChecker,
+ // Reused by the workflow notify_gallery_* actions so the engine path sends the
+ // exact same emails as the legacy hourly checker.
+ queueExpirationWarning,
+ sendGalleryExpiredEmails,
+};
diff --git a/backend/src/services/invoiceSchedulerService.js b/backend/src/services/invoiceSchedulerService.js
index 181ab99e..a6ec2644 100644
--- a/backend/src/services/invoiceSchedulerService.js
+++ b/backend/src/services/invoiceSchedulerService.js
@@ -42,6 +42,23 @@ async function runTick() {
} catch (err) {
logger.error('Event reminder pass failed', { err: err.message });
}
+ try {
+ // Resume workflow runs whose wait has elapsed. No-op (fails closed) when
+ // the `workflows` feature flag is off. Independent try/catch so a workflow
+ // failure never suppresses the invoice/reminder jobs above.
+ const wf = require('./workflows');
+ const resumed = await wf.runDueWaits();
+ if (resumed) logger.info('Workflow scheduler: resumed waiting runs', { resumed });
+ // Fire pre-event reminders for events entering an enabled flow's lead window.
+ const preEvent = await wf.emitDueEventReminders();
+ if (preEvent) logger.info('Workflow scheduler: emitted pre-event reminders', { preEvent });
+ // Recover runs orphaned by a crash (stuck in running/pending). Runs on the
+ // boot tick too, so a restart catches anything stranded during downtime.
+ const recovered = await wf.recoverStaleRuns();
+ if (recovered) logger.warn('Workflow scheduler: recovered orphaned runs', { recovered });
+ } catch (err) {
+ logger.error('Workflow resume pass failed', { err: err.message });
+ }
}
function startInvoiceScheduler() {
diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js
index b25400bd..497ce2a6 100644
--- a/backend/src/services/invoiceService.js
+++ b/backend/src/services/invoiceService.js
@@ -1785,11 +1785,10 @@ async function buildInvoiceRenderContext(invoice, lineItems) {
vatAmountMinor: invoice.vat_amount_minor,
shippingAmountMinor: invoice.shipping_amount_minor,
totalAmountMinor: invoice.total_amount_minor,
- // Mahngebühr surfaced to the totals box (renders a row
- // between VAT and the grand-total divider) and folded
- // into the displayed Grand Total when > 0. Reminder
- // invoices after level 2 carry a non-zero value.
- lateFeeAmountMinor: invoice.late_fee_amount_minor || 0,
+ // The Mahngebühr is shown on the separate Mahnung document, NEVER on
+ // the (immutable) invoice — so the invoice render always reports 0. The
+ // Mahnung render path (applyReminder) overrides this with the tracked fee.
+ lateFeeAmountMinor: 0,
},
doc: {
// Document type discriminator. `'invoice'` (default) renders
@@ -1803,7 +1802,7 @@ async function buildInvoiceRenderContext(invoice, lineItems) {
issueDate: invoice.issue_date,
dueDate: invoice.due_date,
totalAmountMinor: invoice.total_amount_minor,
- lateFeeMinor: invoice.late_fee_amount_minor,
+ lateFeeMinor: 0,
// Reminder level — drives Skonto suppression on second
// reminders (no early-payment discount once the customer
// is in dunning).
@@ -2039,6 +2038,28 @@ async function sendInvoice(id, adminId) {
});
try { await logActivity('invoice_sent', { invoiceId: id }, invoice.event_id || null, `admin:${adminId}`); } catch (_) {}
+
+ // Fire the workflow engine's invoice.sent trigger (after the row is updated +
+ // the email queued). Idempotent per invoice id; no-op when the workflows flag
+ // is off. Never throws into the send path.
+ try {
+ await require('./workflows').emitWorkflowEvent('invoice.sent', {
+ entityType: 'invoice',
+ entityId: id,
+ payload: {
+ invoiceId: id,
+ invoiceNumber: invoice.invoice_number,
+ eventId: invoice.event_id || null,
+ customerAccountId: invoice.customer_account_id,
+ customerEmail: invoiceTo,
+ dueDate: invoice.due_date,
+ issueDate: invoice.issue_date,
+ totalMinor: invoice.total_amount_minor,
+ currency: invoice.currency,
+ },
+ });
+ } catch (_) {}
+
return { sent: true, pdfPath };
}
@@ -2068,7 +2089,7 @@ async function markPaid(id, { amountMinor, paidAt, paymentMethod, reference, not
? Math.max(0, ensureInt(invoice.total_amount_minor) - amount)
: null;
- return await db.transaction(async (trx) => {
+ const markResult = await db.transaction(async (trx) => {
await trx('invoice_payment_log').insert({
invoice_id: id,
amount_minor: amount,
@@ -2145,6 +2166,26 @@ async function markPaid(id, { amountMinor, paidAt, paymentMethod, reference, not
return { paidTotalMinor: total, status: isFull ? 'paid' : invoice.status };
});
+
+ // Fire invoice.paid for the workflow engine ONLY on the transition into
+ // 'paid' (mirrors the admin-notification guard above). After the commit so a
+ // workflow side effect can never roll back the recorded payment.
+ if (markResult.status === 'paid' && invoice.status !== 'paid') {
+ try {
+ await require('./workflows').emitWorkflowEvent('invoice.paid', {
+ entityType: 'invoice',
+ entityId: id,
+ payload: {
+ invoiceId: id,
+ invoiceNumber: invoice.invoice_number,
+ eventId: invoice.event_id || null,
+ customerAccountId: invoice.customer_account_id,
+ paidTotalMinor: markResult.paidTotalMinor,
+ },
+ });
+ } catch (_) {}
+ }
+ return markResult;
}
/**
@@ -2600,94 +2641,158 @@ async function sendReminder(id, levelOverride, adminId) {
throw new AppError(`Cannot remind on status '${invoice.status}'`, 409);
}
const newLevel = levelOverride || (invoice.reminder_level + 1);
- if (newLevel > 2) {
+ if (newLevel > 3) {
throw new AppError('Reminder level exhausted', 409);
}
return await applyReminder(invoice, lineItems, newLevel, adminId);
}
+// Per-reminder Mahngebühr in minor units (0 when disabled). Flat amount OR a
+// percentage of the invoice gross, per crm_invoices_late_fee_type. Charged from
+// the 2nd reminder onwards. ⚠️ A late fee is only enforceable if the concrete
+// amount is stated in the AGB — verify with a Treuhänder (the admin UI says so).
+// Net per-reminder Mahngebühr (flat amount or % of invoice gross), 0 disabled.
+async function resolveLateFeeNetMinor(invoice) {
+ if ((await getAppSetting('crm_invoices_late_fee_enabled')) === false) return 0;
+ const type = (await getAppSetting('crm_invoices_late_fee_type')) || 'flat';
+ let fee;
+ if (type === 'percent') {
+ const pct = Number(await getAppSetting('crm_invoices_late_fee_percent')) || 0;
+ fee = Math.round(Number(invoice.total_amount_minor || 0) * pct / 100);
+ } else {
+ fee = ensureInt(await getAppSetting('crm_invoices_late_fee_minor')) || 2500;
+ }
+ return Math.max(0, fee);
+}
+
+// VAT rate on the fee — jurisdiction-dependent (CH: yes; DE/AT: no), so
+// toggle-gated AND org-VAT-gated: 0 when the org has no default VAT rate, so
+// enabling the toggle on a non-VAT org adds nothing.
+async function resolveLateFeeVatRate() {
+ if ((await getAppSetting('crm_invoices_late_fee_vat_enabled')) !== true) return 0;
+ const profile = await db('business_profile').where({ id: 1 }).first('vat_rate_default');
+ return Number(profile?.vat_rate_default) || 0;
+}
+
+// Gross per-reminder fee (net + VAT) — for the admin payment-check preview.
+async function resolvePerReminderFeeMinor(invoice) {
+ const net = await resolveLateFeeNetMinor(invoice);
+ if (net <= 0) return 0;
+ const rate = await resolveLateFeeVatRate();
+ return rate > 0 ? net + Math.round(net * rate / 100) : net;
+}
+
async function applyReminder(invoice, lineItems, level, adminId) {
const customer = await db('customer_accounts').where({ id: invoice.customer_account_id }).first();
- let lateFeeMinor = invoice.late_fee_amount_minor || 0;
- if (level === 2) {
- const enabled = await getAppSetting('crm_invoices_late_fee_enabled');
- if (enabled !== false) {
- const fee = ensureInt(await getAppSetting('crm_invoices_late_fee_minor')) || 2500;
- lateFeeMinor = fee;
- }
- }
- const newTotal = invoice.total_amount_minor + lateFeeMinor;
- await db('invoices').where({ id: invoice.id }).update({
+ // Per fee-bearing reminder (levels 2..level): 2nd = 1×, 3rd = 2×, computed
+ // from `level` so re-applying the same level never stacks. The fee is dunning
+ // STATE on the row (gross + the VAT portion) — it is NOT shown on the
+ // immutable invoice; it appears on the separate Mahnung document below.
+ let lateFeeGross = invoice.late_fee_amount_minor || 0;
+ let lateFeeVat = invoice.late_fee_vat_minor || 0;
+ if (level >= 2) {
+ const net = await resolveLateFeeNetMinor(invoice);
+ const rate = await resolveLateFeeVatRate();
+ const vatPer = rate > 0 ? Math.round(net * rate / 100) : 0;
+ lateFeeGross = (level - 1) * (net + vatPer);
+ lateFeeVat = (level - 1) * vatPer;
+ }
+ const newTotal = Number(invoice.total_amount_minor || 0) + lateFeeGross;
+
+ const update = {
status: 'overdue',
reminder_level: level,
last_reminder_sent_at: new Date(),
- late_fee_amount_minor: lateFeeMinor,
+ late_fee_amount_minor: lateFeeGross,
updated_at: new Date(),
- });
+ };
+ if (await hasColumnCached('invoices', 'late_fee_vat_minor')) update.late_fee_vat_minor = lateFeeVat;
+ await db('invoices').where({ id: invoice.id }).update(update);
- // Re-render PDF so the late fee shows up.
+ // Fire invoice.overdue at the status→overdue flip. Deduped per (workflow,
+ // invoice), so across the reminder ladder it triggers a flow at most once.
+ // Best-effort / fail-closed.
+ try {
+ await require('./workflows').emitWorkflowEvent('invoice.overdue', {
+ entityType: 'invoice',
+ entityId: invoice.id,
+ payload: {
+ invoiceId: invoice.id,
+ invoiceNumber: invoice.invoice_number,
+ eventId: invoice.event_id || null,
+ customerAccountId: invoice.customer_account_id,
+ customerEmail: customer?.email || null,
+ dueDate: invoice.due_date,
+ reminderLevel: level,
+ totalMinor: invoice.total_amount_minor,
+ currency: invoice.currency,
+ },
+ });
+ } catch (_) {}
+
+ // Render the MAHNUNG (reminder letter). The original invoice PDF is left
+ // UNTOUCHED (immutable). The Mahnung reuses the invoice layout via a
+ // 'mahnung' kind: same line items + the Mahngebühr row + the new total, with
+ // a "Mahnung" title and no QR (it would encode the old amount).
const fresh = await db('invoices').where({ id: invoice.id }).first();
const ctx = await buildInvoiceRenderContext(fresh, lineItems);
+ ctx.doc.kind = 'mahnung';
+ ctx.doc.reminderLevel = level;
+ ctx.doc.lateFeeMinor = lateFeeGross;
+ ctx.totals.lateFeeAmountMinor = lateFeeGross;
const buffer = await pdfService.renderInvoiceToBuffer(ctx);
const fs = require('fs');
const path = require('path');
const year = new Date(fresh.issue_date).getFullYear();
- const root = path.join(process.cwd(), 'storage', 'business-docs', 'invoice', String(year));
+ const root = path.join(process.cwd(), 'storage', 'business-docs', 'mahnung', String(year));
fs.mkdirSync(root, { recursive: true });
- const pdfPath = path.join(root, `${fresh.invoice_number}.pdf`);
- fs.writeFileSync(pdfPath, buffer);
+ const mahnungPath = path.join(root, `${fresh.invoice_number}_mahnung_L${level}.pdf`);
+ fs.writeFileSync(mahnungPath, buffer);
- await db('invoices').where({ id: invoice.id }).update({ pdf_path: pdfPath, updated_at: new Date() });
-
- // days_overdue floors at 1 — a reminder that fires with "0 days
- // overdue" reads as broken to the customer ("Why am I getting this
- // already?"). The scheduler only triggers the row once
- // due_date <= now - reminder_first_days, so the natural minimum is
- // the configured threshold; for the manual "Send reminder now"
- // path the admin's intent is "this customer is late", so 1 is the
- // sensible lower bound even if the calendar arithmetic disagrees.
+ // days_overdue floors at 1 (a "0 days overdue" reminder reads as broken).
const rawDaysOverdue = Math.floor((Date.now() - new Date(invoice.due_date).getTime()) / 86400000);
const daysOverdue = Math.max(1, rawDaysOverdue);
const templateKey = level === 1 ? 'invoice_reminder_first' : 'invoice_reminder_second';
+ const locale = ctx.locale || invoice.language || 'de';
+ const outstandingMinor = Math.max(0, newTotal - Number(invoice.paid_amount_minor || 0));
- // Outstanding = gross total + late fee − already paid. Reminder
- // templates use this for the "outstanding is X" line so partial
- // payments are reflected in the reminder amount.
- const outstandingMinor = Math.max(0,
- Number(invoice.total_amount_minor || 0)
- + Number(lateFeeMinor || 0)
- - Number(invoice.paid_amount_minor || 0));
+ // Attach the (unchanged) original invoice PDF + the new Mahnung.
+ const attachments = [];
+ if (invoice.pdf_path && fs.existsSync(invoice.pdf_path)) {
+ attachments.push({ filename: `${invoice.invoice_number}.pdf`, contentPath: invoice.pdf_path, contentType: 'application/pdf' });
+ }
+ attachments.push({ filename: `${fresh.invoice_number}_Mahnung.pdf`, contentPath: mahnungPath, contentType: 'application/pdf' });
const { to: reminderTo, cc: reminderCc } = resolveBillingRecipients(customer, invoice.cc_pdf_email);
- await emailProcessor.queueEmail(invoice.event_id || null, reminderTo, templateKey, {
- invoice_number: invoice.invoice_number,
- customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0],
- total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, ctx.locale),
- new_total_amount: formatMajor(newTotal, invoice.currency, ctx.locale),
- outstanding_amount: formatMajor(outstandingMinor, invoice.currency, ctx.locale),
- paid_amount: formatMajor(invoice.paid_amount_minor, invoice.currency, ctx.locale),
- late_fee_amount: formatMajor(lateFeeMinor, invoice.currency, ctx.locale),
- // Format dates as DD.MM.YYYY for the customer-facing email
- // (matches the quote_sent + invoice_sent templates).
- due_date: formatShortDate(invoice.due_date),
- days_overdue: daysOverdue,
- cc: reminderCc,
- attachments: [{
- filename: `${invoice.invoice_number}.pdf`,
- contentPath: pdfPath,
- contentType: 'application/pdf',
- }],
- // Dunning reminders are relationship mail — hold to business hours so
- // the customer isn't pinged overnight (no-op unless hours configured).
- }, { respectBusinessHours: true });
+ try {
+ await emailProcessor.queueEmail(invoice.event_id || null, reminderTo, templateKey, {
+ invoice_number: invoice.invoice_number,
+ customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0],
+ total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, locale),
+ new_total_amount: formatMajor(newTotal, invoice.currency, locale),
+ outstanding_amount: formatMajor(outstandingMinor, invoice.currency, locale),
+ paid_amount: formatMajor(invoice.paid_amount_minor, invoice.currency, locale),
+ late_fee_amount: formatMajor(lateFeeGross, invoice.currency, locale),
+ due_date: formatShortDate(invoice.due_date),
+ days_overdue: daysOverdue,
+ cc: reminderCc,
+ attachments,
+ // Dunning reminders are relationship mail — hold to business hours.
+ }, { respectBusinessHours: true });
+ } catch (err) {
+ // Don't leave the just-rendered Mahnung PDF orphaned on disk if queueing the
+ // email failed — it would only be reachable via the next reminder anyway.
+ try { fs.unlinkSync(mahnungPath); } catch (_) { /* best-effort cleanup */ }
+ throw err;
+ }
try {
- await logActivity('invoice_reminder_sent', { invoiceId: invoice.id, level, lateFeeMinor },
+ await logActivity('invoice_reminder_sent', { invoiceId: invoice.id, level, lateFeeMinor: lateFeeGross },
invoice.event_id || null, `admin:${adminId || 'system'}`);
} catch (_) {}
- return { level, lateFeeMinor };
+ return { level, lateFeeMinor: lateFeeGross };
}
// ---------------------------------------------------------------------
@@ -2867,10 +2972,9 @@ async function queuePaymentCheckEmail(invoiceId, { skipThrottle = false } = {})
// Determine whether the customer reminder will include a Mahngebühr
// if the admin selects "Not paid" / "Partial" — surfaced to the
// email so the admin sees the consequence before clicking.
- const reminderLateFeeEnabled = (await getAppSetting('crm_invoices_late_fee_enabled')) !== false;
- const reminderFeeMinor = ensureInt(await getAppSetting('crm_invoices_late_fee_minor')) || 2500;
+ const reminderFeeMinor = await resolvePerReminderFeeMinor(invoice);
const nextLevel = (invoice.reminder_level || 0) + 1;
- const willChargeFee = reminderLateFeeEnabled && nextLevel >= 2;
+ const willChargeFee = reminderFeeMinor > 0 && nextLevel >= 2;
const baseUrl = process.env.FRONTEND_URL
|| (await getAppSetting('app_frontend_url'))
@@ -3121,7 +3225,7 @@ async function recordPaymentCheckAction({ token, action, amountMinor, ip, adminI
const refreshed = await db('invoices').where({ id: invoice.id }).first();
if (refreshed.status !== 'paid') {
const nextLevel = (refreshed.reminder_level || 0) + 1;
- if (nextLevel <= 2) {
+ if (nextLevel <= 3) {
const lineItems = await db('invoice_line_items')
.where({ invoice_id: invoice.id }).orderBy('position', 'asc');
await applyReminder(refreshed, lineItems, nextLevel, adminId);
@@ -3132,7 +3236,7 @@ async function recordPaymentCheckAction({ token, action, amountMinor, ip, adminI
// 'unpaid'
const nextLevel = (invoice.reminder_level || 0) + 1;
- if (nextLevel > 2) {
+ if (nextLevel > 3) {
// Already at max reminder — admin has to take this offline.
return { applied: 'unpaid', reminderSkipped: 'max_level_reached' };
}
@@ -3350,7 +3454,17 @@ async function runScheduledTasks() {
// Throttled to one email per 24h per invoice via
// invoices.last_payment_check_at.
const remindersEnabled = await getAppSetting('crm_invoices_reminders_enabled');
- if (remindersEnabled !== false) {
+ // Mutual exclusion with the workflow engine: the hardcoded ladder stands down
+ // only when the invoice_dunning built-in is ENABLED (then the engine fires the
+ // payment-check emails). A disabled built-in leaves this ladder running — so
+ // the flow can ship disabled without dunning going dark, and disabling the
+ // flow reverts to the ladder. Fails closed → ladder stays on if the subsystem
+ // is down.
+ let engineDrivesDunning = false;
+ try {
+ engineDrivesDunning = await require('./workflows').isBuiltinFlowActive('invoice_dunning');
+ } catch (_) { /* workflows tables absent / flag system down → ladder stays on */ }
+ if (remindersEnabled !== false && !engineDrivesDunning) {
const firstDays = ensureInt(await getAppSetting('crm_invoices_reminder_first_days')) || 14;
const secondDays = ensureInt(await getAppSetting('crm_invoices_reminder_second_days')) || 30;
@@ -3441,6 +3555,10 @@ module.exports = {
validateInstallmentPlanInput,
sendInvoice,
sendReminder,
+ applyReminder,
+ resolveLateFeeNetMinor,
+ resolveLateFeeVatRate,
+ resolvePerReminderFeeMinor,
markPaid,
cancelInvoice,
releaseForDelivery,
diff --git a/backend/src/services/pdf-i18n.js b/backend/src/services/pdf-i18n.js
index 22ce8e78..064c0002 100644
--- a/backend/src/services/pdf-i18n.js
+++ b/backend/src/services/pdf-i18n.js
@@ -25,6 +25,7 @@ const LABELS = {
// under the title that the customer/auditor needs to trace the
// §14c-defensible reversal.
storno_title: 'Cancellation invoice',
+ mahnung_title: 'Payment reminder',
reference_cancels: 'Cancels',
date: 'Date',
quote_number: 'Quote',
@@ -154,6 +155,7 @@ const LABELS = {
quote_number_label: 'Angebotsnummer',
invoice_number_label: 'Rechnungsnummer',
storno_title: 'Stornorechnung',
+ mahnung_title: 'Mahnung',
reference_cancels: 'Storno zu',
date: 'Datum',
quote_number: 'Angebot',
diff --git a/backend/src/services/pdfService.js b/backend/src/services/pdfService.js
index 0e4cf8d6..c12d607b 100644
--- a/backend/src/services/pdfService.js
+++ b/backend/src/services/pdfService.js
@@ -1477,6 +1477,10 @@ function renderDocument(type, context) {
// family — Storni share the invoice renderer surface, only
// the cosmetic + accounting-sign branches differ.
const isStorno = type === 'invoice' && ctx.doc.kind === 'storno';
+ // Mahnung (reminder letter) reuses the invoice surface: same line items +
+ // a Mahngebühr row + the new grand total, but a "Mahnung" title and NO
+ // QR (the QR would encode the original amount, not the new total).
+ const isMahnung = type === 'invoice' && ctx.doc.kind === 'mahnung';
// ---- document number (above) + date (below), both right-aligned
// The number sits directly under the sender address block so the
@@ -1516,7 +1520,9 @@ function renderDocument(type, context) {
? t(ctx.locale, 'quote_title')
: isStorno
? t(ctx.locale, 'storno_title')
- : t(ctx.locale, 'invoice_title');
+ : isMahnung
+ ? t(ctx.locale, 'mahnung_title')
+ : t(ctx.locale, 'invoice_title');
y = drawTitle(doc, title, leftX, y + 2);
// Mandatory Storno reference line — "Bezug: Storno zu Rechnung
@@ -1700,7 +1706,7 @@ function renderDocument(type, context) {
// Both append a fresh page; 'none' is a no-op.
// Suppressed on Stornorechnungen — negative-amount QR codes
// aren't a defined construct in either spec.
- if (type === 'invoice' && !isStorno) {
+ if (type === 'invoice' && !isStorno && !isMahnung) {
if (ctx.qrFormat === 'swiss') {
appendSwissQrBill(doc, ctx);
} else if (ctx.qrFormat === 'epc') {
diff --git a/backend/src/services/quoteService.js b/backend/src/services/quoteService.js
index 8c83686e..98aefae9 100644
--- a/backend/src/services/quoteService.js
+++ b/backend/src/services/quoteService.js
@@ -305,6 +305,25 @@ async function nextQuoteNumber(trx) {
return formatNumberInTemplate(format, year, seq);
}
+/**
+ * Resolve the fallback event type for a quote→event conversion when the quote
+ * itself carries none. Never hardcodes a specific slug (any of them, incl.
+ * 'other', can be disabled by the admin): prefer the generic 'other' catch-all
+ * when it's active, else the first active type by display order, and only fall
+ * back to the literal 'other' if the catalog is somehow empty/unreadable.
+ */
+async function resolveDefaultEventType(conn) {
+ const q = conn || db;
+ try {
+ const other = await q('event_types').where({ slug_prefix: 'other', is_active: true }).first('slug_prefix');
+ if (other) return 'other';
+ const firstActive = await q('event_types').where({ is_active: true }).orderBy('display_order', 'asc').first('slug_prefix');
+ return firstActive?.slug_prefix || 'other';
+ } catch (_) {
+ return 'other';
+ }
+}
+
function ensureCustomerFeatureEnabled(customer, feature) {
// Global toggle (`customer_feature_quotes_enabled` / `..._bills_enabled`)
// is checked at the route layer (feature flag); here we only enforce
@@ -563,6 +582,15 @@ async function createQuote(payload, adminId) {
if (payload.vatCode !== undefined && await hasColumnCached('quotes', 'vat_code')) {
row.vat_code = payload.vatCode ? String(payload.vatCode).slice(0, 16) : null;
}
+ // Migration 146 — event type (event_types.slug_prefix). Drives the type of
+ // the event the quote converts into, instead of the old hardcoded 'wedding'.
+ if (payload.eventType !== undefined && await hasColumnCached('quotes', 'event_type')) {
+ row.event_type = payload.eventType ? String(payload.eventType).slice(0, 64) : null;
+ }
+ // Migration 147 — the booking workflow this quote runs on acceptance.
+ if (payload.bookingWorkflowId !== undefined && await hasColumnCached('quotes', 'booking_workflow_id')) {
+ row.booking_workflow_id = payload.bookingWorkflowId || null;
+ }
const inserted = await trx('quotes').insert(row).returning('id');
const quoteId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
@@ -691,6 +719,14 @@ async function updateQuote(id, payload, adminId) {
if (Object.prototype.hasOwnProperty.call(payload, 'vatCode') && await hasColumnCached('quotes', 'vat_code')) {
updates.vat_code = payload.vatCode ? String(payload.vatCode).slice(0, 16) : null;
}
+ // Migration 146 — event type.
+ if (Object.prototype.hasOwnProperty.call(payload, 'eventType') && await hasColumnCached('quotes', 'event_type')) {
+ updates.event_type = payload.eventType ? String(payload.eventType).slice(0, 64) : null;
+ }
+ // Migration 147 — selected booking workflow.
+ if (Object.prototype.hasOwnProperty.call(payload, 'bookingWorkflowId') && await hasColumnCached('quotes', 'booking_workflow_id')) {
+ updates.booking_workflow_id = payload.bookingWorkflowId || null;
+ }
await trx('quotes').where({ id }).update(updates);
// When linked to a project, cascade across the deal lineage so the linked
@@ -981,6 +1017,11 @@ async function sendQuote(id, adminId) {
await logActivity('quote_sent', { quoteId: id, token }, null, `admin:${adminId}`);
} catch (_) {}
+ // Fire the quote.sent workflow trigger (best-effort; emit is fail-closed when
+ // the workflows flag is off). The accepted/declined emits already exist; this
+ // closes the gap so flows can react to a quote going out.
+ await emitQuoteEvent(quote, 'sent');
+
logger.info('Quote sent', { adminId, quoteId: id });
return { token, pdfPath };
}
@@ -1022,6 +1063,43 @@ async function persistDocPdf(type, doc, buffer) {
* the same token may flip accept↔decline. After the window expires the
* response is locked.
*/
+/**
+ * Fire a quote lifecycle event for the workflow engine. Best-effort: resolves
+ * the customer email (so send_email actions have a recipient) and never throws
+ * into the caller. No-op when the workflows flag is off (emit fails closed).
+ */
+async function emitQuoteEvent(quote, status) {
+ try {
+ let customerEmail = null;
+ if (quote.customer_account_id) {
+ const c = await db('customer_accounts').where({ id: quote.customer_account_id }).first();
+ customerEmail = c?.email || null;
+ }
+ // On acceptance, if the admin picked a booking workflow on the quote, run
+ // ONLY that flow (instead of fanning out to every enabled quote.accepted
+ // flow). Other statuses keep the normal fan-out.
+ const targetWorkflowId = (status === 'accepted' && quote.booking_workflow_id)
+ ? quote.booking_workflow_id
+ : null;
+ await require('./workflows').emitWorkflowEvent(`quote.${status}`, {
+ entityType: 'quote',
+ entityId: quote.id,
+ targetWorkflowId,
+ payload: {
+ quoteId: quote.id,
+ quoteNumber: quote.quote_number,
+ customerAccountId: quote.customer_account_id || null,
+ customerEmail,
+ eventName: quote.event_name || null,
+ eventDate: quote.event_date || null,
+ eventType: quote.event_type || null,
+ totalMinor: quote.total_amount_minor ?? null,
+ bookingWorkflowId: quote.booking_workflow_id || null,
+ },
+ });
+ } catch (_) { /* best-effort */ }
+}
+
async function recordResponse({ token, action, ip, tosAccepted }) {
if (!['accept', 'decline'].includes(action)) {
throw new AppError('Invalid action', 400);
@@ -1105,6 +1183,8 @@ async function recordResponse({ token, action, ip, tosAccepted }) {
await logActivity(`quote_${newStatus}`, { quoteId: quote.id, token: tokenRow.token }, null, 'customer:public');
} catch (_) {}
+ await emitQuoteEvent(quote, newStatus);
+
return { status: newStatus, lockedAt: responseLockedAt };
}
@@ -1202,6 +1282,8 @@ async function adminAcceptQuote(id, adminId) {
logger.warn('quote_accepted_customer email queue failed', { quoteId: id, err: err.message });
}
+ await emitQuoteEvent(quote, 'accepted');
+
return { status: 'accepted', lockedAt: responseLockedAt };
}
@@ -1265,6 +1347,8 @@ async function adminDeclineQuote(id, adminId, reason = null) {
await logActivity('quote_declined_by_admin', { quoteId: id, reason: cleanReason }, null, `admin:${adminId}`);
} catch (_) {}
+ await emitQuoteEvent(quote, 'declined');
+
return { status: 'declined', declinedAt: now };
}
@@ -1443,6 +1527,13 @@ async function convertToEvent(quoteId, adminId, options = {}) {
const customerEmail = customer.email || `${quote.quote_number.toLowerCase()}@picpeak.local`;
const adminEmail = adminRow?.email || customer.email || 'admin@picpeak.local';
+ // Event type for the new event: the type chosen on the quote (migration 146),
+ // else a configurable org default, else the resolved catch-all (an ACTIVE
+ // type — never a hardcoded slug the admin may have disabled).
+ const eventType = (quote.event_type && String(quote.event_type).trim())
+ || (await getAppSetting('crm_default_event_type'))
+ || (await resolveDefaultEventType(trx));
+
// Each candidate column is paired with the value we'd write. We
// ask the DB which columns exist and only keep the matching pairs
// — bullet-proof against schema drift in either direction.
@@ -1457,7 +1548,7 @@ async function convertToEvent(quoteId, adminId, options = {}) {
customer_email: customerEmail,
customer_phone: customer.phone,
admin_email: adminEmail,
- event_type: 'wedding',
+ event_type: eventType,
password_hash: placeholder,
share_link: shareLink,
share_token: shareLink,
diff --git a/backend/src/services/webhookService.js b/backend/src/services/webhookService.js
index 3dca6b02..9f450d66 100644
--- a/backend/src/services/webhookService.js
+++ b/backend/src/services/webhookService.js
@@ -210,6 +210,38 @@ function parseJsonField(value, fallback) {
try { return JSON.parse(value) ?? fallback; } catch { return fallback; }
}
+/**
+ * Enqueue a delivery for ONE specific active webhook subscription, bypassing the
+ * event-type subscription matching that `fire` does. Used by the workflow engine
+ * `webhook` action: the flow author picks a configured webhook (which carries
+ * the URL + signing secret + create-time URL validation), and the delivery then
+ * rides the SAME worker pipeline as every other webhook — per-delivery SSRF
+ * re-validation, HMAC signing, retries/backoff and the audit log, all for free.
+ * Never throws. Returns { enqueued, reason?, deliveryId? }.
+ */
+async function enqueueForWebhook(webhookId, eventType, data) {
+ try {
+ const w = await db('webhooks').where({ id: webhookId, active: true }).first();
+ if (!w) return { enqueued: false, reason: 'webhook not found or inactive' };
+ const now = new Date();
+ const deliveryUuid = crypto.randomUUID();
+ const envelope = { id: deliveryUuid, type: eventType, created_at: now.toISOString(), data };
+ await db('webhook_deliveries').insert({
+ webhook_id: w.id,
+ event_type: String(eventType).slice(0, 64),
+ payload: JSON.stringify(envelope),
+ attempt_count: 0,
+ status: 'pending',
+ next_retry_at: now,
+ created_at: now,
+ });
+ return { enqueued: true, webhookId: w.id, deliveryId: deliveryUuid };
+ } catch (err) {
+ logger.error(`[webhookService.enqueueForWebhook] failed for #${webhookId}: ${err.message}`);
+ return { enqueued: false, reason: err.message };
+ }
+}
+
/**
* Canonical event sub-object for outbound webhooks (#341). Always returns
* the full key set so receivers don't have to handle "field missing vs
@@ -235,6 +267,7 @@ function buildEventSubject(input = {}) {
module.exports = {
fire,
+ enqueueForWebhook,
generateSecret,
signPayload,
verifySignature,
diff --git a/backend/src/services/workflows/actions.js b/backend/src/services/workflows/actions.js
new file mode 100644
index 00000000..468a32bb
--- /dev/null
+++ b/backend/src/services/workflows/actions.js
@@ -0,0 +1,220 @@
+/**
+ * Workflow action + condition handlers that touch real picpeak data.
+ *
+ * Registered at load time (index.js requires this module). Kept separate from
+ * registry.js (which holds only primitives) so the I/O-coupled handlers don't
+ * bloat the pure core.
+ *
+ * Email routing rule (locked requirement): INTERNAL/admin mail sends
+ * immediately; EXTERNAL/customer mail respects the business-hours floor. The
+ * action sets queueEmail's `respectBusinessHours` from the recipient class.
+ *
+ * The create/prepare-document actions (quote/contract/event/gallery/invoice)
+ * are registered so flows validate, but are intentionally NOT wired to the
+ * services yet — they record a `skipped` step with a clear reason so the gap
+ * is observable rather than silent. Wiring is a follow-up commit.
+ */
+const registry = require('./registry');
+
+const DOCUMENT_ACTIONS = [
+ 'prepare_quote',
+ 'prepare_contract',
+ 'prepare_event',
+ 'prepare_gallery',
+ 'prepare_invoice',
+ 'send_document',
+ 'reserve_date',
+];
+
+// --- Conditions ---
+
+// True once the run's invoice entity is settled (paid_at set, status paid, or
+// the cumulative paid amount covers the total).
+registry.registerCondition('invoice_paid', async (ctx) => {
+ const id = ctx.run.entity_id;
+ if (!id) return false;
+ const inv = await ctx.db('invoices').where({ id }).first();
+ if (!inv) return false;
+ if (inv.paid_at) return true;
+ if (inv.status === 'paid') return true;
+ const paid = Number(inv.paid_amount_minor) || 0;
+ const total = Number(inv.total_amount_minor);
+ return Number.isFinite(total) && total > 0 && paid >= total;
+});
+
+// --- Actions ---
+
+// Queue an email. recipientClass 'admin' (internal) sends immediately;
+// anything else (customer/external) respects the business-hours floor.
+registry.registerAction('send_email', async (ctx) => {
+ const cfg = ctx.node.config || {};
+ if (ctx.vars?.__dryRun) return { dryRun: true, would: 'send_email', recipientClass: cfg.recipientClass || cfg.recipient || 'customer', emailType: cfg.emailType || cfg.template };
+ const recipientClass = cfg.recipientClass || cfg.recipient || 'customer';
+ const isInternal = recipientClass === 'admin' || recipientClass === 'internal';
+ const to = cfg.to
+ || ctx.vars[isInternal ? 'adminEmail' : 'customerEmail']
+ || ctx.vars.recipientEmail;
+ if (!to) return { skipped: true, reason: 'no recipient resolved' };
+
+ const emailProcessor = require('../emailProcessor');
+ const eventId = ctx.vars.eventId || null;
+ const emailType = cfg.emailType || cfg.template || 'workflow_notification';
+ const emailData = { ...(cfg.emailData || {}), ...(ctx.vars.emailData || {}) };
+
+ // INTERNAL/admin = immediate; EXTERNAL/customer = business-hours floor.
+ const respectBusinessHours = !isInternal;
+ await emailProcessor.queueEmail(eventId, to, emailType, emailData, { respectBusinessHours });
+ return { sent_to: to, recipientClass, respectBusinessHours };
+});
+
+// Fire the existing admin payment-check email (the dunning gate). Delegates to
+// invoiceService.queuePaymentCheckEmail so the proven escalation +
+// Mahngebühr / reminder_level state machine (recordPaymentCheckAction) stays
+// the single source of truth — the workflow only decides WHEN it fires. This
+// is what makes the built-in dunning flow a faithful replacement for the
+// hardcoded ladder (paired with the mutual-exclusion guard in runScheduledTasks).
+registry.registerAction('queue_payment_check', async (ctx) => {
+ const id = ctx.run.entity_id;
+ if (!id) return { skipped: true, reason: 'no invoice entity' };
+ if (ctx.vars?.__dryRun) return { dryRun: true, would: 'queue_payment_check', invoiceId: id };
+ await require('../invoiceService').queuePaymentCheckEmail(id);
+ return { payment_check_queued: id };
+});
+
+// After the dunning loop exhausts (e.g. 3 unpaid reminders), consolidate
+// everything collections needs into ONE email to the admin: customer data, the
+// outstanding total (invoice + late fees − paid) and the invoice PDF attached —
+// ready to forward to an Inkasso agency / for Betreibung. Internal mail → sent
+// immediately. Does NOT touch the invoice.
+registry.registerAction('escalate_to_collections', async (ctx) => {
+ const id = ctx.run.entity_id;
+ if (!id) return { skipped: true, reason: 'no invoice entity' };
+ if (ctx.vars?.__dryRun) return { dryRun: true, would: 'escalate_to_collections', invoiceId: id };
+ const { db } = ctx;
+ const invoice = await db('invoices').where({ id }).first();
+ if (!invoice) return { skipped: true, reason: 'invoice not found' };
+ const customer = invoice.customer_account_id
+ ? await db('customer_accounts').where({ id: invoice.customer_account_id }).first()
+ : null;
+ const profile = await db('business_profile').where({ id: 1 }).first();
+ const adminEmail = ctx.vars?.adminEmail || profile?.email || null;
+ if (!adminEmail) return { skipped: true, reason: 'no admin email' };
+
+ const currency = invoice.currency || 'CHF';
+ const fmt = (m) => `${currency} ${(Number(m || 0) / 100).toFixed(2)}`;
+ const total = Number(invoice.total_amount_minor || 0);
+ const fee = Number(invoice.late_fee_amount_minor || 0);
+ const paid = Number(invoice.paid_amount_minor || 0);
+ const outstanding = Math.max(0, total + fee - paid);
+ const address = [customer?.address, customer?.postal_code, customer?.city, customer?.country_name]
+ .filter(Boolean).join(', ');
+
+ const attachments = [];
+ try {
+ const fs = require('fs');
+ if (invoice.pdf_path && fs.existsSync(invoice.pdf_path)) {
+ attachments.push({ filename: `${invoice.invoice_number}.pdf`, contentPath: invoice.pdf_path, contentType: 'application/pdf' });
+ }
+ } catch (_) { /* attachment is best-effort */ }
+
+ await require('../emailProcessor').queueEmail(invoice.event_id || null, adminEmail, 'invoice_collections_handoff', {
+ invoice_number: invoice.invoice_number,
+ customer_name: customer?.display_name || customer?.email || '—',
+ customer_email: customer?.email || '',
+ customer_address: address,
+ event_name: invoice.event_name || '',
+ original_amount: fmt(total),
+ late_fee_amount: fee ? fmt(fee) : '',
+ paid_amount: fmt(paid),
+ outstanding_amount: fmt(outstanding),
+ due_date: invoice.due_date ? String(invoice.due_date).slice(0, 10) : '',
+ reminder_level: invoice.reminder_level || 0,
+ attachments,
+ }, { respectBusinessHours: false }); // internal/admin → immediate
+
+ return { collections_handoff_to: adminEmail, outstanding };
+});
+
+// --- Gallery / pre-event notification actions (cutover) ---
+//
+// These DELEGATE to the existing service send functions, so the engine path is
+// byte-identical to the legacy hourly checker/pass it replaces (same templates,
+// recipients, variables, dedup). The legacy path stands down when the matching
+// built-in flow is enabled (isBuiltinFlowActive guard), so exactly one email
+// goes out.
+
+// Send the gallery expiration-warning email for the run's event entity.
+registry.registerAction('notify_gallery_expiring', async (ctx) => {
+ const id = ctx.run.entity_id;
+ if (!id) return { skipped: true, reason: 'no event entity' };
+ if (ctx.vars?.__dryRun) return { dryRun: true, would: 'notify_gallery_expiring', eventId: id };
+ const event = await ctx.db('events').where({ id }).first();
+ if (!event) return { skipped: true, reason: 'event not found' };
+ await require('../expirationChecker').queueExpirationWarning(event);
+ return { warning_queued: id };
+});
+
+// Send the gallery_expired email(s) for the run's event entity.
+registry.registerAction('notify_gallery_expired', async (ctx) => {
+ const id = ctx.run.entity_id;
+ if (!id) return { skipped: true, reason: 'no event entity' };
+ if (ctx.vars?.__dryRun) return { dryRun: true, would: 'notify_gallery_expired', eventId: id };
+ const event = await ctx.db('events').where({ id }).first();
+ if (!event) return { skipped: true, reason: 'event not found' };
+ await require('../expirationChecker').sendGalleryExpiredEmails(event);
+ return { expired_email_queued: id };
+});
+
+// Send the pre-event customer reminder for the run's event entity. Delegates to
+// eventReminderService so per-event overrides + sent_at idempotency are honoured.
+registry.registerAction('notify_pre_event', async (ctx) => {
+ const id = ctx.run.entity_id;
+ if (!id) return { skipped: true, reason: 'no event entity' };
+ // The template GROUP is chosen on THIS block (config.templateGroup, e.g.
+ // 'event_reminder'); the exact template is still auto-picked by event type
+ // within that group. Blank → the default group.
+ const templateGroup = ctx.node.config?.templateGroup || null;
+ if (ctx.vars?.__dryRun) return { dryRun: true, would: 'notify_pre_event', eventId: id, templateGroup };
+ const res = await require('../eventReminderService').sendReminderForEvent(id, { templateGroup });
+ return res;
+});
+
+// Call a webhook (the `webhook` node type + the "Call a webhook" action both
+// resolve here). The flow author picks a CONFIGURED webhook subscription
+// (config.webhookId, managed in Settings → Webhooks); this enqueues a real
+// delivery for it, so it rides the same worker pipeline as every other webhook:
+// per-delivery SSRF re-validation (validateExternalUrl / GHSA-wmjx-pc37-272r),
+// HMAC signing with the subscription's secret, retries/backoff, and the audit
+// log — all inherited, nothing reimplemented. Best-effort: an unset / missing /
+// inactive webhook records an observable skipped step.
+registry.registerAction('webhook', async (ctx) => {
+ const webhookId = ctx.node.config?.webhookId ? Number(ctx.node.config.webhookId) : null;
+ if (!webhookId) return { skipped: true, reason: 'no webhook selected (pick one in Settings → Webhooks)' };
+ if (ctx.vars?.__dryRun) return { dryRun: true, would: 'webhook', webhookId };
+
+ const eventType = `workflow.${ctx.run.trigger_event || 'webhook'}`;
+ const res = await require('../webhookService').enqueueForWebhook(webhookId, eventType, {
+ workflow: { id: ctx.run.workflow_id, version: ctx.run.version },
+ run: {
+ id: ctx.run.id,
+ trigger_event: ctx.run.trigger_event,
+ entity_type: ctx.run.entity_type,
+ entity_id: ctx.run.entity_id,
+ },
+ vars: ctx.vars || {},
+ });
+ return res.enqueued
+ ? { webhook_enqueued: res.webhookId, deliveryId: res.deliveryId }
+ : { skipped: true, reason: res.reason };
+});
+
+// Create/prepare-document actions — registered so flows referencing them are
+// valid; service wiring is a follow-up. Records a skipped step (observable).
+for (const key of DOCUMENT_ACTIONS) {
+ registry.registerAction(key, async (ctx) => {
+ ctx.logger?.warn?.('[workflow] document action not yet wired', { action: key, runId: ctx.run.id });
+ return { skipped: true, reason: `action ${key} not yet implemented` };
+ });
+}
+
+module.exports = { DOCUMENT_ACTIONS };
diff --git a/backend/src/services/workflows/approvals.js b/backend/src/services/workflows/approvals.js
new file mode 100644
index 00000000..aee175d7
--- /dev/null
+++ b/backend/src/services/workflows/approvals.js
@@ -0,0 +1,140 @@
+/**
+ * Workflow approval gates — the human-in-the-loop step.
+ *
+ * When the engine hits a `gate` node it calls the registered `gate_setup`
+ * action, which creates a workflow_approvals row (single-use token stored as a
+ * SHA-256 hash) and emails the admin a confirm/deny link. The run stays
+ * `waiting` until the admin acts — via the email link (actByToken) or the
+ * webview pending-approvals inbox (actById) — at which point the run resumes
+ * down the matching confirm/deny edge.
+ *
+ * Internal/admin mail → sent immediately (respectBusinessHours: false).
+ */
+const crypto = require('crypto');
+const { db } = require('../../database/db');
+const logger = require('../../utils/logger');
+const registry = require('./registry');
+const engine = require('./engine');
+
+function hashToken(raw) {
+ return crypto.createHash('sha256').update(String(raw)).digest('hex');
+}
+
+/**
+ * gate_setup action — create the approval + email the admin. Called by the
+ * engine when a gate node is reached. Best-effort on the email; the approval
+ * row (and thus the inbox path) is always created.
+ */
+async function createApproval(ctx) {
+ const { run, node } = ctx;
+ const cfg = node.config || {};
+ const raw = crypto.randomBytes(32).toString('hex');
+ const expiresAt = cfg.timeoutDays
+ ? new Date(Date.now() + Number(cfg.timeoutDays) * 86400000).toISOString()
+ : null;
+
+ await db('workflow_approvals').insert({
+ run_id: run.id,
+ node_key: node.node_key,
+ type: cfg.type || 'payment_confirm',
+ status: 'pending',
+ token_hash: hashToken(raw),
+ payload: JSON.stringify({ prompt: cfg.prompt || null, vars: ctx.vars || {} }),
+ expires_at: expiresAt,
+ created_at: db.fn.now(),
+ });
+
+ try {
+ const { getFrontendBaseUrl } = require('../../utils/frontendUrl');
+ const base = (await getFrontendBaseUrl()) || '';
+ const confirmUrl = `${base}/api/public/workflow-approvals/${raw}/confirm`;
+ const denyUrl = `${base}/api/public/workflow-approvals/${raw}/deny`;
+
+ let adminEmail = ctx.vars?.adminEmail || null;
+ if (!adminEmail) {
+ const bp = await db('business_profile').where({ id: 1 }).first('email');
+ adminEmail = bp?.email || null;
+ }
+ if (adminEmail) {
+ const emailProcessor = require('../emailProcessor');
+ await emailProcessor.queueEmail(
+ ctx.vars?.eventId || null,
+ adminEmail,
+ cfg.emailType || 'workflow_approval',
+ {
+ prompt: cfg.prompt || 'A workflow needs your confirmation.',
+ confirm_url: confirmUrl,
+ deny_url: denyUrl,
+ ...(ctx.vars?.emailData || {}),
+ },
+ { respectBusinessHours: false }, // internal/admin → immediate
+ );
+ } else {
+ logger.warn('[workflow] approval created but no admin email to notify', { runId: run.id });
+ }
+ } catch (e) {
+ logger.error('[workflow] approval email failed', { runId: run.id, error: e.message });
+ }
+
+ return { approval: true };
+}
+
+registry.registerAction('gate_setup', createApproval);
+
+async function finalizeApproval(approval, decision, actorPatch) {
+ if (!approval) return { ok: false, reason: 'not_found' };
+ if (approval.status !== 'pending') return { ok: true, already: true, status: approval.status };
+ if (approval.expires_at && new Date(approval.expires_at).getTime() < Date.now()) {
+ await db('workflow_approvals').where({ id: approval.id }).update({ status: 'expired' });
+ return { ok: false, reason: 'expired' };
+ }
+ const status = decision === 'confirm' ? 'confirmed' : 'denied';
+ await db('workflow_approvals').where({ id: approval.id })
+ .update({ status, acted_at: db.fn.now(), ...actorPatch });
+ // Resume down the matching edge (handles 'confirm' | 'deny').
+ await engine.resumeRun(approval.run_id, { decisionHandle: decision });
+ return { ok: true, status };
+}
+
+/** Act on an approval via the emailed single-use token. */
+async function actByToken(rawToken, decision) {
+ const approval = await db('workflow_approvals').where({ token_hash: hashToken(rawToken) }).first();
+ return finalizeApproval(approval, decision, { acted_via: 'email' });
+}
+
+/**
+ * Read-only lookup for the emailed token — used to render the confirm/deny
+ * interstitial WITHOUT mutating state (so email-client prefetchers can't
+ * advance the gate). Never resumes the run.
+ */
+async function peekApproval(rawToken) {
+ const a = await db('workflow_approvals').where({ token_hash: hashToken(rawToken) }).first();
+ if (!a) return { found: false };
+ let prompt = null;
+ try { prompt = (JSON.parse(a.payload || '{}') || {}).prompt || null; } catch (_) { /* ignore */ }
+ const expired = !!(a.expires_at && new Date(a.expires_at).getTime() < Date.now());
+ return { found: true, status: a.status, prompt, expired };
+}
+
+/** Act on an approval from the admin webview inbox. */
+async function actById(id, decision, adminId) {
+ const approval = await db('workflow_approvals').where({ id }).first();
+ return finalizeApproval(approval, decision, { acted_via: 'web', acted_by: adminId || null });
+}
+
+/** Pending approvals for the webview inbox, newest first, with workflow name. */
+async function listPending(limit = 100) {
+ return db('workflow_approvals as a')
+ .join('workflow_runs as r', 'r.id', 'a.run_id')
+ .join('workflows as w', 'w.id', 'r.workflow_id')
+ .where('a.status', 'pending')
+ .select(
+ 'a.id', 'a.type', 'a.payload', 'a.created_at', 'a.expires_at',
+ 'r.id as run_id', 'r.entity_type', 'r.entity_id',
+ 'w.id as workflow_id', 'w.name as workflow_name',
+ )
+ .orderBy('a.created_at', 'desc')
+ .limit(limit);
+}
+
+module.exports = { hashToken, createApproval, actByToken, actById, listPending, peekApproval };
diff --git a/backend/src/services/workflows/engine.js b/backend/src/services/workflows/engine.js
new file mode 100644
index 00000000..7d7cb4c5
--- /dev/null
+++ b/backend/src/services/workflows/engine.js
@@ -0,0 +1,561 @@
+/**
+ * Workflow execution engine — walks a flow GRAPH (nodes + edges) per run.
+ *
+ * Node types: trigger | condition/branch | loop | wait | gate | action | webhook.
+ * - condition/branch: run a registered condition → follow the yes/no edge.
+ * - loop: increment a per-node counter in run.context → follow loop/exit edge
+ * (bounded by config.maxIterations — no infinite runs).
+ * - wait: set status='waiting' + wake_at; the scheduler resumes it later.
+ * - gate: set status='waiting'; an approval (email confirm/deny or the webview
+ * inbox) resumes it via the matching confirm/deny edge.
+ * - action/webhook: dispatch to a registered action handler.
+ *
+ * Runs are idempotent (unique dedup_key per trigger+entity) and every node
+ * writes a workflow_run_steps row for observability / System Health. Designed
+ * to be called AFTER the caller's DB commit (emit never throws into callers).
+ */
+const { db } = require('../../database/db');
+const logger = require('../../utils/logger');
+const registry = require('./registry');
+
+const MAX_STEPS_PER_ADVANCE = 200;
+
+function parseJson(value, fallback) {
+ if (value == null) return fallback;
+ if (typeof value === 'object') return value;
+ try { return JSON.parse(value); } catch (e) { return fallback; }
+}
+
+async function loadGraph(workflowId, version) {
+ const nodes = await db('workflow_nodes').where({ workflow_id: workflowId, version });
+ const edges = await db('workflow_edges').where({ workflow_id: workflowId, version });
+ const nodeByKey = new Map(nodes.map((n) => [n.node_key, { ...n, config: parseJson(n.config, {}) }]));
+ return { nodeByKey, edges };
+}
+
+// Pick the outgoing edge from `fromNode`. With a handle, prefer the matching
+// handle; otherwise fall back to a default (null-handle) edge or the sole edge.
+function outEdge(edges, fromNode, handle) {
+ const candidates = edges.filter((e) => e.from_node === fromNode);
+ if (handle != null) {
+ const exact = candidates.find((e) => (e.from_handle || null) === handle);
+ if (exact) return exact;
+ }
+ return candidates.find((e) => e.from_handle == null) || (candidates.length === 1 ? candidates[0] : null);
+}
+
+function computeWakeAt(config = {}, vars = {}) {
+ const cfg = config || {};
+ if (cfg.untilVar && vars[cfg.untilVar]) return new Date(vars[cfg.untilVar]).toISOString();
+ const ms = (Number(cfg.delayDays || 0) * 86400000)
+ + (Number(cfg.delayHours || 0) * 3600000)
+ + (Number(cfg.delayMinutes || 0) * 60000);
+ return new Date(Date.now() + ms).toISOString();
+}
+
+function gateTimeout(config = {}) {
+ const days = Number((config || {}).timeoutDays || 0);
+ return days > 0 ? new Date(Date.now() + days * 86400000).toISOString() : null;
+}
+
+function matchFilter(filter, payload) {
+ if (!filter || typeof filter !== 'object') return true;
+ const { field, op = 'eq', value } = filter;
+ const actual = payload ? payload[field] : undefined;
+ // Strict equality: a filter {value: 0} must NOT match false/''/null (loose ==
+ // conflated them). Authors must therefore match the payload's actual type.
+ switch (op) {
+ case 'neq': return actual !== value;
+ case 'truthy': return Boolean(actual);
+ case 'falsy': return !actual;
+ case 'eq':
+ default: return actual === value;
+ }
+}
+
+async function recordStep(runId, node, status, result, error) {
+ await db('workflow_run_steps').insert({
+ run_id: runId,
+ node_key: node.node_key,
+ node_type: node.type,
+ status,
+ result: result ? JSON.stringify(result) : null,
+ error: error || null,
+ finished_at: db.fn.now(),
+ });
+}
+
+async function failRun(runId, error) {
+ await db('workflow_runs').where({ id: runId }).update({ status: 'failed', error, finished_at: db.fn.now() });
+ logger.error('[workflow] run failed', { runId, error });
+}
+
+async function finishRun(runId) {
+ await db('workflow_runs').where({ id: runId }).update({ status: 'done', finished_at: db.fn.now() });
+}
+
+/**
+ * Walk the graph from the run's current node until it ends, fails, or pauses
+ * (wait / gate). Persists context + current_node after each node.
+ */
+async function advanceRun(runId) {
+ let run = await db('workflow_runs').where({ id: runId }).first();
+ if (!run || run.status !== 'running') return;
+ const { nodeByKey, edges } = await loadGraph(run.workflow_id, run.version);
+ const context = parseJson(run.context, { vars: {} });
+ if (!context.vars) context.vars = {};
+
+ let currentKey = run.current_node;
+ let steps = 0;
+
+ while (currentKey) {
+ if (++steps > MAX_STEPS_PER_ADVANCE) { await failRun(runId, 'max steps per advance exceeded'); return; }
+ const node = nodeByKey.get(currentKey);
+ if (!node) { await failRun(runId, `node not found: ${currentKey}`); return; }
+
+ const ctx = { run, node, vars: context.vars, db, logger };
+ let nextKey = null;
+
+ try {
+ switch (node.type) {
+ case 'trigger': {
+ const e = outEdge(edges, currentKey, null);
+ nextKey = e ? e.to_node : null;
+ await recordStep(runId, node, 'done', null);
+ break;
+ }
+ case 'condition':
+ case 'branch': {
+ const cond = registry.getCondition(node.config?.condition || 'expr');
+ const result = cond ? await cond(ctx) : false;
+ const handle = result ? (node.config?.trueHandle || 'yes') : (node.config?.falseHandle || 'no');
+ const e = outEdge(edges, currentKey, handle) || outEdge(edges, currentKey, result ? 'true' : 'false');
+ nextKey = e ? e.to_node : null;
+ await recordStep(runId, node, 'done', { result, handle });
+ break;
+ }
+ case 'loop': {
+ const counterKey = `__loop_${node.node_key}`;
+ const count = (Number(context.vars[counterKey]) || 0) + 1;
+ context.vars[counterKey] = count;
+ const max = Number(node.config?.maxIterations ?? node.config?.max ?? 3);
+ const handle = count > max ? (node.config?.exitHandle || 'exit') : (node.config?.loopHandle || 'loop');
+ const e = outEdge(edges, currentKey, handle);
+ nextKey = e ? e.to_node : null;
+ await recordStep(runId, node, 'done', { count, max, handle });
+ break;
+ }
+ case 'wait': {
+ // Dry-run (test-fire): don't park — pass straight through so the whole
+ // flow runs in one shot, recording what it WOULD have waited for.
+ if (context.vars.__dryRun) {
+ const e = outEdge(edges, currentKey, null);
+ nextKey = e ? e.to_node : null;
+ await recordStep(runId, node, 'skipped', { dryRun: true, wouldWaitUntil: computeWakeAt(node.config, context.vars) });
+ break;
+ }
+ const wakeAt = computeWakeAt(node.config, context.vars);
+ await db('workflow_runs').where({ id: runId })
+ .update({ status: 'waiting', wake_at: wakeAt, current_node: currentKey, context: JSON.stringify(context) });
+ await recordStep(runId, node, 'waiting', { wake_at: wakeAt });
+ return; // paused — scheduler resumes when wake_at passes
+ }
+ case 'gate': {
+ // Dry-run (test-fire): auto-take the 'confirm' path so the escalation
+ // is exercised end-to-end, without creating an approval / emailing.
+ if (context.vars.__dryRun) {
+ const e = outEdge(edges, currentKey, 'confirm') || outEdge(edges, currentKey, null);
+ nextKey = e ? e.to_node : null;
+ await recordStep(runId, node, 'skipped', { dryRun: true, gateAutoConfirm: true });
+ break;
+ }
+ await db('workflow_runs').where({ id: runId })
+ .update({ status: 'waiting', wake_at: gateTimeout(node.config), current_node: currentKey, context: JSON.stringify(context) });
+ await recordStep(runId, node, 'waiting', { gate: true });
+ // Optional setup hook (create approval + send admin email) — registered
+ // by the approval phase. Engine still pauses cleanly without it.
+ const setup = registry.getAction('gate_setup');
+ if (setup) {
+ try { await setup(ctx); } catch (e) { logger.error('[workflow] gate setup failed', { runId, error: e.message }); }
+ }
+ return; // paused — an approval (email or inbox) resumes via resumeRun
+ }
+ case 'action':
+ case 'webhook': {
+ const actionKey = node.config?.action || (node.type === 'webhook' ? 'webhook' : 'noop');
+ const action = registry.getAction(actionKey);
+ const result = action ? (await action(ctx)) || {} : { skipped: true, reason: `unknown action ${actionKey}` };
+ if (result.set && typeof result.set === 'object') Object.assign(context.vars, result.set);
+ const e = outEdge(edges, currentKey, null);
+ nextKey = e ? e.to_node : null;
+ await recordStep(runId, node, result.skipped ? 'skipped' : 'done', result);
+ break;
+ }
+ default: {
+ await recordStep(runId, node, 'skipped', { reason: `unknown node type ${node.type}` });
+ const e = outEdge(edges, currentKey, null);
+ nextKey = e ? e.to_node : null;
+ }
+ }
+ } catch (err) {
+ await recordStep(runId, node, 'failed', null, err.message);
+ await failRun(runId, `node ${currentKey} failed: ${err.message}`);
+ return;
+ }
+
+ currentKey = nextKey;
+ await db('workflow_runs').where({ id: runId }).update({ current_node: currentKey || null, context: JSON.stringify(context), updated_at: db.fn.now() });
+ }
+
+ await finishRun(runId);
+}
+
+/** Begin a freshly-created run at its trigger node. */
+async function startRun(runId) {
+ const run = await db('workflow_runs').where({ id: runId }).first();
+ if (!run || ['done', 'failed', 'cancelled'].includes(run.status)) return;
+ const { nodeByKey } = await loadGraph(run.workflow_id, run.version);
+ let entry = null;
+ for (const n of nodeByKey.values()) { if (n.type === 'trigger') { entry = n; break; } }
+ if (!entry) { await failRun(runId, 'no trigger node'); return; }
+ await db('workflow_runs').where({ id: runId }).update({ status: 'running', current_node: entry.node_key, updated_at: db.fn.now() });
+ await advanceRun(runId);
+}
+
+/**
+ * Resume a paused (waiting) run. For a wait node, pass no handle. For a gate,
+ * pass decisionHandle = 'confirm' | 'deny' so the matching edge is taken.
+ */
+async function resumeRun(runId, { decisionHandle = null } = {}) {
+ const run = await db('workflow_runs').where({ id: runId }).first();
+ if (!run || run.status !== 'waiting') return;
+ const { edges } = await loadGraph(run.workflow_id, run.version);
+ // For a gate decision, the edge MUST match the handle exactly — we cannot fall
+ // back to outEdge's "sole edge" heuristic, or a 'deny' with only a 'confirm'
+ // edge would silently take the confirm path. A missing handle edge is a broken
+ // graph → fail loudly (same posture as unknown nodes) so the lost decision is
+ // visible in run history instead of masquerading as a green 'done'.
+ let e;
+ if (decisionHandle != null) {
+ e = edges.find((x) => x.from_node === run.current_node && (x.from_handle || null) === decisionHandle);
+ if (!e) {
+ await failRun(runId, `gate decision '${decisionHandle}' has no matching edge from node '${run.current_node}'`);
+ return;
+ }
+ } else {
+ e = outEdge(edges, run.current_node, null);
+ }
+ const nextKey = e ? e.to_node : null;
+ await db('workflow_runs').where({ id: runId }).update({ status: 'running', current_node: nextKey, wake_at: null, updated_at: db.fn.now() });
+ if (!nextKey) { await finishRun(runId); return; }
+ await advanceRun(runId);
+}
+
+/**
+ * Entry point for lifecycle events. Creates one run per matching enabled
+ * workflow (idempotent via dedup_key) and starts it. Never throws — safe to
+ * call after a caller's commit. Fails CLOSED if the flag system is unavailable.
+ */
+async function emitWorkflowEvent(triggerType, { entityType = null, entityId = null, payload = {}, targetWorkflowId = null } = {}) {
+ try {
+ const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag');
+ let enabled = false;
+ try { enabled = await isFeatureEnabled('workflows'); } catch (e) {
+ logger.warn('[workflow] flag check failed — treating workflows as disabled', { error: e.message });
+ return [];
+ }
+ if (!enabled) return [];
+
+ // targetWorkflowId restricts the fan-out to a SINGLE chosen flow — used when
+ // the entity explicitly selected which flow to run (e.g. a quote picks its
+ // booking workflow). Still gated on enabled + matching trigger_type, so a
+ // disabled/mismatched selection simply runs nothing.
+ const q = db('workflows').where({ enabled: true, trigger_type: triggerType });
+ if (targetWorkflowId != null) q.where({ id: targetWorkflowId });
+ const workflows = await q;
+ const runIds = [];
+ for (const wf of workflows) {
+ const tcfg = parseJson(wf.trigger_config, {});
+ if (tcfg && tcfg.filter && !matchFilter(tcfg.filter, payload)) continue;
+
+ const dedupKey = `${wf.id}:${wf.version}:${triggerType}:${entityType || ''}:${entityId || ''}`;
+ const existing = await db('workflow_runs').where({ dedup_key: dedupKey }).first();
+ if (existing) continue;
+
+ try {
+ await db('workflow_runs').insert({
+ workflow_id: wf.id,
+ version: wf.version,
+ trigger_event: triggerType,
+ entity_type: entityType,
+ entity_id: entityId,
+ status: 'pending',
+ context: JSON.stringify({ vars: { ...payload } }),
+ dedup_key: dedupKey,
+ });
+ } catch (e) {
+ continue; // unique race — another emitter created it
+ }
+ const row = await db('workflow_runs').where({ dedup_key: dedupKey }).first();
+ if (!row) continue;
+ runIds.push(row.id);
+ await startRun(row.id).catch((err) => logger.error('[workflow] start failed', { runId: row.id, error: err.message }));
+ }
+ return runIds;
+ } catch (e) {
+ logger.error('[workflow] emit failed', { triggerType, error: e.message });
+ return [];
+ }
+}
+
+/**
+ * Resume runs whose wait has elapsed. Called from the cron scheduler tick.
+ * Only advances `wait` nodes — gate timeouts are handled by the approvals
+ * layer. Fails CLOSED if the workflows flag is off (master kill-switch).
+ */
+async function runDueWaits(limit = 100) {
+ try {
+ const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag');
+ let enabled = false;
+ try { enabled = await isFeatureEnabled('workflows'); } catch (e) { return 0; }
+ if (!enabled) return 0;
+
+ const nowIso = new Date().toISOString();
+ const due = await db('workflow_runs')
+ .where({ status: 'waiting' })
+ .whereNotNull('wake_at')
+ .where('wake_at', '<=', nowIso)
+ .limit(limit);
+
+ let resumed = 0;
+ for (const run of due) {
+ try {
+ const node = await db('workflow_nodes')
+ .where({ workflow_id: run.workflow_id, version: run.version, node_key: run.current_node })
+ .first();
+ if (node && node.type === 'wait') {
+ await resumeRun(run.id);
+ resumed += 1;
+ }
+ } catch (err) {
+ logger.error('[workflow] runDueWaits item failed', { runId: run.id, error: err.message });
+ }
+ }
+ return resumed;
+ } catch (e) {
+ logger.error('[workflow] runDueWaits failed', { error: e.message });
+ return 0;
+ }
+}
+
+const RECOVERY_STALE_MS = 10 * 60 * 1000; // a 'running' run idle this long = orphaned by a crash
+const MAX_RECOVERY_ATTEMPTS = 5;
+
+/**
+ * Resume runs orphaned by a crash. A run left in 'running'/'pending' has nothing
+ * to resume it (the scheduler only wakes 'waiting'), so this sweep picks up ones
+ * whose heartbeat (updated_at) has gone stale and re-enters them from their
+ * persisted node. Re-entry is at-least-once: the current node may re-execute —
+ * loop counters + the late-fee math are idempotent, so the only residual risk is
+ * a duplicate reminder email. `attempts` caps recovery so a node that reliably
+ * crashes the process is marked failed instead of looping forever. Flag-gated
+ * (fails closed when workflows is off). Called from the scheduler tick + boot.
+ */
+async function recoverStaleRuns({ staleMs = RECOVERY_STALE_MS, limit = 50 } = {}) {
+ try {
+ const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag');
+ let enabled = false;
+ try { enabled = await isFeatureEnabled('workflows'); } catch (e) { return 0; }
+ if (!enabled) return 0;
+ if (!(await db.schema.hasColumn('workflow_runs', 'updated_at'))) return 0;
+
+ const cutoff = new Date(Date.now() - staleMs).toISOString();
+ const stale = await db('workflow_runs')
+ .whereIn('status', ['running', 'pending'])
+ .where('updated_at', '<=', cutoff)
+ .limit(limit);
+
+ let recovered = 0;
+ for (const run of stale) {
+ try {
+ const attempts = Number(run.attempts) || 0;
+ if (attempts >= MAX_RECOVERY_ATTEMPTS) {
+ await failRun(run.id, `abandoned after ${attempts} recovery attempts (suspected crash loop)`);
+ continue;
+ }
+ await db('workflow_runs').where({ id: run.id }).update({ attempts: attempts + 1, updated_at: db.fn.now() });
+ if (!run.current_node) {
+ await startRun(run.id);
+ } else {
+ await db('workflow_runs').where({ id: run.id }).update({ status: 'running', updated_at: db.fn.now() });
+ await advanceRun(run.id);
+ }
+ recovered += 1;
+ } catch (err) {
+ logger.error('[workflow] recovery failed', { runId: run.id, error: err.message });
+ }
+ }
+ return recovered;
+ } catch (e) {
+ logger.error('[workflow] recoverStaleRuns failed', { error: e.message });
+ return 0;
+ }
+}
+
+/**
+ * True when the workflows flag is on AND a built-in flow with this key is
+ * enabled. The hardcoded automations (reminder ladder, expiry emails, pre-event
+ * reminders) call this to STAND DOWN when their engine flow is live — so the
+ * engine and the legacy path never double-fire. Fails CLOSED (returns false) on
+ * any error so the legacy path keeps running if the workflow subsystem is down.
+ */
+async function isBuiltinFlowActive(builtinKey) {
+ try {
+ const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag');
+ if (!(await isFeatureEnabled('workflows'))) return false;
+ if (!(await db.schema.hasTable('workflows'))) return false;
+ const wf = await db('workflows').where({ builtin_key: builtinKey, enabled: true }).first();
+ return !!wf;
+ } catch (e) {
+ return false;
+ }
+}
+
+/**
+ * Emit `event.date_approaching` for events entering an enabled flow's lead
+ * window. This is the trigger source for the pre-event reminder built-in, so it
+ * faithfully honours the same per-event controls the legacy eventReminderService
+ * pass uses (migration 143): skips `event_reminder_disabled` events, skips ones
+ * already sent (`event_reminder_sent_at`), and fires at `event_date − offset`
+ * where offset = the event's `event_reminder_offset_days` override else the
+ * flow's `daysBefore`. emitWorkflowEvent's per-(flow,entity) dedup_key keeps the
+ * hourly sweep to a single run per event. Fails CLOSED when the flag is off.
+ */
+async function emitDueEventReminders(limit = 200) {
+ try {
+ const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag');
+ let enabled = false;
+ try { enabled = await isFeatureEnabled('workflows'); } catch (e) { return 0; }
+ if (!enabled) return 0;
+ if (!(await db.schema.hasTable('events'))) return 0;
+
+ const flows = await db('workflows').where({ enabled: true, trigger_type: 'event.date_approaching' });
+ if (!flows.length) return 0;
+
+ const { hasColumnCached } = require('../../utils/schemaCache');
+ const hasReminderCols = await hasColumnCached('events', 'event_reminder_sent_at');
+
+ // The admin heads-up resolves its recipient from ctx.vars.adminEmail; events
+ // don't carry one, so source it from the business profile (best-effort).
+ let adminEmail = null;
+ try {
+ if (await db.schema.hasTable('business_profile')) {
+ const profile = await db('business_profile').where({ id: 1 }).first();
+ adminEmail = profile?.email || null;
+ }
+ } catch (_) { /* best-effort */ }
+
+ const now = Date.now();
+ const todayIso = new Date(now).toISOString().slice(0, 10);
+ let emitted = 0;
+ for (const wf of flows) {
+ const cfg = parseJson(wf.trigger_config, {});
+ const daysBefore = Number(cfg.daysBefore) > 0 ? Number(cfg.daysBefore) : 3;
+ // Surface every still-upcoming event up to the widest the offset could be;
+ // the per-event triggerAt check below decides if it's actually due.
+ const maxOffset = Math.max(daysBefore, 60);
+ const windowEndIso = new Date(now + maxOffset * 86400000).toISOString().slice(0, 10);
+
+ let q = db('events')
+ .where('is_active', true)
+ .where('is_archived', false)
+ .whereNotNull('event_date')
+ .where('event_date', '>=', todayIso)
+ .where('event_date', '<=', windowEndIso);
+ // Faithful to the legacy pass: never remind a disabled or already-sent event.
+ if (hasReminderCols) {
+ q = q.where('event_reminder_disabled', false).whereNull('event_reminder_sent_at');
+ }
+ const events = await q.limit(limit);
+
+ for (const ev of events) {
+ // A null/blank per-event offset means "use the flow's daysBefore" — guard
+ // against Number(null)===0 silently making the reminder fire on the event day.
+ const rawOffset = hasReminderCols ? ev.event_reminder_offset_days : null;
+ const offset = (rawOffset != null && rawOffset !== '' && Number.isFinite(Number(rawOffset)))
+ ? Number(rawOffset)
+ : daysBefore;
+ const ed = ev.event_date instanceof Date ? ev.event_date : new Date(ev.event_date);
+ const triggerAt = ed.getTime() - offset * 86400000;
+ if (now < triggerAt) continue; // not yet inside this event's lead window
+
+ const runIds = await emitWorkflowEvent('event.date_approaching', {
+ entityType: 'event',
+ entityId: ev.id,
+ payload: {
+ eventId: ev.id,
+ eventName: ev.event_name || null,
+ eventDate: ev.event_date,
+ eventType: ev.event_type || null,
+ hostName: ev.host_name || null,
+ customerEmail: ev.customer_email || ev.host_email || null,
+ adminEmail,
+ daysBefore: offset,
+ },
+ });
+ emitted += runIds.length;
+ }
+ }
+ return emitted;
+ } catch (e) {
+ logger.error('[workflow] emitDueEventReminders failed', { error: e.message });
+ return 0;
+ }
+}
+
+/**
+ * Test-fire a workflow on demand (admin testing). Creates a run for the given
+ * entity/payload and starts it. Defaults to dryRun: side-effecting actions are
+ * mocked, waits pass through, and gates auto-take 'confirm' — so the WHOLE flow
+ * runs in one shot and the step log shows exactly what it would do, without
+ * sending real customer mail or charging fees.
+ */
+async function testRun(workflowId, { entityType = null, entityId = null, payload = {}, dryRun = true } = {}) {
+ const wf = await db('workflows').where({ id: workflowId }).first();
+ if (!wf) throw new Error('Workflow not found');
+ const vars = { ...(payload || {}), __test: true };
+ if (dryRun) vars.__dryRun = true;
+ const dedupKey = `test:${workflowId}:${Date.now()}:${Math.round(Math.random() * 1e9)}`;
+ await db('workflow_runs').insert({
+ workflow_id: wf.id,
+ version: wf.version,
+ trigger_event: `test:${wf.trigger_type}`,
+ entity_type: entityType,
+ entity_id: entityId,
+ status: 'pending',
+ context: JSON.stringify({ vars }),
+ dedup_key: dedupKey,
+ updated_at: db.fn.now(),
+ });
+ const row = await db('workflow_runs').where({ dedup_key: dedupKey }).first();
+ await startRun(row.id);
+ return row.id;
+}
+
+module.exports = {
+ emitWorkflowEvent,
+ isBuiltinFlowActive,
+ runDueWaits,
+ emitDueEventReminders,
+ recoverStaleRuns,
+ testRun,
+ startRun,
+ advanceRun,
+ resumeRun,
+ finishRun,
+ failRun,
+ // exported for tests / introspection
+ loadGraph,
+ outEdge,
+ computeWakeAt,
+};
diff --git a/backend/src/services/workflows/index.js b/backend/src/services/workflows/index.js
new file mode 100644
index 00000000..e41742bf
--- /dev/null
+++ b/backend/src/services/workflows/index.js
@@ -0,0 +1,22 @@
+/**
+ * Workflow engine public surface.
+ *
+ * const { emitWorkflowEvent } = require('./services/workflows');
+ *
+ * `engine` holds the executor (start/advance/resume), `registry` the catalog
+ * of conditions/actions. Action/condition handler modules require `registry`
+ * and call registerAction/registerCondition at load time.
+ */
+const engine = require('./engine');
+const registry = require('./registry');
+// Side-effect import: registers the data-touching action/condition handlers
+// (send_email, invoice_paid, prepare_* document actions) onto the registry.
+require('./actions');
+// Registers the gate_setup action + exposes approval helpers.
+const approvals = require('./approvals');
+
+module.exports = {
+ ...engine,
+ ...approvals,
+ registry,
+};
diff --git a/backend/src/services/workflows/registry.js b/backend/src/services/workflows/registry.js
new file mode 100644
index 00000000..ae21ca87
--- /dev/null
+++ b/backend/src/services/workflows/registry.js
@@ -0,0 +1,61 @@
+/**
+ * Workflow registry — the curated catalog of CONDITIONS and ACTIONS the engine
+ * can run. Node `config.condition` / `config.action` keys map to handlers here.
+ *
+ * Handlers are async `(ctx) => result`, where ctx = { run, node, vars, db,
+ * logger }. `vars` is the run's mutable context bag (loop counters, accumulated
+ * values, the trigger payload). A condition returns a boolean; an action may
+ * return `{ set: {...} }` to merge values back into `vars`.
+ *
+ * Keep handlers curated and typed — this is NOT arbitrary code execution. New
+ * triggers/actions register here; the canvas palette is derived from these.
+ */
+const conditions = new Map();
+const actions = new Map();
+
+function registerCondition(key, fn) { conditions.set(key, fn); }
+function registerAction(key, fn) { actions.set(key, fn); }
+function getCondition(key) { return conditions.get(key); }
+function getAction(key) { return actions.get(key); }
+function listConditions() { return Array.from(conditions.keys()); }
+function listActions() { return Array.from(actions.keys()); }
+
+// --- Primitive conditions ---
+registerCondition('always', async () => true);
+registerCondition('never', async () => false);
+// Generic field/op/value compare against the run's `vars` bag.
+registerCondition('expr', async (ctx) => {
+ const { field, op = 'truthy', value } = ctx.node.config || {};
+ const actual = field != null ? ctx.vars[field] : undefined;
+ switch (op) {
+ case 'eq': return actual == value; // eslint-disable-line eqeqeq
+ case 'neq': return actual != value; // eslint-disable-line eqeqeq
+ case 'gt': return Number(actual) > Number(value);
+ case 'gte': return Number(actual) >= Number(value);
+ case 'lt': return Number(actual) < Number(value);
+ case 'lte': return Number(actual) <= Number(value);
+ case 'falsy': return !actual;
+ case 'truthy':
+ default: return Boolean(actual);
+ }
+});
+
+// --- Primitive actions ---
+registerAction('noop', async () => ({}));
+registerAction('log', async (ctx) => {
+ ctx.logger?.info?.('[workflow] log action', { runId: ctx.run.id, message: ctx.node.config?.message });
+ return { logged: true };
+});
+// Merge a static object into the run context (handy for tests + seeding flags).
+registerAction('set_context', async (ctx) => ({ set: ctx.node.config?.set || {} }));
+
+module.exports = {
+ registerCondition,
+ registerAction,
+ getCondition,
+ getAction,
+ listConditions,
+ listActions,
+ conditions,
+ actions,
+};
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 9aac8719..2287d0c4 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,13 +1,14 @@
{
"name": "picpeak-frontend",
- "version": "3.47.2-beta.0",
+ "version": "3.69.0-beta.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-frontend",
- "version": "3.47.2-beta.0",
+ "version": "3.69.0-beta.0",
"dependencies": {
+ "@dagrejs/dagre": "^3.0.0",
"@fullcalendar/core": "^6.1.20",
"@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/interaction": "^6.1.20",
@@ -25,6 +26,7 @@
"@types/dompurify": "^3.0.5",
"@types/lodash": "^4.17.20",
"@types/react-google-recaptcha": "^2.1.9",
+ "@xyflow/react": "^12.11.1",
"axios": "1.15.2",
"clsx": "^2.0.0",
"date-fns": "4.1.0",
@@ -548,6 +550,21 @@
"node": ">=18"
}
},
+ "node_modules/@dagrejs/dagre": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.0.0.tgz",
+ "integrity": "sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@dagrejs/graphlib": "4.0.1"
+ }
+ },
+ "node_modules/@dagrejs/graphlib": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-4.0.1.tgz",
+ "integrity": "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==",
+ "license": "MIT"
+ },
"node_modules/@epic-web/invariant": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
@@ -3460,6 +3477,55 @@
"assertion-error": "^2.0.1"
}
},
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-drag": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
+ "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-selection": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
+ "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-transition": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
+ "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-zoom": {
+ "version": "3.0.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
+ "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-interpolate": "*",
+ "@types/d3-selection": "*"
+ }
+ },
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -3996,6 +4062,48 @@
"url": "https://opencollective.com/vitest"
}
},
+ "node_modules/@xyflow/react": {
+ "version": "12.11.1",
+ "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.1.tgz",
+ "integrity": "sha512-L+zBoLGSXham0MnlY8QqjfR7/C5JNw0zxkaey5aZ5XmCgJBAdH4+WRIu8CR40d3l/BdU635V6YbhBK1jMo8/6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@xyflow/system": "0.0.78",
+ "classcat": "^5.0.3",
+ "zustand": "^4.4.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=17",
+ "@types/react-dom": ">=17",
+ "react": ">=17",
+ "react-dom": ">=17"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@xyflow/system": {
+ "version": "0.0.78",
+ "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.78.tgz",
+ "integrity": "sha512-lY0z2qP33fUhTva9Vaxrk0lqZta2pkbxB1trHAx1omnJqRtPvDlAQYV2r5fhS6AdpkulYmbNW0svy+A4/t4B/g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-drag": "^3.0.7",
+ "@types/d3-interpolate": "^3.0.4",
+ "@types/d3-selection": "^3.0.10",
+ "@types/d3-transition": "^3.0.8",
+ "@types/d3-zoom": "^3.0.8",
+ "d3-drag": "^3.0.0",
+ "d3-interpolate": "^3.0.1",
+ "d3-selection": "^3.0.0",
+ "d3-zoom": "^3.0.0"
+ }
+ },
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -4435,6 +4543,12 @@
"node": ">= 6"
}
},
+ "node_modules/classcat": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
+ "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
+ "license": "MIT"
+ },
"node_modules/cli-cursor": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
@@ -4634,6 +4748,111 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dispatch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+ "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-drag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
+ "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-selection": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-selection": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
+ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-transition": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
+ "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-dispatch": "1 - 3",
+ "d3-ease": "1 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "d3-selection": "2 - 3"
+ }
+ },
+ "node_modules/d3-zoom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
+ "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "2 - 3",
+ "d3-transition": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/data-urls": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
@@ -9297,6 +9516,34 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
+ },
+ "node_modules/zustand": {
+ "version": "4.5.7",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
+ "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
+ "license": "MIT",
+ "dependencies": {
+ "use-sync-external-store": "^1.2.2"
+ },
+ "engines": {
+ "node": ">=12.7.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=16.8",
+ "immer": ">=9.0.6",
+ "react": ">=16.8"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/frontend/package.json b/frontend/package.json
index e7ef4266..6582e5eb 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -15,6 +15,7 @@
"i18n:ci": "i18next-cli extract --ci --dry-run"
},
"dependencies": {
+ "@dagrejs/dagre": "^3.0.0",
"@fullcalendar/core": "^6.1.20",
"@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/interaction": "^6.1.20",
@@ -32,6 +33,7 @@
"@types/dompurify": "^3.0.5",
"@types/lodash": "^4.17.20",
"@types/react-google-recaptcha": "^2.1.9",
+ "@xyflow/react": "^12.11.1",
"axios": "1.15.2",
"clsx": "^2.0.0",
"date-fns": "4.1.0",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 55ae58fa..6aa17d84 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -46,6 +46,9 @@ import { QuoteResponsePage } from './pages/public/QuoteResponsePage';
import { ContractResponsePage } from './pages/public/ContractResponsePage';
import { ProjectsListPage } from './pages/admin/projects/ProjectsListPage';
import { ProjectCockpitPage } from './pages/admin/projects/ProjectCockpitPage';
+import { WorkflowsListPage } from './pages/admin/workflows/WorkflowsListPage';
+import { WorkflowApprovalsPage } from './pages/admin/workflows/WorkflowApprovalsPage';
+import { WorkflowEditorPage } from './pages/admin/workflows/WorkflowEditorPage';
import { ContractsListPage } from './pages/admin/contracts/ContractsListPage';
import { ContractEditorPage } from './pages/admin/contracts/ContractEditorPage';
import { ContractDetailPage } from './pages/admin/contracts/ContractDetailPage';
@@ -347,6 +350,14 @@ function App() {
} />
} />
+ {/* Workflows (automation engine) — top-level area gated
+ by the `workflows` flag. */}
+ }>
+ } />
+ } />
+ } />
+
+
} />
} />
} />
diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx
index 2dbd26d0..6a18cd09 100644
--- a/frontend/src/components/admin/AdminSidebar.tsx
+++ b/frontend/src/components/admin/AdminSidebar.tsx
@@ -11,6 +11,7 @@ import {
Users,
Briefcase,
Landmark,
+ Workflow,
PanelLeftClose,
PanelLeftOpen,
} from 'lucide-react';
@@ -110,6 +111,12 @@ const navigation: NavItem[] = [
permission: 'accounting.view',
featureFlag: 'accounting',
},
+ // Workflows (automation engine) — top-level, gated by the `workflows` flag.
+ {
+ nameKey: 'navigation.workflows', href: '/admin/workflows', icon: Workflow,
+ permission: 'workflows.view',
+ featureFlag: 'workflows',
+ },
];
export const AdminSidebar: React.FC = ({ isOpen, onClose, collapsed = false, onToggleCollapse }) => {
diff --git a/frontend/src/contexts/FeatureFlagsContext.tsx b/frontend/src/contexts/FeatureFlagsContext.tsx
index c97d4c1b..218d81d2 100644
--- a/frontend/src/contexts/FeatureFlagsContext.tsx
+++ b/frontend/src/contexts/FeatureFlagsContext.tsx
@@ -64,6 +64,9 @@ export const DEFAULT_FLAGS: FeatureFlags = {
whatsapp: false,
// Live Slideshow ("Diashow") — opt-in; gates all slideshow admin UI.
slideshow: false,
+ // Workflow / automation engine — opt-in; gates the Workflows admin area
+ // and the engine runtime (triggers/actions/gates).
+ workflows: false,
};
export const FEATURE_FLAGS_QUERY_KEY = ['feature-flags'] as const;
diff --git a/frontend/src/features/settings/tabs/FeaturesTab.tsx b/frontend/src/features/settings/tabs/FeaturesTab.tsx
index 5816bb3f..9434b923 100644
--- a/frontend/src/features/settings/tabs/FeaturesTab.tsx
+++ b/frontend/src/features/settings/tabs/FeaturesTab.tsx
@@ -23,6 +23,7 @@ import {
Wallet,
FolderKanban,
MonitorPlay,
+ Workflow,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Card } from '../../../components/common';
@@ -134,6 +135,24 @@ export const FeaturesTab: React.FC = () => {
/>
+ {/* Automation — the visual workflow engine. Master kill-switch for the
+ Workflows admin area and the runtime; off by default. */}
+
+ setFlag('workflows', next)}
+ />
+
+
{/* Clients (#354 follow-up). Visual grouping for the CRM-area
sub-features. The "Clients" sidebar section itself is gated
by a derived `clients` flag (computed from whether any
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index f1a84d4e..9f5de7c5 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -199,8 +199,96 @@
"calendar": "Kalender",
"clients": "CRM",
"accounting": "Buchhaltung",
+ "workflows": "Workflows",
"betaTag": "Beta"
},
+ "workflows": {
+ "title": "Workflows",
+ "subtitle": "Visuelle Automatisierungen – Auslöser, Bedingungen, Freigaben und Aktionen.",
+ "new": "Neuer Workflow",
+ "empty": "Noch keine Workflows. Erstelle einen, um Rechnungsstellung und Buchungsschritte zu automatisieren.",
+ "builtin": "integriert",
+ "triggerLabel": "Auslöser",
+ "enabled": "Aktiv",
+ "disabled": "Inaktiv",
+ "confirmDelete": "Diesen Workflow löschen?",
+ "toggle": {
+ "confirmDisableBuiltin": "Das Deaktivieren dieses eingebauten Ablaufs stellt das vorherige Standardverhalten wieder her – die Automatisierung wird dadurch nicht abgeschaltet. Fortfahren?"
+ },
+ "test": {
+ "title": "Testlauf",
+ "hint": "Probelauf: durchläuft den ganzen Ablauf sofort (Wartezeiten übersprungen, Gates automatisch bestätigt), Nebeneffekte werden nur simuliert – keine echten E-Mails. Optional eine Entitäts-ID (z. B. eine Rechnung) angeben, damit Bedingungen sie lesen können.",
+ "entityId": "Entitäts-ID (optional, z. B. Rechnungs-ID)",
+ "run": "Probelauf starten",
+ "result": "Ergebnis",
+ "failed": "Testlauf fehlgeschlagen"
+ },
+ "toast": {
+ "createFailed": "Workflow konnte nicht erstellt werden",
+ "deleted": "Workflow gelöscht",
+ "deleteFailed": "Workflow konnte nicht gelöscht werden"
+ },
+ "approvals": {
+ "title": "Freigaben",
+ "subtitle": "Workflow-Durchläufe, die auf deine Bestätigung warten.",
+ "empty": "Aktuell wartet nichts auf dich.",
+ "confirm": "Bestätigen",
+ "deny": "Ablehnen",
+ "recorded": "Antwort gespeichert",
+ "defaultPrompt": "Ein Workflow benötigt deine Bestätigung.",
+ "pendingTitle": "Offene Freigaben",
+ "viewAll": "Alle Freigaben ansehen",
+ "acted": "Erledigt"
+ },
+ "editor": {
+ "namePlaceholder": "Workflow-Name",
+ "when": "Wenn",
+ "cleanUp": "Layout aufräumen",
+ "textView": "Text",
+ "canvasView": "Canvas",
+ "loadText": "In Editor laden",
+ "textLoaded": "Geladen – prüfen und speichern",
+ "textNeedsArrays": "Benötigt „nodes“- und „edges“-Arrays",
+ "textNeedsTrigger": "Benötigt genau einen Trigger-Knoten",
+ "textHint": "Der gesamte Ablauf als JSON – zum Teilen kopieren oder an ein LLM geben, oder einen Ablauf einfügen und in den Editor laden. Nach dem Import „Layout aufräumen“ klicken.",
+ "saved": "Workflow gespeichert",
+ "saveFailed": "Speichern fehlgeschlagen",
+ "badJson": "Konfiguration ist kein gültiges JSON",
+ "daysBefore": "Tage vor dem Anlass",
+ "templateGroup": "Vorlagengruppe für Erinnerungen",
+ "templateGroupHint": "Die genaue Vorlage wird je Anlasstyp innerhalb dieser Gruppe automatisch gewählt: «Gruppe»_«Anlasstyp», falls vorhanden, sonst «Gruppe»_default. Leer = event_reminder.",
+ "showAdvanced": "Erweitert (JSON)",
+ "hideAdvanced": "Erweitert ausblenden (JSON)",
+ "triggerHint": "Der Auslöser wird oben in der Leiste gesetzt (Wenn …).",
+ "actionLabel": "Aktion",
+ "recipient": "Empfänger",
+ "recipientCustomer": "Kunde (berücksichtigt Geschäftszeiten)",
+ "recipientAdmin": "Admin (sofort gesendet)",
+ "emailTemplate": "E-Mail-Vorlagenschlüssel",
+ "webhookUrl": "Webhook-URL",
+ "webhookTarget": "Webhook",
+ "webhookNone": "— Konfigurierten Webhook wählen —",
+ "webhookInactive": "(inaktiv)",
+ "webhookHint": "Wird über die Webhook-Pipeline zugestellt (Signatur, Wiederholungen, SSRF-Prüfungen). Endpunkte unter Einstellungen → Webhooks verwalten.",
+ "condition": "Bedingung",
+ "exprField": "Feld",
+ "exprOp": "Operator",
+ "exprValue": "Wert",
+ "conditionHint": "Führt bei „wahr“ zur „yes“-Kante, sonst zur „no“-Kante.",
+ "waitType": "Warten",
+ "waitUntil": "Bis zu einem Datum",
+ "waitDelay": "Feste Verzögerung",
+ "waitAnchor": "Warten bis",
+ "days": "Tage",
+ "hours": "Stunden",
+ "minutes": "Min",
+ "maxIterations": "Höchstens wiederholen (mal)",
+ "gatePrompt": "Frage an den Admin",
+ "gatePromptPh": "z. B. Keine Zahlung erhalten – Mahnung senden?",
+ "gateTimeout": "Automatisch ablaufen nach (Tagen, optional)",
+ "gateHint": "Sendet dem Admin einen Bestätigen/Ablehnen-Link; führt zur „confirm“- oder „deny“-Kante."
+ }
+ },
"eventTypes": {
"title": "Veranstaltungsarten",
"subtitle": "Veranstaltungsarten und deren Standard-Themes anpassen",
@@ -1683,7 +1771,8 @@
"accounting": "Buchhaltung",
"insights": "Auswertungen & Zugriff",
"customers": "Kunden",
- "clients": "CRM"
+ "clients": "CRM",
+ "automation": "Automatisierung"
},
"status": {
"stable": "stabil",
@@ -1799,6 +1888,11 @@
"slideshow": {
"title": "Live-Diashow",
"description": "Ein separater Vollbild-„Diashow“-Link pro Event für Beamer bei Live-Events – übernimmt neue Uploads automatisch, mit Voreinstellungen je Event-Typ und globalen Wasserzeichen-Vorgaben unter Einstellungen → Diashow."
+ },
+ "workflows": {
+ "title": "Workflows",
+ "description": "Visuelle Automatisierungen auf einer Canvas erstellen – Auslöser, Bedingungen, Verzweigungen, Schleifen und Freigabe-Gates für Admins. Deine Mahnstufen und Buchungsschritte werden zu bearbeitbaren Abläufen. Strikt optional.",
+ "sidebar": "Workflows"
}
},
"customerSurface": {
@@ -4845,6 +4939,25 @@
"crm_invoices_late_fee_minor": {
"label": "Mahngebühr (Rappen / Cent)"
},
+ "crm_invoices_late_fee_type": {
+ "label": "Art der Mahngebühr"
+ },
+ "crm_invoices_late_fee_percent": {
+ "label": "Mahngebühr (% der Rechnung)"
+ },
+ "lateFeeType": {
+ "flat": "Fester Betrag (Rappen)",
+ "percent": "Prozentsatz der Rechnung"
+ },
+ "lateFeeAgb": {
+ "title": "Mahngebühren müssen in den AGB stehen",
+ "body": "Vertragliche Pflicht: Sätze wie „Es werden Mahnspesen erhoben“ reichen nicht aus. In den AGB muss die konkrete Gebühr klar beziffert sein (z.B. „CHF 20 ab der 2. Mahnung“). Mit dem Treuhänder prüfen."
+ },
+ "dunningMoved": {
+ "title": "Der Mahnrhythmus liegt jetzt in den Workflows",
+ "body": "Wann und wie oft Zahlungserinnerungen für überfällige Rechnungen verschickt werden, wird im Workflow „Rechnungsmahnung“ festgelegt. Die Mahngebühren unten gelten weiterhin.",
+ "link": "Workflows öffnen"
+ },
"crm_invoices_late_fee_label": {
"label": "Bezeichnung Mahngebühr"
},
@@ -4879,7 +4992,7 @@
"label": "Automatische Mahnungen aktivieren"
},
"crm_invoices_late_fee_enabled": {
- "label": "Mahngebühr aktivieren"
+ "label": "Mahngebühr ab der 2. Mahnung (jede weitere Mahnung)"
},
"crm_quotes_tos_required": {
"label": "Kunden müssen „Ich akzeptiere die AGB“ ankreuzen, bevor sie annehmen können"
@@ -4948,6 +5061,11 @@
},
"reminderTemplates": {
"title": "Erinnerungs-E-Mails vor dem Anlass",
+ "scheduleMoved": {
+ "title": "Der Versandzeitpunkt liegt jetzt in den Workflows",
+ "body": "Ob Erinnerungen vor dem Anlass verschickt werden und wie viele Tage vorher, wird im Workflow „Erinnerung vor dem Anlass“ festgelegt. Auf dieser Seite werden die E-Mail-Vorlagen bearbeitet; anlassspezifische Überschreibungen bleiben auf der jeweiligen Anlass-Detailseite.",
+ "link": "Workflows öffnen"
+ },
"globalSection": "Globales Verhalten",
"globalHelp": "Standardmässig aus — aktiviere, um Erinnerungen zu senden. Der unten gesetzte Offset ist der Standard; jeder Anlass kann ihn auf der Detailseite überschreiben.",
"enableLabel": "Erinnerungs-E-Mails vor dem Anlass senden",
@@ -5144,6 +5262,13 @@
"eventHelp": "Wird auf den Vertrag übernommen und an jeden daraus erzeugten Anlass / jede Rechnung weitergegeben. Setzen Sie dies, damit Kundenportal und Mahn-E-Mails die richtige Bezeichnung \"Hochzeit Doe / Müller\" anzeigen.",
"eventName": "Anlassname",
"eventNamePlaceholder": "z. B. Hochzeit Doe / Müller",
+ "eventType": "Anlasstyp",
+ "eventTypeNone": "— Standard verwenden —",
+ "eventTypeHint": "Wird für den Anlass verwendet, der bei Annahme dieses Angebots erstellt wird.",
+ "bookingWorkflow": "Buchungs-Workflow (bei Annahme)",
+ "bookingWorkflowNone": "— Keiner —",
+ "bookingWorkflowDisabled": "(deaktiviert)",
+ "bookingWorkflowHint": "Der Ablauf, der startet, wenn die Kundin/der Kunde annimmt. „Keiner“ = kein Buchungsablauf. Der Ablauf muss aktiviert sein, um zu starten.",
"eventSection": "Anlass (optional)",
"eventTimeEnd": "Ende",
"eventTimeStart": "Beginn",
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index a1d09a29..8c7c51f9 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -199,8 +199,96 @@
"calendar": "Calendar",
"clients": "CRM",
"accounting": "Accounting",
+ "workflows": "Workflows",
"betaTag": "Beta"
},
+ "workflows": {
+ "title": "Workflows",
+ "subtitle": "Visual automations — triggers, conditions, gates and actions.",
+ "new": "New workflow",
+ "empty": "No workflows yet. Create one to automate your invoicing and booking steps.",
+ "builtin": "built-in",
+ "triggerLabel": "Trigger",
+ "enabled": "Enabled",
+ "disabled": "Disabled",
+ "confirmDelete": "Delete this workflow?",
+ "toggle": {
+ "confirmDisableBuiltin": "Disabling this built-in reverts to the previous built-in behaviour — it does not turn the automation off. Continue?"
+ },
+ "test": {
+ "title": "Test run",
+ "hint": "Dry run: walks the whole flow now (waits skipped, gates auto-confirmed) with side effects mocked — no real emails. Optionally give an entity id (e.g. an invoice) so conditions can read it.",
+ "entityId": "Entity id (optional, e.g. invoice id)",
+ "run": "Run dry test",
+ "result": "Result",
+ "failed": "Test run failed"
+ },
+ "toast": {
+ "createFailed": "Could not create workflow",
+ "deleted": "Workflow deleted",
+ "deleteFailed": "Could not delete workflow"
+ },
+ "approvals": {
+ "title": "Approvals",
+ "subtitle": "Workflow runs waiting on your confirmation.",
+ "empty": "Nothing waiting for you right now.",
+ "confirm": "Confirm",
+ "deny": "Deny",
+ "recorded": "Response recorded",
+ "defaultPrompt": "A workflow needs your confirmation.",
+ "pendingTitle": "Pending approvals",
+ "viewAll": "View all approvals",
+ "acted": "Done"
+ },
+ "editor": {
+ "namePlaceholder": "Workflow name",
+ "when": "When",
+ "cleanUp": "Clean up layout",
+ "textView": "Text",
+ "canvasView": "Canvas",
+ "loadText": "Load into editor",
+ "textLoaded": "Loaded — review and Save",
+ "textNeedsArrays": "Needs \"nodes\" and \"edges\" arrays",
+ "textNeedsTrigger": "Needs exactly one trigger node",
+ "textHint": "The whole flow as JSON — copy it to share or hand to an LLM, or paste a flow and load it into the editor. Click “Clean up layout” after importing.",
+ "saved": "Workflow saved",
+ "saveFailed": "Could not save",
+ "badJson": "Config is not valid JSON",
+ "daysBefore": "days before event",
+ "templateGroup": "Reminder template group",
+ "templateGroupHint": "The exact template is auto-picked per event type within this group: «group»_«eventType» if you authored one, else «group»_default. Blank = event_reminder.",
+ "showAdvanced": "Advanced (JSON)",
+ "hideAdvanced": "Hide advanced (JSON)",
+ "triggerHint": "The trigger is set in the toolbar above (When …).",
+ "actionLabel": "Action",
+ "recipient": "Recipient",
+ "recipientCustomer": "Customer (respects business hours)",
+ "recipientAdmin": "Admin (sent immediately)",
+ "emailTemplate": "Email template key",
+ "webhookUrl": "Webhook URL",
+ "webhookTarget": "Webhook",
+ "webhookNone": "— Select a configured webhook —",
+ "webhookInactive": "(inactive)",
+ "webhookHint": "Delivered via the webhook pipeline (signing, retries, SSRF checks). Manage endpoints in Settings → Webhooks.",
+ "condition": "Condition",
+ "exprField": "Field",
+ "exprOp": "Operator",
+ "exprValue": "Value",
+ "conditionHint": "Routes to the “yes” edge when true, “no” when false.",
+ "waitType": "Wait",
+ "waitUntil": "Until a date",
+ "waitDelay": "A fixed delay",
+ "waitAnchor": "Wait until",
+ "days": "Days",
+ "hours": "Hours",
+ "minutes": "Min",
+ "maxIterations": "Repeat at most (times)",
+ "gatePrompt": "Question for the admin",
+ "gatePromptPh": "e.g. No payment received — send a reminder?",
+ "gateTimeout": "Auto-expire after (days, optional)",
+ "gateHint": "Emails the admin a confirm/deny link; routes to the “confirm” or “deny” edge."
+ }
+ },
"archives": {
"title": "Archives",
"subtitle": "Manage archived photo galleries",
@@ -1239,7 +1327,8 @@
"accounting": "Accounting",
"insights": "Insights & Access",
"customers": "Customers",
- "clients": "CRM"
+ "clients": "CRM",
+ "automation": "Automation"
},
"status": {
"stable": "stable",
@@ -1355,6 +1444,11 @@
"slideshow": {
"title": "Live Slideshow",
"description": "A separate fullscreen \"Diashow\" link per event for projectors at live events — auto-picks-up new uploads, with per-event-type presets and global watermark defaults under Settings → Slideshow."
+ },
+ "workflows": {
+ "title": "Workflows",
+ "description": "Build visual automations on a canvas — triggers, conditions, branches, loops and admin approval gates. Your reminder ladder and booking steps become editable flows. Strictly opt-in.",
+ "sidebar": "Workflows"
}
},
"customerSurface": {
@@ -4843,6 +4937,25 @@
"crm_invoices_late_fee_minor": {
"label": "Late fee (minor units / Rappen)"
},
+ "crm_invoices_late_fee_type": {
+ "label": "Late fee type"
+ },
+ "crm_invoices_late_fee_percent": {
+ "label": "Late fee (% of invoice)"
+ },
+ "lateFeeType": {
+ "flat": "Flat amount (Rappen)",
+ "percent": "Percentage of invoice"
+ },
+ "lateFeeAgb": {
+ "title": "Late fees must be itemised in your terms (AGB)",
+ "body": "A contractual duty: phrases like “late fees apply” aren't enough. Your terms must state the concrete fee (e.g. “CHF 20 from the 2nd reminder”). Verify with your Treuhänder."
+ },
+ "dunningMoved": {
+ "title": "Reminder schedule is now in Workflows",
+ "body": "When and how often overdue reminders go out is configured in the “Invoice dunning” workflow. The late-fee amounts below still apply.",
+ "link": "Open Workflows"
+ },
"crm_invoices_late_fee_label": {
"label": "Late fee label"
},
@@ -4877,7 +4990,7 @@
"label": "Send automatic reminders for overdue invoices"
},
"crm_invoices_late_fee_enabled": {
- "label": "Add a late fee on the second reminder"
+ "label": "Add a late fee on every reminder after the first"
},
"crm_quotes_tos_required": {
"label": "Require customers to tick \"I accept the Terms of Service\" before accepting"
@@ -4946,6 +5059,11 @@
},
"reminderTemplates": {
"title": "Pre-event reminder emails",
+ "scheduleMoved": {
+ "title": "The reminder schedule is now in Workflows",
+ "body": "Whether pre-event reminders are sent, and how many days before the event, is configured in the “Pre-event reminder” workflow. This page edits the email templates; per-event overrides stay on each event’s detail page.",
+ "link": "Open Workflows"
+ },
"globalSection": "Global behaviour",
"globalHelp": "Off by default — turn on to start sending pre-event reminders. The offset below is the default; each event can override on its detail page.",
"enableLabel": "Send pre-event reminder emails",
@@ -5142,6 +5260,13 @@
"eventHelp": "Snapshotted onto the contract and propagated to any event / invoice generated from it. Set this so the customer portal and dunning emails show the right \"Wedding Doe / Müller\" label.",
"eventName": "Event name",
"eventNamePlaceholder": "e.g. Wedding Doe / Müller",
+ "eventType": "Event type",
+ "eventTypeNone": "— Use default —",
+ "eventTypeHint": "Used for the event created when this quote is accepted.",
+ "bookingWorkflow": "Booking workflow (on acceptance)",
+ "bookingWorkflowNone": "— None —",
+ "bookingWorkflowDisabled": "(disabled)",
+ "bookingWorkflowHint": "The flow that runs when the customer accepts. Leave as None to run no booking flow. The flow must be enabled to fire.",
"eventSection": "Event (optional)",
"eventTimeEnd": "End",
"eventTimeStart": "Start",
diff --git a/frontend/src/pages/admin/AdminDashboard.tsx b/frontend/src/pages/admin/AdminDashboard.tsx
index 4fd0ee0f..142dc4f3 100644
--- a/frontend/src/pages/admin/AdminDashboard.tsx
+++ b/frontend/src/pages/admin/AdminDashboard.tsx
@@ -10,18 +10,24 @@ import {
HardDrive,
Image,
Archive,
- Heart
+ Heart,
+ Inbox,
+ Check,
+ X
} from 'lucide-react';
import { differenceInDays, parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
+import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button, Card, Loading } from '../../components/common';
import { UpdateNotification } from '../../components/admin/UpdateNotification';
import { CrmOverviewSection } from '../../components/admin/CrmOverviewSection';
-import { useQuery } from '@tanstack/react-query';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { adminService, ActivityType } from '../../services/admin.service';
+import { workflowsService } from '../../services/workflows.service';
+import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
interface StatCard {
title: string;
@@ -72,6 +78,24 @@ export const AdminDashboard: React.FC = () => {
queryFn: () => eventsService.getEvents(1, 5, 'expiring'),
});
+ // Pending workflow approvals — only when the workflow engine is live. These
+ // are the human-in-the-loop gates (e.g. "review invoice before sending").
+ const { flags } = useFeatureFlags();
+ const qc = useQueryClient();
+ const { data: pendingApprovals } = useQuery({
+ queryKey: ['workflow-approvals'],
+ queryFn: () => workflowsService.approvals(),
+ enabled: !!flags.workflows,
+ });
+ const approvalMutation = useMutation({
+ mutationFn: ({ id, action }: { id: number; action: 'confirm' | 'deny' }) => workflowsService.actApproval(id, action),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['workflow-approvals'] });
+ toast.success(t('workflows.approvals.acted', 'Done') as string);
+ },
+ onError: (err: any) => toast.error(err?.response?.data?.error || (t('common.error', 'Something went wrong') as string)),
+ });
+
const isLoading = statsLoading || eventsLoading;
if (isLoading) {
@@ -241,6 +265,52 @@ export const AdminDashboard: React.FC = () => {
)}
+
+ {/* Pending workflow approvals — the human-in-the-loop gates. Only
+ rendered when the workflow engine is live and something is waiting. */}
+ {!!flags.workflows && pendingApprovals && pendingApprovals.length > 0 && (
+
+
+ {t('quotes.field.eventTypeHint', 'Used for the event created when this quote is accepted.')}
+
+
+ {workflowsLive && (
+
+
+
+
+ {t('quotes.field.bookingWorkflowHint', 'The flow that runs when the customer accepts. Leave as None to run no booking flow. The flow must be enabled to fire.')}
+
+
+ )}
setForm((f) => ({ ...f, eventDate: iso }))} />
{
// invoice-pipeline + revenue tiles, all of which are CRM-money).
const showQuotes = !!flags.quotes;
const showInvoices = !!flags.bills;
+ // When the workflow engine is live, reminder TIMING is owned by the Invoice
+ // dunning flow — show a pointer instead of the legacy schedule controls. When
+ // it's off, the legacy reminder ladder still runs, so keep its controls.
+ const workflowsLive = !!flags.workflows;
const showContracts = !!flags.contracts;
const showDashboardOverview = !!(flags.quotes || flags.bills);
const anySection = showQuotes || showInvoices || showContracts || showDashboardOverview;
@@ -241,21 +249,69 @@ export const CrmSettingsPage: React.FC = () => {
{t('crmSettings.section.invoices', 'Invoices')}
{checkbox('crm_invoices_qr_enabled', 'Render payment QR on invoice PDFs')}
- {checkbox('crm_invoices_reminders_enabled', 'Send automatic reminders for overdue invoices')}
- {checkbox('crm_invoices_late_fee_enabled', 'Add a late fee on the second reminder')}
+
+ {/* Reminder TIMING: owned by the Invoice dunning workflow when the
+ engine is live (callout); otherwise the legacy schedule controls. The
+ late-fee math below is configured here in both cases — it's the fee
+ the dunning path applies, not part of the schedule. */}
+ {workflowsLive ? (
+
+
+
+
{t('crmSettings.dunningMoved.title', 'Reminder schedule is now in Workflows')}
+
+ {t('crmSettings.dunningMoved.body', 'When and how often overdue reminders go out is configured in the “Invoice dunning” workflow. Late-fee amounts below still apply.')}{' '}
+ {t('crmSettings.dunningMoved.link', 'Open Workflows')}
+
+
+
+ ) : (
+ checkbox('crm_invoices_reminders_enabled', 'Send automatic reminders for overdue invoices')
+ )}
+
+ {checkbox('crm_invoices_late_fee_enabled', 'Add a late fee (Mahngebühr) on every reminder after the first')}
+
+
{t('crmSettings.lateFeeAgb.title', 'Late fees must be itemised in your terms (AGB)')}
+
{t('crmSettings.lateFeeAgb.body', 'Vertragliche Pflicht: Sätze wie „Es werden Mahnspesen erhoben“ reichen nicht aus. In den AGB muss die konkrete Gebühr klar beziffert sein (z.B. „CHF 20 ab der 2. Mahnung“). Mit dem Treuhänder prüfen.')}
+
+ {checkbox('crm_invoices_late_fee_vat_enabled', 'Charge VAT on late fees (Switzerland — leave off for DE/AT; no effect if your organisation has no VAT rate)')}
- {t('reminderTemplates.globalHelp',
- 'Off by default — turn on to start sending pre-event reminders. The offset below is the default; each event can override on its detail page.')}
-
{t('reminderTemplates.scheduleMoved.title', 'The reminder schedule is now in Workflows')}
+
+ {t('reminderTemplates.scheduleMoved.body', 'Whether pre-event reminders are sent, and how many days before the event, is configured in the “Pre-event reminder” workflow. This page edits the email templates; per-event overrides stay on each event’s detail page.')}{' '}
+ {t('reminderTemplates.scheduleMoved.link', 'Open Workflows')}
+
+ {t('reminderTemplates.globalHelp',
+ 'Off by default — turn on to start sending pre-event reminders. The offset below is the default; each event can override on its detail page.')}
+
+ {t('workflows.editor.templateGroupHint', 'The exact template is auto-picked per event type within this group: «group»_«eventType» if you authored one, else «group»_default. Blank = event_reminder.')}
+
+ {t('workflows.editor.textHint', 'The whole flow as JSON — copy it to share or hand to an LLM, or paste a flow and load it into the editor. Click “Clean up layout” after importing.')}
+
+ {t('workflows.test.hint', 'Dry run: walks the whole flow now (waits skipped, gates auto-confirmed) with side effects mocked — no real emails. Optionally give an entity id (e.g. an invoice) so conditions can read it.')}
+