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:
Luca
2026-06-23 14:11:11 +02:00
parent e70ddd36b8
commit 62ba905464
4 changed files with 342 additions and 73 deletions
@@ -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',
+188 -49
View File
@@ -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 seedBuiltinWorkflowsAtBoot(db, logger) { async function seedOneBuiltin(db, logger, def) {
try { const { nodes, edges } = await def.build();
if (!(await db.schema.hasTable('workflows'))) return; const triggerConfig = { ...(def.trigger_config || {}), seedVersion: def.version };
const firstDays = Number(await getAppSetting('crm_invoices_reminder_first_days')) || 14; const existing = await db('workflows').where({ builtin_key: def.key }).first();
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: 3 });
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) { if (existing) {
// Re-seed the graph only when (a) it has never been activated and (b) our // 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, // seed version moved on. Once the admin enables it, it's their live flow —
// it's their live flow — never overwrite it. // never overwrite it.
const storedVersion = Number(parseSeedConfig(existing.trigger_config).seedVersion) || 0; const storedVersion = Number(parseSeedConfig(existing.trigger_config).seedVersion) || 0;
const isEnabled = existing.enabled === true || existing.enabled === 1; const isEnabled = existing.enabled === true || existing.enabled === 1;
if (isEnabled || storedVersion >= SEED_VERSION) { booted = true; return; } if (isEnabled || storedVersion >= def.version) return;
const newVersion = (existing.version || 1) + 1; const newVersion = (existing.version || 1) + 1;
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
await trx('workflows').where({ id: existing.id }).update({ await trx('workflows').where({ id: existing.id }).update({
name: 'Invoice dunning (built-in)', name: def.name,
description, description: def.description,
trigger_config: JSON.stringify({ seedVersion: SEED_VERSION }), trigger_type: def.trigger_type,
trigger_config: JSON.stringify(triggerConfig),
version: newVersion, version: newVersion,
updated_at: trx.fn.now(), updated_at: trx.fn.now(),
}); });
await writeGraph(trx, existing.id, newVersion, nodes, edges); await writeGraph(trx, existing.id, newVersion, nodes, edges);
}); });
booted = true; logger?.info?.(`Re-seeded built-in workflow: ${def.key} (v${def.version})`);
logger?.info?.('Re-seeded built-in workflow: invoice dunning (delegation graph v2)');
return; return;
} }
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
const ins = await trx('workflows').insert({ const ins = await trx('workflows').insert({
name: 'Invoice dunning (built-in)', name: def.name,
description, description: def.description,
enabled: false, enabled: false,
version: 1, version: 1,
trigger_type: 'invoice.sent', trigger_type: def.trigger_type,
trigger_config: JSON.stringify({ seedVersion: SEED_VERSION }), trigger_config: JSON.stringify(triggerConfig),
is_builtin: true, is_builtin: true,
builtin_key: DUNNING_KEY, builtin_key: def.key,
}).returning('id'); }).returning('id');
// Postgres returns [] without `.returning`, so ins[0] would be undefined // Postgres returns [] without `.returning`, so ins[0] would be undefined and
// and the child node inserts would roll back on NOT NULL. Normalise the // the child node inserts would roll back on NOT NULL. Normalise the {id}
// {id} (pg) vs bare-id (sqlite) shapes. // (pg) vs bare-id (sqlite) shapes.
const workflowId = ins[0]?.id ?? ins[0]; const workflowId = ins[0]?.id ?? ins[0];
await writeGraph(trx, workflowId, 1, nodes, edges); await writeGraph(trx, workflowId, 1, nodes, edges);
}); });
logger?.info?.(`Seeded built-in workflow: ${def.key} (disabled)`);
}
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; 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();
+74
View File
@@ -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,