562 lines
24 KiB
JavaScript
562 lines
24 KiB
JavaScript
/**
|
||
* 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,
|
||
};
|