From 62ba905464387784be7710610a65341569b68e46 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:11:11 +0200 Subject: [PATCH] feat(workflows): seed booking + pre-event built-ins, wire event.date_approaching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../integration/workflowEngine.test.js | 53 ++++ backend/src/services/_workflowSeedBoot.js | 285 +++++++++++++----- .../src/services/invoiceSchedulerService.js | 3 + backend/src/services/workflows/engine.js | 74 +++++ 4 files changed, 342 insertions(+), 73 deletions(-) diff --git a/backend/__tests__/integration/workflowEngine.test.js b/backend/__tests__/integration/workflowEngine.test.js index 3aa747dd..22ff4a17 100644 --- a/backend/__tests__/integration/workflowEngine.test.js +++ b/backend/__tests__/integration/workflowEngine.test.js @@ -280,6 +280,59 @@ describe('workflow engine', () => { 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: 'c@x.test' }; + 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 () => { const wfId = await makeWorkflow({ trigger: 'recover.event', diff --git a/backend/src/services/_workflowSeedBoot.js b/backend/src/services/_workflowSeedBoot.js index 4f33c9a4..d1137acc 100644 --- a/backend/src/services/_workflowSeedBoot.js +++ b/backend/src/services/_workflowSeedBoot.js @@ -1,34 +1,33 @@ /** * 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. + * 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 — 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. + * 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='invoice_dunning'. Once seeded, admin edits - * are preserved (we never overwrite an existing built-in). Self-heal pattern - * per [[feedback_self_heal_pattern]]. + * 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'; -// 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 }) { // 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. + // 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 }, @@ -56,6 +55,146 @@ function buildDunningGraph({ firstDays, gapDays, maxReminders }) { 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; 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) { try { if (!(await db.schema.hasTable('workflows'))) 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: 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) { - // 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; + 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); + } } - - 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; - 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 }; +module.exports = { seedBuiltinWorkflowsAtBoot, buildDunningGraph, DUNNING_KEY, BUILTINS }; diff --git a/backend/src/services/invoiceSchedulerService.js b/backend/src/services/invoiceSchedulerService.js index 49fe983d..a6ec2644 100644 --- a/backend/src/services/invoiceSchedulerService.js +++ b/backend/src/services/invoiceSchedulerService.js @@ -49,6 +49,9 @@ async function runTick() { 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(); diff --git a/backend/src/services/workflows/engine.js b/backend/src/services/workflows/engine.js index 4bc86408..11d90dbe 100644 --- a/backend/src/services/workflows/engine.js +++ b/backend/src/services/workflows/engine.js @@ -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 * 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 = { emitWorkflowEvent, runDueWaits, + emitDueEventReminders, recoverStaleRuns, testRun, startRun,