feat(workflows): seed booking + pre-event built-ins, wire event.date_approaching
Three more editable built-in flows, seeded disabled like the dunning ladder: - booking_full: quote.accepted → prepare/send contract → admin "signed?" gate → create event → wait to event date → prepare/send invoice - booking_simple: the no-contract path (quote.accepted → event → invoice) - pre_event_email: customer reminder + admin heads-up, fired daysBefore the event date The booking document actions stay stubs (observable skipped steps) until the booking cutover. pre_event_email uses the already-wired send_email action, so it is functional once enabled — backed by a new scheduler emitter (emitDueEventReminders) that fires event.date_approaching for events entering a flow's lead window, deduped per event. Refactors the boot seeder to a built-in registry so each flow self-heals on its own SEED_VERSION.
This commit is contained in:
@@ -280,6 +280,59 @@ describe('workflow engine', () => {
|
|||||||
expect(after.version).toBe(before.version); // unchanged
|
expect(after.version).toBe(before.version); // unchanged
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('seeds the booking + pre-event built-ins (disabled, correct triggers)', async () => {
|
||||||
|
const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot');
|
||||||
|
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} });
|
||||||
|
|
||||||
|
const bookingFull = await db('workflows').where({ builtin_key: 'booking_full' }).first();
|
||||||
|
expect(bookingFull).toBeTruthy();
|
||||||
|
expect(!!bookingFull.enabled).toBe(false);
|
||||||
|
expect(bookingFull.trigger_type).toBe('quote.accepted');
|
||||||
|
const fullNodes = await db('workflow_nodes').where({ workflow_id: bookingFull.id, version: bookingFull.version });
|
||||||
|
expect(fullNodes.some((n) => n.type === 'gate')).toBe(true); // contract-signed gate
|
||||||
|
expect(fullNodes.some((n) => JSON.parse(n.config || '{}').action === 'prepare_contract')).toBe(true);
|
||||||
|
|
||||||
|
const bookingSimple = await db('workflows').where({ builtin_key: 'booking_simple' }).first();
|
||||||
|
expect(bookingSimple).toBeTruthy();
|
||||||
|
expect(bookingSimple.trigger_type).toBe('quote.accepted');
|
||||||
|
|
||||||
|
const preEvent = await db('workflows').where({ builtin_key: 'pre_event_email' }).first();
|
||||||
|
expect(preEvent).toBeTruthy();
|
||||||
|
expect(preEvent.trigger_type).toBe('event.date_approaching');
|
||||||
|
expect(JSON.parse(preEvent.trigger_config).daysBefore).toBe(3);
|
||||||
|
const preNodes = await db('workflow_nodes').where({ workflow_id: preEvent.id, version: preEvent.version });
|
||||||
|
expect(preNodes.some((n) => JSON.parse(n.config || '{}').action === 'send_email')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emitDueEventReminders starts a run for an event inside the lead window', async () => {
|
||||||
|
const wfId = await makeWorkflow({
|
||||||
|
trigger: 'event.date_approaching',
|
||||||
|
enabled: true,
|
||||||
|
nodes: [{ key: 'pe1', type: 'trigger' }, { key: 'pe2', type: 'action', config: { action: 'noop' } }],
|
||||||
|
edges: [{ from: 'pe1', to: 'pe2' }],
|
||||||
|
});
|
||||||
|
// Park the workflow's trigger window at 5 days so our event (2 days out) is in range.
|
||||||
|
await db('workflows').where({ id: wfId }).update({ trigger_config: JSON.stringify({ daysBefore: 5 }) });
|
||||||
|
|
||||||
|
const inWindow = new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 10);
|
||||||
|
const tooFar = new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10);
|
||||||
|
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
|
||||||
|
const evt = { event_type: 'wedding', password_hash: 'x', expires_at: farFuture, is_active: true, is_archived: false, customer_email: '[email protected]' };
|
||||||
|
await db('events').insert({ ...evt, slug: 'pe-soon', share_link: 'pe-soon', event_name: 'Soon', event_date: inWindow });
|
||||||
|
await db('events').insert({ ...evt, slug: 'pe-far', share_link: 'pe-far', event_name: 'Far', event_date: tooFar });
|
||||||
|
|
||||||
|
const emitted = await engine.emitDueEventReminders();
|
||||||
|
expect(emitted).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
const runs = await db('workflow_runs').where({ workflow_id: wfId, entity_type: 'event' });
|
||||||
|
expect(runs.length).toBe(1); // only the in-window event, not the far one
|
||||||
|
|
||||||
|
// Idempotent: a second pass dedups (no duplicate run for the same event).
|
||||||
|
await engine.emitDueEventReminders();
|
||||||
|
const runs2 = await db('workflow_runs').where({ workflow_id: wfId, entity_type: 'event' });
|
||||||
|
expect(runs2.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('recoverStaleRuns resumes a run orphaned mid-flow (crash recovery)', async () => {
|
test('recoverStaleRuns resumes a run orphaned mid-flow (crash recovery)', async () => {
|
||||||
const wfId = await makeWorkflow({
|
const wfId = await makeWorkflow({
|
||||||
trigger: 'recover.event',
|
trigger: 'recover.event',
|
||||||
|
|||||||
@@ -1,34 +1,33 @@
|
|||||||
/**
|
/**
|
||||||
* Boot-time seed for built-in workflows.
|
* Boot-time seed for built-in workflows.
|
||||||
*
|
*
|
||||||
* Seeds the invoice-dunning ladder as an EDITABLE built-in flow (the corrected
|
* Seeds the reminder/booking ladders as EDITABLE built-in flows, so the canvas
|
||||||
* gate-in-loop graph), so the canvas has real content and admins can see their
|
* has real content and admins can see (and tweak) their processes as blocks.
|
||||||
* reminder process as blocks. Seeded from the current reminder settings.
|
|
||||||
*
|
*
|
||||||
* IMPORTANT — seeded DISABLED, and live behaviour is UNCHANGED: the existing
|
* IMPORTANT — every built-in is seeded DISABLED. Live behaviour is UNCHANGED
|
||||||
* hardcoded reminder ladder in invoiceService.runScheduledTasks still runs. The
|
* until an admin enables a flow: the hardcoded reminder ladder still runs, and
|
||||||
* cutover (drive reminders through the engine + stop the hardcoded ladder) is a
|
* the booking document actions (prepare_quote/contract/event/invoice) are still
|
||||||
* deliberate follow-up so we never double-send. Enabling this flow before that
|
* stubs that record an observable `skipped` step rather than firing. The
|
||||||
* cutover would duplicate reminders — hence default off.
|
* 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='invoice_dunning'. Once seeded, admin edits
|
* Idempotent: keyed on builtin_key. Once seeded, admin edits are preserved (we
|
||||||
* are preserved (we never overwrite an existing built-in). Self-heal pattern
|
* never overwrite an enabled built-in, and re-seed a disabled one only when its
|
||||||
* per [[feedback_self_heal_pattern]].
|
* SEED_VERSION moves on). Self-heal pattern per [[feedback_self_heal_pattern]].
|
||||||
*/
|
*/
|
||||||
const { getAppSetting } = require('../utils/appSettings');
|
const { getAppSetting } = require('../utils/appSettings');
|
||||||
|
|
||||||
const DUNNING_KEY = 'invoice_dunning';
|
const DUNNING_KEY = 'invoice_dunning';
|
||||||
// Bump when the built-in graph changes so a disabled, never-activated copy is
|
|
||||||
// re-seeded on boot. v2 = delegation/cutover graph; v3 = 3 reminder loops;
|
|
||||||
// v4 = collections handoff after the loop exhausts.
|
|
||||||
const SEED_VERSION = 4;
|
|
||||||
|
|
||||||
function buildDunningGraph({ firstDays, gapDays, maxReminders }) {
|
function buildDunningGraph({ firstDays, gapDays, maxReminders }) {
|
||||||
// Delegation model: the payment-check email IS the admin gate (it drives the
|
// Delegation model: the payment-check email IS the admin gate (it drives the
|
||||||
// existing confirm + reminder_level + Mahngebühr state machine), so the flow
|
// existing confirm + reminder_level + Mahngebühr state machine), so the flow
|
||||||
// just decides WHEN to fire it. After due date + grace, loop up to
|
// 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,
|
// maxReminders times: if still unpaid, queue a payment-check, wait the gap,
|
||||||
// repeat; stop early once paid.
|
// repeat; stop early once paid. After the loop exhausts → collections handoff.
|
||||||
const nodes = [
|
const nodes = [
|
||||||
{ node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 },
|
{ 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: 'waitDue', type: 'wait', config: { untilVar: 'dueDate' }, pos_x: 240, pos_y: 110 },
|
||||||
@@ -56,6 +55,146 @@ function buildDunningGraph({ firstDays, gapDays, maxReminders }) {
|
|||||||
return { nodes, edges };
|
return { nodes, edges };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Booking — quote accepted → prepare + send contract → admin gate "signed?" →
|
||||||
|
// create the event/gallery → wait to the event date → prepare + send invoice.
|
||||||
|
// The signing step is an admin gate (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: 240, pos_y: 0 },
|
||||||
|
{ node_key: 'prepContract', type: 'action', config: { action: 'prepare_contract' }, pos_x: 240, pos_y: 110 },
|
||||||
|
{ node_key: 'sendContract', type: 'action', config: { action: 'send_document', document: 'contract', recipient: 'customer' }, pos_x: 240, pos_y: 220 },
|
||||||
|
{ node_key: 'gateSigned', type: 'gate', config: { label: 'Contract signed?' }, pos_x: 240, pos_y: 330 },
|
||||||
|
{ node_key: 'prepEvent', type: 'action', config: { action: 'prepare_event' }, pos_x: 240, pos_y: 440 },
|
||||||
|
{ node_key: 'waitEvent', type: 'wait', config: { untilVar: 'eventDate' }, pos_x: 240, pos_y: 550 },
|
||||||
|
{ node_key: 'prepInvoice', type: 'action', config: { action: 'prepare_invoice' }, pos_x: 240, pos_y: 660 },
|
||||||
|
{ node_key: 'sendInvoice', type: 'action', config: { action: 'send_document', document: 'invoice', recipient: 'customer' }, pos_x: 240, pos_y: 770 },
|
||||||
|
{ node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 240, pos_y: 880 },
|
||||||
|
{ node_key: 'declined', type: 'action', config: { action: 'noop' }, pos_x: 520, pos_y: 330 },
|
||||||
|
];
|
||||||
|
const edges = [
|
||||||
|
{ from_node: 't', to_node: 'prepContract' },
|
||||||
|
{ from_node: 'prepContract', to_node: 'sendContract' },
|
||||||
|
{ from_node: 'sendContract', to_node: 'gateSigned' },
|
||||||
|
{ from_node: 'gateSigned', from_handle: 'confirm', to_node: 'prepEvent' },
|
||||||
|
{ from_node: 'gateSigned', from_handle: 'deny', to_node: 'declined' },
|
||||||
|
{ from_node: 'prepEvent', to_node: 'waitEvent' },
|
||||||
|
{ from_node: 'waitEvent', to_node: 'prepInvoice' },
|
||||||
|
{ from_node: 'prepInvoice', 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 + send invoice. The no-contract path (e.g. small shoots). Same stub
|
||||||
|
// caveat as the full booking flow.
|
||||||
|
function buildBookingSimpleGraph() {
|
||||||
|
const nodes = [
|
||||||
|
{ node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 },
|
||||||
|
{ node_key: 'prepEvent', type: 'action', config: { action: 'prepare_event' }, pos_x: 240, pos_y: 110 },
|
||||||
|
{ node_key: 'waitEvent', type: 'wait', config: { untilVar: 'eventDate' }, pos_x: 240, pos_y: 220 },
|
||||||
|
{ node_key: 'prepInvoice', type: 'action', config: { action: 'prepare_invoice' }, pos_x: 240, pos_y: 330 },
|
||||||
|
{ node_key: 'sendInvoice', type: 'action', config: { action: 'send_document', document: 'invoice', recipient: 'customer' }, pos_x: 240, pos_y: 440 },
|
||||||
|
{ node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 240, pos_y: 550 },
|
||||||
|
];
|
||||||
|
const edges = [
|
||||||
|
{ from_node: 't', to_node: 'prepEvent' },
|
||||||
|
{ from_node: 'prepEvent', to_node: 'waitEvent' },
|
||||||
|
{ from_node: 'waitEvent', to_node: 'prepInvoice' },
|
||||||
|
{ from_node: 'prepInvoice', to_node: 'sendInvoice' },
|
||||||
|
{ from_node: 'sendInvoice', to_node: 'done' },
|
||||||
|
];
|
||||||
|
return { nodes, edges };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-event email — fired by the scheduler `daysBefore` the event date (see
|
||||||
|
// emitDueEventReminders in the engine). Sends a customer reminder, then a heads-
|
||||||
|
// up to the admin. Unlike the booking flows this uses the already-wired
|
||||||
|
// send_email action, so it is functional once enabled (the customer template
|
||||||
|
// `pre_event_reminder` should exist / be authored).
|
||||||
|
function buildPreEventEmailGraph() {
|
||||||
|
const nodes = [
|
||||||
|
{ node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 },
|
||||||
|
{ node_key: 'emailCustomer', type: 'action', config: { action: 'send_email', recipientClass: 'customer', emailType: 'pre_event_reminder' }, pos_x: 240, pos_y: 110 },
|
||||||
|
{ node_key: 'emailAdmin', type: 'action', config: { action: 'send_email', recipientClass: 'admin', emailType: 'pre_event_internal' }, pos_x: 240, pos_y: 220 },
|
||||||
|
{ node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 240, pos_y: 330 },
|
||||||
|
];
|
||||||
|
const edges = [
|
||||||
|
{ from_node: 't', to_node: 'emailCustomer' },
|
||||||
|
{ from_node: 'emailCustomer', to_node: 'emailAdmin' },
|
||||||
|
{ from_node: 'emailAdmin', to_node: 'done' },
|
||||||
|
];
|
||||||
|
return { nodes, edges };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built-in registry. `version` is the SEED_VERSION — bump when a graph changes
|
||||||
|
// so a disabled, never-activated copy is re-seeded on boot.
|
||||||
|
// invoice_dunning v4 = collections handoff after the loop exhausts.
|
||||||
|
const BUILTINS = [
|
||||||
|
{
|
||||||
|
key: DUNNING_KEY,
|
||||||
|
version: 4,
|
||||||
|
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; '
|
||||||
|
+ 'while it is enabled the hardcoded reminder ladder is skipped automatically, so the two '
|
||||||
|
+ 'never double-send.',
|
||||||
|
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: 'booking_full',
|
||||||
|
version: 1,
|
||||||
|
name: 'Booking — quote → contract → event → invoice (built-in)',
|
||||||
|
trigger_type: 'quote.accepted',
|
||||||
|
trigger_config: {},
|
||||||
|
description:
|
||||||
|
'On quote acceptance: prepare and send the contract, wait for the admin to confirm it is '
|
||||||
|
+ 'signed, then create the event/gallery, wait to the shoot date and prepare + send the '
|
||||||
|
+ 'invoice. 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: 1,
|
||||||
|
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, wait to the '
|
||||||
|
+ 'shoot date and prepare + send the invoice. Same stub caveat as the full booking flow; '
|
||||||
|
+ 'disabled by default.',
|
||||||
|
build: async () => buildBookingSimpleGraph(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pre_event_email',
|
||||||
|
version: 1,
|
||||||
|
name: 'Pre-event email (built-in)',
|
||||||
|
trigger_type: 'event.date_approaching',
|
||||||
|
// daysBefore drives the scheduler emitter — how many days before the event
|
||||||
|
// date the reminder fires.
|
||||||
|
trigger_config: { daysBefore: 3 },
|
||||||
|
description:
|
||||||
|
'A few days before the event date, send the customer a reminder and the admin a heads-up. '
|
||||||
|
+ 'Fired by the scheduler from the event date (daysBefore in the trigger config). Uses the '
|
||||||
|
+ 'wired send_email action, so it works once enabled and the pre_event_reminder template '
|
||||||
|
+ 'exists. Disabled by default.',
|
||||||
|
build: async () => buildPreEventEmailGraph(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
let booted = false;
|
let booted = false;
|
||||||
|
|
||||||
function parseSeedConfig(raw) {
|
function parseSeedConfig(raw) {
|
||||||
@@ -79,70 +218,70 @@ async function writeGraph(trx, workflowId, version, nodes, edges) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function seedOneBuiltin(db, logger, def) {
|
||||||
|
const { nodes, edges } = await def.build();
|
||||||
|
const triggerConfig = { ...(def.trigger_config || {}), seedVersion: def.version };
|
||||||
|
|
||||||
|
const existing = await db('workflows').where({ builtin_key: def.key }).first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Re-seed the graph only when (a) it has never been activated and (b) our
|
||||||
|
// seed version moved on. Once the admin enables it, it's their live flow —
|
||||||
|
// never overwrite it.
|
||||||
|
const storedVersion = Number(parseSeedConfig(existing.trigger_config).seedVersion) || 0;
|
||||||
|
const isEnabled = existing.enabled === true || existing.enabled === 1;
|
||||||
|
if (isEnabled || 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),
|
||||||
|
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})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
const ins = await trx('workflows').insert({
|
||||||
|
name: def.name,
|
||||||
|
description: def.description,
|
||||||
|
enabled: false,
|
||||||
|
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} (disabled)`);
|
||||||
|
}
|
||||||
|
|
||||||
async function seedBuiltinWorkflowsAtBoot(db, logger) {
|
async function seedBuiltinWorkflowsAtBoot(db, logger) {
|
||||||
try {
|
try {
|
||||||
if (!(await db.schema.hasTable('workflows'))) return;
|
if (!(await db.schema.hasTable('workflows'))) return;
|
||||||
|
for (const def of BUILTINS) {
|
||||||
const firstDays = Number(await getAppSetting('crm_invoices_reminder_first_days')) || 14;
|
try {
|
||||||
const secondDays = Number(await getAppSetting('crm_invoices_reminder_second_days')) || 30;
|
await seedOneBuiltin(db, logger, def);
|
||||||
const gapDays = Math.max(1, secondDays - firstDays);
|
} catch (err) {
|
||||||
const { nodes, edges } = buildDunningGraph({ firstDays, gapDays, maxReminders: 3 });
|
logger?.warn?.(`Built-in workflow seed failed for ${def.key}:`, err.message);
|
||||||
|
}
|
||||||
const description = 'Drives overdue dunning through the engine: wait to the due date, then up '
|
|
||||||
+ 'to two 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. '
|
|
||||||
+ 'Disabled by default; while it is enabled the hardcoded reminder ladder is skipped '
|
|
||||||
+ 'automatically, so the two never double-send.';
|
|
||||||
|
|
||||||
const existing = await db('workflows').where({ builtin_key: DUNNING_KEY }).first();
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
// Re-seed the graph only when (a) it has never been activated and (b) our
|
|
||||||
// seed version moved on (the dunning cutover). Once the admin enables it,
|
|
||||||
// it's their live flow — never overwrite it.
|
|
||||||
const storedVersion = Number(parseSeedConfig(existing.trigger_config).seedVersion) || 0;
|
|
||||||
const isEnabled = existing.enabled === true || existing.enabled === 1;
|
|
||||||
if (isEnabled || storedVersion >= SEED_VERSION) { booted = true; return; }
|
|
||||||
|
|
||||||
const newVersion = (existing.version || 1) + 1;
|
|
||||||
await db.transaction(async (trx) => {
|
|
||||||
await trx('workflows').where({ id: existing.id }).update({
|
|
||||||
name: 'Invoice dunning (built-in)',
|
|
||||||
description,
|
|
||||||
trigger_config: JSON.stringify({ seedVersion: SEED_VERSION }),
|
|
||||||
version: newVersion,
|
|
||||||
updated_at: trx.fn.now(),
|
|
||||||
});
|
|
||||||
await writeGraph(trx, existing.id, newVersion, nodes, edges);
|
|
||||||
});
|
|
||||||
booted = true;
|
|
||||||
logger?.info?.('Re-seeded built-in workflow: invoice dunning (delegation graph v2)');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
|
||||||
const ins = await trx('workflows').insert({
|
|
||||||
name: 'Invoice dunning (built-in)',
|
|
||||||
description,
|
|
||||||
enabled: false,
|
|
||||||
version: 1,
|
|
||||||
trigger_type: 'invoice.sent',
|
|
||||||
trigger_config: JSON.stringify({ seedVersion: SEED_VERSION }),
|
|
||||||
is_builtin: true,
|
|
||||||
builtin_key: DUNNING_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);
|
|
||||||
});
|
|
||||||
|
|
||||||
booted = true;
|
booted = true;
|
||||||
logger?.info?.('Seeded built-in workflow: invoice dunning (disabled)');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger?.warn?.('Built-in workflow seed failed at boot:', err.message);
|
logger?.warn?.('Built-in workflow seed failed at boot:', err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { seedBuiltinWorkflowsAtBoot, buildDunningGraph, DUNNING_KEY };
|
module.exports = { seedBuiltinWorkflowsAtBoot, buildDunningGraph, DUNNING_KEY, BUILTINS };
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ async function runTick() {
|
|||||||
const wf = require('./workflows');
|
const wf = require('./workflows');
|
||||||
const resumed = await wf.runDueWaits();
|
const resumed = await wf.runDueWaits();
|
||||||
if (resumed) logger.info('Workflow scheduler: resumed waiting runs', { resumed });
|
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
|
// Recover runs orphaned by a crash (stuck in running/pending). Runs on the
|
||||||
// boot tick too, so a restart catches anything stranded during downtime.
|
// boot tick too, so a restart catches anything stranded during downtime.
|
||||||
const recovered = await wf.recoverStaleRuns();
|
const recovered = await wf.recoverStaleRuns();
|
||||||
|
|||||||
@@ -380,6 +380,79 @@ async function recoverStaleRuns({ staleMs = RECOVERY_STALE_MS, limit = 50 } = {}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit `event.date_approaching` for events whose date is within the configured
|
||||||
|
* lead window of an enabled pre-event flow. Iterates per flow so each respects
|
||||||
|
* its own trigger_config.daysBefore; emitWorkflowEvent's per-(flow,entity)
|
||||||
|
* dedup_key guarantees a single run per event, so polling hourly never
|
||||||
|
* duplicates. Fails CLOSED when the workflows flag is off. Called from the
|
||||||
|
* scheduler tick.
|
||||||
|
*
|
||||||
|
* Caveat (v1): emitWorkflowEvent fans out to ALL enabled flows of this trigger,
|
||||||
|
* so with multiple pre-event flows the widest window wins for surfacing an
|
||||||
|
* event; a narrower flow may then fire earlier than its own daysBefore. The
|
||||||
|
* single-built-in case (the norm) is exact.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 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 */ }
|
||||||
|
|
||||||
|
let emitted = 0;
|
||||||
|
const todayIso = new Date().toISOString().slice(0, 10);
|
||||||
|
for (const wf of flows) {
|
||||||
|
const cfg = parseJson(wf.trigger_config, {});
|
||||||
|
const daysBefore = Number(cfg.daysBefore) > 0 ? Number(cfg.daysBefore) : 3;
|
||||||
|
const windowEndIso = new Date(Date.now() + daysBefore * 86400000).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const events = await db('events')
|
||||||
|
.where('is_active', true)
|
||||||
|
.where('is_archived', false)
|
||||||
|
.whereNotNull('event_date')
|
||||||
|
.where('event_date', '>=', todayIso)
|
||||||
|
.where('event_date', '<=', windowEndIso)
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
for (const ev of events) {
|
||||||
|
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,
|
||||||
|
hostName: ev.host_name || null,
|
||||||
|
customerEmail: ev.customer_email || ev.host_email || null,
|
||||||
|
adminEmail,
|
||||||
|
daysBefore,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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
|
* 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
|
* entity/payload and starts it. Defaults to dryRun: side-effecting actions are
|
||||||
@@ -412,6 +485,7 @@ async function testRun(workflowId, { entityType = null, entityId = null, payload
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
emitWorkflowEvent,
|
emitWorkflowEvent,
|
||||||
runDueWaits,
|
runDueWaits,
|
||||||
|
emitDueEventReminders,
|
||||||
recoverStaleRuns,
|
recoverStaleRuns,
|
||||||
testRun,
|
testRun,
|
||||||
startRun,
|
startRun,
|
||||||
|
|||||||
Reference in New Issue
Block a user