diff --git a/backend/__tests__/integration/workflowEngine.test.js b/backend/__tests__/integration/workflowEngine.test.js index c57f4939..a038e62e 100644 --- a/backend/__tests__/integration/workflowEngine.test.js +++ b/backend/__tests__/integration/workflowEngine.test.js @@ -233,4 +233,23 @@ describe('workflow engine', () => { const again = await engine.actByToken(rawToken, 'confirm'); expect(again.already).toBe(true); }); + + test('seeds the invoice-dunning built-in flow (disabled, idempotent)', async () => { + const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot'); + const noopLogger = { info() {}, warn() {} }; + await seedBuiltinWorkflowsAtBoot(db, noopLogger); + + const wf = await db('workflows').where({ builtin_key: DUNNING_KEY }).first(); + expect(wf).toBeTruthy(); + expect(!!wf.is_builtin).toBe(true); + expect(!!wf.enabled).toBe(false); + + const nodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: 1 }); + expect(nodes.filter((n) => n.type === 'trigger')).toHaveLength(1); + expect(nodes.length).toBeGreaterThanOrEqual(10); + + await seedBuiltinWorkflowsAtBoot(db, noopLogger); // idempotent + const all = await db('workflows').where({ builtin_key: DUNNING_KEY }); + expect(all.length).toBe(1); + }); }); diff --git a/backend/server.js b/backend/server.js index 4fd62cb9..6b71ea3f 100644 --- a/backend/server.js +++ b/backend/server.js @@ -897,6 +897,15 @@ async function startServer() { logger.warn('restore-settings self-heal failed at boot:', err.message); } + // Seed built-in workflows (the editable invoice-dunning flow). Disabled by + // default — live reminder behaviour is unchanged. See _workflowSeedBoot.js. + try { + const { seedBuiltinWorkflowsAtBoot } = require('./src/services/_workflowSeedBoot'); + await seedBuiltinWorkflowsAtBoot(db, logger); + } catch (err) { + logger.warn('built-in workflow seed failed at boot:', err.message); + } + // Install-from-backup trigger. If `RESTORE_ON_INSTALL` (or // `.txt`) exists in the /backup mount AND the DB is empty, run // the restore HERE before any admin UI surfaces. Lets admins diff --git a/backend/src/services/_workflowSeedBoot.js b/backend/src/services/_workflowSeedBoot.js new file mode 100644 index 00000000..01a74175 --- /dev/null +++ b/backend/src/services/_workflowSeedBoot.js @@ -0,0 +1,102 @@ +/** + * Boot-time seed for built-in workflows. + * + * Seeds the invoice-dunning ladder as an EDITABLE built-in flow (the corrected + * gate-in-loop graph), so the canvas has real content and admins can see their + * reminder process as blocks. Seeded from the current reminder settings. + * + * IMPORTANT — seeded DISABLED, and live behaviour is UNCHANGED: the existing + * hardcoded reminder ladder in invoiceService.runScheduledTasks still runs. The + * cutover (drive reminders through the engine + stop the hardcoded ladder) is a + * deliberate follow-up so we never double-send. Enabling this flow before that + * cutover would duplicate reminders — hence default off. + * + * Idempotent: keyed on builtin_key='invoice_dunning'. Once seeded, admin edits + * are preserved (we never overwrite an existing built-in). Self-heal pattern + * per [[feedback_self_heal_pattern]]. + */ +const { getAppSetting } = require('../utils/appSettings'); + +const DUNNING_KEY = 'invoice_dunning'; + +function buildDunningGraph({ firstDays, gapDays, maxReminders }) { + 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: 'checkPaid', type: 'condition', config: { condition: 'invoice_paid' }, pos_x: 240, pos_y: 330 }, + { node_key: 'gate', type: 'gate', config: { type: 'payment_confirm', prompt: 'No payment received yet — send a reminder?' }, pos_x: 240, pos_y: 440 }, + { node_key: 'loop', type: 'loop', config: { maxIterations: maxReminders }, pos_x: 240, pos_y: 550 }, + { node_key: 'remind', type: 'action', config: { action: 'send_email', recipientClass: 'customer', emailType: 'invoice_reminder' }, pos_x: 240, pos_y: 660 }, + { node_key: 'waitGap', type: 'wait', config: { delayDays: gapDays }, pos_x: 240, pos_y: 770 }, + { node_key: 'final', type: 'action', config: { action: 'send_email', recipientClass: 'customer', emailType: 'invoice_final_notice' }, pos_x: 520, pos_y: 660 }, + { node_key: 'doneEnd', type: 'action', config: { action: 'noop' }, pos_x: 520, pos_y: 770 }, + { node_key: 'donePaid', type: 'action', config: { action: 'noop' }, pos_x: 520, pos_y: 330 }, + ]; + const edges = [ + { from_node: 't', to_node: 'waitDue' }, + { from_node: 'waitDue', to_node: 'waitGrace' }, + { from_node: 'waitGrace', to_node: 'checkPaid' }, + { from_node: 'checkPaid', from_handle: 'yes', to_node: 'donePaid' }, + { from_node: 'checkPaid', from_handle: 'no', to_node: 'gate' }, + { from_node: 'gate', from_handle: 'confirm', to_node: 'loop' }, + { from_node: 'gate', from_handle: 'deny', to_node: 'donePaid' }, + { from_node: 'loop', from_handle: 'loop', to_node: 'remind' }, + { from_node: 'loop', from_handle: 'exit', to_node: 'final' }, + { from_node: 'remind', to_node: 'waitGap' }, + { from_node: 'waitGap', to_node: 'checkPaid', loop_back: true }, + { from_node: 'final', to_node: 'doneEnd' }, + ]; + return { nodes, edges }; +} + +let booted = false; + +async function seedBuiltinWorkflowsAtBoot(db, logger) { + try { + if (!(await db.schema.hasTable('workflows'))) return; + const existing = await db('workflows').where({ builtin_key: DUNNING_KEY }).first(); + if (existing) { booted = true; return; } + + 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); + const { nodes, edges } = buildDunningGraph({ firstDays, gapDays, maxReminders: 2 }); + + await db.transaction(async (trx) => { + const ins = await trx('workflows').insert({ + name: 'Invoice dunning (built-in)', + description: 'Editable copy of the overdue-reminder ladder: wait to due date, ' + + 'confirm-no-payment gate, then up to two reminders before a final notice. ' + + 'Disabled by default — the live reminder ladder still runs via the scheduler ' + + 'until an explicit cutover, so enabling this without that change would double-send.', + enabled: false, + version: 1, + trigger_type: 'invoice.sent', + trigger_config: null, + is_builtin: true, + builtin_key: DUNNING_KEY, + }); + const workflowId = ins[0]; + for (const n of nodes) { + await trx('workflow_nodes').insert({ + workflow_id: workflowId, version: 1, 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: 1, 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, + }); + } + }); + + booted = true; + logger?.info?.('Seeded built-in workflow: invoice dunning (disabled)'); + } catch (err) { + logger?.warn?.('Built-in workflow seed failed at boot:', err.message); + } +} + +module.exports = { seedBuiltinWorkflowsAtBoot, buildDunningGraph, DUNNING_KEY };