gigafibre-fsm/services/targo-hub/lib/flow-runtime.js
louispaulb beb6ddc5e5 docs: reorganize into architecture/features/reference/archive folders
All docs moved with git mv so --follow preserves history. Flattens the
single-folder layout into goal-oriented folders and adds a README.md index
at every level.

- docs/README.md — new landing page with "I want to…" intent table
- docs/architecture/ — overview, data-model, app-design
- docs/features/ — billing-payments, cpe-management, vision-ocr, flow-editor
- docs/reference/ — erpnext-item-diff, legacy-wizard/
- docs/archive/ — HANDOFF-2026-04-18, MIGRATION, status-snapshots/
- docs/assets/ — pptx sources, build scripts (fixed hardcoded path)
- roadmap.md gains a "Modules in production" section with clickable
  URLs for every ops/tech/portal route and admin surface
- Phase 4 (Customer Portal) flipped to "Largely Shipped" based on
  audit of services/targo-hub/lib/payments.js (16 endpoints, webhook,
  PPA cron, Klarna BNPL all live)
- Archive files get an "ARCHIVED" banner so stale links inside them
  don't mislead readers

Code comments + nginx configs rewritten to use new doc paths. Root
README.md documentation table replaced with intent-oriented index.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 11:51:33 -04:00

672 lines
28 KiB
JavaScript

'use strict'
/**
* flow-runtime.js — Execution engine for Flow Templates.
*
* Responsibilities
* ────────────────
* 1. startFlow(templateName, ctx) → creates a Flow Run + kicks off the first wave of steps
* 2. advanceFlow(runName) → evaluates which steps are ready and runs them
* 3. completeStep(runName, stepId, result) → mark a step done and advance the flow
* 4. Kind dispatcher: KIND_HANDLERS[step.kind](step, ctx) → { status, result }
* 5. Scheduling: any step with trigger.type === 'after_delay' | 'on_date' creates
* a Flow Step Pending row instead of executing inline
*
* Context shape
* ─────────────
* ctx = {
* run: { name, variables, step_state }, // the Flow Run doc
* template: { flow_definition, … }, // the Flow Template doc
* customer: { name, customer_name, … }, // resolved customer doc
* doc: { … }, // the trigger doc (contract, quotation …)
* doctype, docname, // trigger doc reference
* }
*
* Step state (persisted in Flow Run.step_state)
* ─────────────────────────────────────────────
* { [stepId]: { status: 'pending|running|done|failed|scheduled|skipped',
* started_at, completed_at, result, error, retry_count } }
*
* See docs/features/flow-editor.md for full data model + trigger wiring.
*/
const { log, erpFetch } = require('./helpers')
const cfg = require('./config')
// ───────────────────────────────────────────────────────────────────────────
// Constants
// ───────────────────────────────────────────────────────────────────────────
const FT_DOCTYPE = 'Flow Template'
const FR_DOCTYPE = 'Flow Run'
const FS_DOCTYPE = 'Flow Step Pending'
const ENC_FT = encodeURIComponent(FT_DOCTYPE)
const ENC_FR = encodeURIComponent(FR_DOCTYPE)
const ENC_FS = encodeURIComponent(FS_DOCTYPE)
/** All step statuses (for type-safety when reading step_state). */
const STATUS = {
PENDING: 'pending',
RUNNING: 'running',
DONE: 'done',
FAILED: 'failed',
SCHEDULED: 'scheduled',
SKIPPED: 'skipped',
}
// ───────────────────────────────────────────────────────────────────────────
// Template rendering (simple {{var.path}} interpolation, no Mustache lib)
// ───────────────────────────────────────────────────────────────────────────
const TEMPLATE_RE = /\{\{\s*([\w.]+)\s*\}\}/g
/** Follow dotted paths through nested objects. Safe on missing keys. */
function getPath (obj, path) {
if (!obj || !path) return undefined
return path.split('.').reduce((o, k) => (o == null ? o : o[k]), obj)
}
/** Replace {{a.b.c}} tokens with ctx values; leaves non-strings alone. */
function render (tpl, ctx) {
if (typeof tpl !== 'string') return tpl
return tpl.replace(TEMPLATE_RE, (match, path) => {
const val = getPath(ctx, path)
return val == null ? '' : String(val)
})
}
/** Deep-render every string inside an object. Recursive; arrays preserved. */
function renderDeep (input, ctx) {
if (input == null) return input
if (typeof input === 'string') return render(input, ctx)
if (Array.isArray(input)) return input.map(v => renderDeep(v, ctx))
if (typeof input === 'object') {
const out = {}
for (const [k, v] of Object.entries(input)) out[k] = renderDeep(v, ctx)
return out
}
return input
}
// ───────────────────────────────────────────────────────────────────────────
// Condition evaluator — simple predicate language (no eval)
// ───────────────────────────────────────────────────────────────────────────
/**
* Evaluate a condition payload against the context.
* Shape: { field, op, value }
* Ops: == != < > <= >= in not_in empty not_empty contains starts_with ends_with
* Returns boolean.
*/
function evalCondition (p, ctx) {
const left = getPath(ctx, p.field)
const right = typeof p.value === 'string' ? render(p.value, ctx) : p.value
switch (p.op) {
case '==': return String(left) == String(right) // eslint-disable-line eqeqeq
case '!=': return String(left) != String(right) // eslint-disable-line eqeqeq
case '<': return Number(left) < Number(right)
case '>': return Number(left) > Number(right)
case '<=': return Number(left) <= Number(right)
case '>=': return Number(left) >= Number(right)
case 'in': return String(right).split(',').map(s => s.trim()).includes(String(left))
case 'not_in':return !String(right).split(',').map(s => s.trim()).includes(String(left))
case 'empty': return left == null || left === ''
case 'not_empty': return left != null && left !== ''
case 'contains': return String(left || '').includes(String(right))
case 'starts_with':return String(left || '').startsWith(String(right))
case 'ends_with': return String(left || '').endsWith(String(right))
default: return false
}
}
// ───────────────────────────────────────────────────────────────────────────
// Kind handlers — one function per step kind. All return:
// { status: 'done'|'failed'|'scheduled', result?, error? }
// ───────────────────────────────────────────────────────────────────────────
/** Create a Dispatch Job. Returns the new job name in result. */
async function handleDispatchJob (step, ctx) {
const p = renderDeep(step.payload || {}, ctx)
const payload = {
doctype: 'Dispatch Job',
customer: ctx.customer?.name || p.customer || '',
customer_name: ctx.customer?.customer_name || '',
subject: p.subject || step.label || 'Tâche',
job_type: p.job_type || 'Autre',
priority: p.priority || 'medium',
duration_h: p.duration_h || 1,
assigned_group: p.assigned_group || 'Tech Targo',
status: 'open',
flow_run: ctx.run?.name,
flow_step_id: step.id,
}
if (p.service_location) payload.service_location = p.service_location
else if (ctx.doc?.service_location) payload.service_location = ctx.doc.service_location
if (p.merge_key) payload.merge_key = p.merge_key
if (p.on_open_webhook) payload.on_open_webhook = p.on_open_webhook
if (p.on_close_webhook) payload.on_close_webhook = p.on_close_webhook
if (p.notes) payload.description = p.notes
const r = await erpFetch('/api/resource/Dispatch Job', {
method: 'POST', body: JSON.stringify(payload),
})
if (r.status !== 200 && r.status !== 201) {
return { status: STATUS.FAILED, error: 'Dispatch Job creation failed: ' + JSON.stringify(r.data).slice(0, 200) }
}
return { status: STATUS.DONE, result: { job: r.data.data?.name } }
}
/** Create an ERPNext Issue (ticket). */
async function handleIssue (step, ctx) {
const p = renderDeep(step.payload || {}, ctx)
const payload = {
doctype: 'Issue',
subject: p.subject || step.label || 'Suivi',
description: p.description || '',
priority: p.priority || 'Medium',
issue_type: p.issue_type || 'Suivi',
status: 'Open',
customer: ctx.customer?.name || null,
flow_run: ctx.run?.name,
flow_step_id: step.id,
}
const r = await erpFetch('/api/resource/Issue', {
method: 'POST', body: JSON.stringify(payload),
})
if (r.status !== 200 && r.status !== 201) {
return { status: STATUS.FAILED, error: 'Issue creation failed: ' + JSON.stringify(r.data).slice(0, 200) }
}
return { status: STATUS.DONE, result: { issue: r.data.data?.name } }
}
/** Send a notification (SMS or email) via existing Hub modules. */
async function handleNotify (step, ctx) {
const p = renderDeep(step.payload || {}, ctx)
const channel = (p.channel || 'sms').toLowerCase()
const to = p.to || ctx.customer?.cell_phone || ctx.customer?.primary_phone || ctx.customer?.email_id
if (!to) return { status: STATUS.FAILED, error: 'notify: no recipient resolved' }
try {
if (channel === 'sms') {
const { sendSms } = require('./twilio')
const body = p.body || (p.template_id ? _lookupTemplateText(p.template_id, ctx) : '')
if (!body) return { status: STATUS.FAILED, error: 'notify: empty SMS body' }
const r = await sendSms({ to, body })
return { status: STATUS.DONE, result: { channel, sid: r?.sid, simulated: r?.simulated } }
}
if (channel === 'email') {
const { sendEmail } = require('./email')
const subject = p.subject || 'Notification'
const html = p.body || _lookupTemplateText(p.template_id, ctx) || ''
const r = await sendEmail({ to, subject, html })
return { status: STATUS.DONE, result: { channel, id: r?.id } }
}
return { status: STATUS.FAILED, error: `notify: unknown channel "${channel}"` }
} catch (e) {
return { status: STATUS.FAILED, error: 'notify failed: ' + e.message }
}
}
/** Look up a template body from email-templates.js (stubbed with empty fallback). */
function _lookupTemplateText (templateId, ctx) {
if (!templateId) return ''
try {
const tpls = require('./email-templates')
const tpl = tpls[templateId] || tpls.TEMPLATES?.[templateId]
if (!tpl) return ''
const body = typeof tpl === 'string' ? tpl : (tpl.body || tpl.text || '')
return render(body, ctx)
} catch { return '' }
}
/** POST/GET/PUT/DELETE an external webhook. Body JSON-rendered from context. */
async function handleWebhook (step, ctx) {
const p = renderDeep(step.payload || {}, ctx)
if (!p.url) return { status: STATUS.FAILED, error: 'webhook: url required' }
const method = (p.method || 'POST').toUpperCase()
let body
if (p.body_template) {
try { body = JSON.parse(p.body_template) }
catch (e) { return { status: STATUS.FAILED, error: 'webhook: body_template is not valid JSON: ' + e.message } }
}
try {
const r = await fetch(p.url, {
method,
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
})
return {
status: r.ok ? STATUS.DONE : STATUS.FAILED,
result: { http_status: r.status },
error: r.ok ? undefined : `HTTP ${r.status}`,
}
} catch (e) {
return { status: STATUS.FAILED, error: 'webhook request failed: ' + e.message }
}
}
/** Update a field (or set of fields) on an existing ERPNext doc. */
async function handleErpUpdate (step, ctx) {
const p = renderDeep(step.payload || {}, ctx)
if (!p.doctype || !p.docname_ref) {
return { status: STATUS.FAILED, error: 'erp_update: doctype and docname_ref required' }
}
let fields
try { fields = typeof p.fields_json === 'string' ? JSON.parse(p.fields_json) : (p.fields_json || {}) }
catch (e) { return { status: STATUS.FAILED, error: 'erp_update: fields_json invalid: ' + e.message } }
const path = `/api/resource/${encodeURIComponent(p.doctype)}/${encodeURIComponent(p.docname_ref)}`
const r = await erpFetch(path, { method: 'PUT', body: JSON.stringify(fields) })
if (r.status !== 200) {
return { status: STATUS.FAILED, error: `erp_update failed: HTTP ${r.status}` }
}
return { status: STATUS.DONE, result: { doctype: p.doctype, docname: p.docname_ref } }
}
/**
* Activate a Subscription (ERPNext Subscription doctype).
* Expects payload.subscription_ref → name of the Subscription doc.
*/
async function handleSubscriptionActivate (step, ctx) {
const p = renderDeep(step.payload || {}, ctx)
const subName = p.subscription_ref || ctx.doc?.subscription
if (!subName) return { status: STATUS.FAILED, error: 'subscription_activate: subscription_ref required' }
const path = `/api/resource/Subscription/${encodeURIComponent(subName)}`
const r = await erpFetch(path, { method: 'PUT', body: JSON.stringify({ status: 'Active' }) })
if (r.status !== 200) {
return { status: STATUS.FAILED, error: 'subscription_activate failed: HTTP ' + r.status }
}
return { status: STATUS.DONE, result: { subscription: subName } }
}
/** Wait step — purely declarative; always resolves "done" inline. */
function handleWait () {
return { status: STATUS.DONE, result: { waited: true } }
}
/** Condition step — evaluates the predicate; returns DONE with a branch hint. */
function handleCondition (step, ctx) {
const ok = evalCondition(step.payload || {}, ctx)
return { status: STATUS.DONE, result: { branch: ok ? 'yes' : 'no' } }
}
const KIND_HANDLERS = {
dispatch_job: handleDispatchJob,
issue: handleIssue,
notify: handleNotify,
webhook: handleWebhook,
erp_update: handleErpUpdate,
subscription_activate: handleSubscriptionActivate,
wait: handleWait,
condition: handleCondition,
}
// ───────────────────────────────────────────────────────────────────────────
// Run-level persistence helpers
// ───────────────────────────────────────────────────────────────────────────
async function _fetchTemplate (name) {
const r = await erpFetch(`/api/resource/${ENC_FT}/${encodeURIComponent(name)}`)
if (r.status !== 200) throw new Error(`Template ${name} not found`)
const tpl = r.data.data
try { tpl.flow_definition = JSON.parse(tpl.flow_definition || '{}') }
catch { tpl.flow_definition = { steps: [] } }
if (!tpl.flow_definition.steps) tpl.flow_definition.steps = []
return tpl
}
async function _fetchRun (name) {
const r = await erpFetch(`/api/resource/${ENC_FR}/${encodeURIComponent(name)}`)
if (r.status !== 200) throw new Error(`Run ${name} not found`)
const run = r.data.data
run.variables = _parseJson(run.variables, {})
run.step_state = _parseJson(run.step_state, {})
return run
}
function _parseJson (s, fallback) {
if (!s) return fallback
if (typeof s === 'object') return s
try { return JSON.parse(s) } catch { return fallback }
}
async function _persistRun (run, patch) {
const body = { ...patch }
if (body.variables && typeof body.variables !== 'string') body.variables = JSON.stringify(body.variables)
if (body.step_state && typeof body.step_state !== 'string') body.step_state = JSON.stringify(body.step_state)
const r = await erpFetch(`/api/resource/${ENC_FR}/${encodeURIComponent(run.name)}`, {
method: 'PUT', body: JSON.stringify(body),
})
if (r.status !== 200) throw new Error('Failed to persist Flow Run: HTTP ' + r.status)
}
async function _schedulePending (run, step, triggerAt) {
const payload = {
doctype: FS_DOCTYPE,
flow_run: run.name,
step_id: step.id,
status: 'pending',
trigger_at: triggerAt,
context_snapshot: JSON.stringify({ step }),
retry_count: 0,
}
const r = await erpFetch(`/api/resource/${ENC_FS}`, {
method: 'POST', body: JSON.stringify(payload),
})
if (r.status !== 200 && r.status !== 201) {
throw new Error('Failed to schedule step: HTTP ' + r.status)
}
log(`[flow] scheduled ${step.id} @ ${triggerAt}${r.data.data?.name}`)
return r.data.data?.name
}
// ───────────────────────────────────────────────────────────────────────────
// Trigger resolution (decides when a step is ready to run)
// ───────────────────────────────────────────────────────────────────────────
/**
* Compute a "trigger fire time" in ISO format for delayed steps.
* Returns null for immediate triggers.
*/
function resolveTriggerAt (step, now = new Date()) {
const t = step.trigger || {}
if (t.type === 'after_delay') {
const hours = Number(t.delay_hours || 0) + Number(t.delay_days || 0) * 24
if (!hours) return null
return new Date(now.getTime() + hours * 3600 * 1000).toISOString()
}
if (t.type === 'on_date' && t.at) {
return new Date(t.at).toISOString()
}
return null
}
/** A step is "ready" when all its depends_on are done + parent branch matches. */
function isStepReady (step, state, def) {
// Already handled?
const s = state[step.id]
if (s && (s.status === STATUS.DONE || s.status === STATUS.SCHEDULED || s.status === STATUS.FAILED)) return false
// All depends_on must be done
for (const dep of step.depends_on || []) {
if (state[dep]?.status !== STATUS.DONE) return false
}
// Parent step must be done (if any), AND branch must match
if (step.parent_id) {
const parentState = state[step.parent_id]
if (parentState?.status !== STATUS.DONE) return false
const parentStep = def.steps.find(s2 => s2.id === step.parent_id)
if (parentStep?.kind === 'condition' || parentStep?.kind === 'switch') {
if (step.branch && parentState.result?.branch !== step.branch) return false
}
}
return true
}
// ───────────────────────────────────────────────────────────────────────────
// Public API
// ───────────────────────────────────────────────────────────────────────────
/**
* Start a new flow run. Creates the Flow Run doc + immediately advances it.
*
* @param {string} templateName e.g. "FT-00005"
* @param {Object} opts
* - doctype, docname Trigger doc (contract, quotation, …)
* - customer Customer docname (optional; derived from doc if absent)
* - variables Runtime bag merged into context
* - triggerEvent For audit (e.g. "on_contract_signed")
* @returns {Object} { run, executed: [...stepIds], scheduled: [...stepIds] }
*/
async function startFlow (templateName, opts = {}) {
const template = await _fetchTemplate(templateName)
if (!template.is_active) {
throw new Error(`Template ${templateName} is inactive`)
}
// Create Flow Run
const runPayload = {
doctype: FR_DOCTYPE,
flow_template: template.name,
template_version: template.version || 1,
status: 'running',
trigger_event: opts.triggerEvent || template.trigger_event || 'manual',
context_doctype: opts.doctype || template.applies_to || '',
context_docname: opts.docname || '',
customer: opts.customer || '',
variables: JSON.stringify(opts.variables || {}),
step_state: JSON.stringify({}),
started_at: new Date().toISOString(),
}
const r = await erpFetch(`/api/resource/${ENC_FR}`, {
method: 'POST', body: JSON.stringify(runPayload),
})
if (r.status !== 200 && r.status !== 201) {
throw new Error('Failed to create Flow Run: HTTP ' + r.status + ' ' + JSON.stringify(r.data).slice(0, 200))
}
const run = r.data.data
log(`[flow] started ${run.name} from ${templateName}`)
// Advance immediately (fires the first wave of ready steps)
const result = await advanceFlow(run.name)
return { run: result.run, ...result }
}
/**
* Evaluate all steps and run those that became ready. Idempotent: can be
* called again after a pending step resolves (via completeStep), after a
* webhook trigger, or from the scheduler.
*
* @param {string} runName
* @returns {Object} { run, executed, scheduled, errors }
*/
async function advanceFlow (runName) {
const run = await _fetchRun(runName)
if (run.status === 'completed' || run.status === 'failed' || run.status === 'cancelled') {
return { run, executed: [], scheduled: [], errors: [], done: true }
}
const template = await _fetchTemplate(run.flow_template)
const def = template.flow_definition
// Resolve trigger context once (lookup doc + customer)
const ctx = await _buildContext(run, template)
const state = run.step_state
const executed = []
const scheduled = []
const errors = []
let mutations = 0
// Linear pass — tree is small, we can re-check after mutations
// This loop continues until no more steps become ready (max 3 waves for safety)
for (let wave = 0; wave < 50; wave++) {
let progressed = false
for (const step of def.steps) {
if (!isStepReady(step, state, def)) continue
const trigType = step.trigger?.type
// Delayed triggers → persist as pending
if (trigType === 'after_delay' || trigType === 'on_date') {
const at = resolveTriggerAt(step)
if (at) {
state[step.id] = { status: STATUS.SCHEDULED, scheduled_for: at }
try { await _schedulePending(run, step, at) }
catch (e) { errors.push({ step: step.id, error: e.message }) }
scheduled.push(step.id)
mutations++
progressed = true
continue
}
}
// Webhook / manual triggers → leave in pending state, caller advances via event
if (trigType === 'on_webhook' || trigType === 'manual') {
state[step.id] = { status: STATUS.PENDING, reason: trigType }
mutations++
continue
}
// Inline execution
state[step.id] = { status: STATUS.RUNNING, started_at: new Date().toISOString() }
const handler = KIND_HANDLERS[step.kind]
if (!handler) {
state[step.id] = { status: STATUS.FAILED, error: `no handler for kind "${step.kind}"`, completed_at: new Date().toISOString() }
errors.push({ step: step.id, error: 'no handler' })
} else {
try {
const r2 = await handler(step, { ...ctx, run })
state[step.id] = {
status: r2.status, result: r2.result, error: r2.error,
started_at: state[step.id].started_at, completed_at: new Date().toISOString(),
}
if (r2.status === STATUS.FAILED) errors.push({ step: step.id, error: r2.error })
executed.push(step.id)
} catch (e) {
state[step.id] = { status: STATUS.FAILED, error: e.message, completed_at: new Date().toISOString() }
errors.push({ step: step.id, error: e.message })
}
}
mutations++
progressed = true
}
if (!progressed) break
}
// Persist new state + compute run-level status
const allDone = def.steps.every(s => {
const st = state[s.id]?.status
return st === STATUS.DONE || st === STATUS.FAILED || st === STATUS.SKIPPED
})
const anyFailed = def.steps.some(s => state[s.id]?.status === STATUS.FAILED)
const patch = { step_state: state }
if (allDone) {
patch.status = anyFailed ? 'failed' : 'completed'
patch.completed_at = new Date().toISOString()
}
if (mutations > 0) {
await _persistRun(run, patch)
Object.assign(run, patch)
}
return { run, executed, scheduled, errors, done: allDone }
}
/**
* Mark a scheduled/manual/webhook step as complete externally. Useful for
* the scheduler, manual "Je confirme" buttons, or webhook callbacks.
*
* @param {string} runName
* @param {string} stepId
* @param {Object} [result]
*/
async function completeStep (runName, stepId, result = {}) {
const run = await _fetchRun(runName)
const state = run.step_state
state[stepId] = {
...(state[stepId] || {}),
status: STATUS.DONE,
result,
completed_at: new Date().toISOString(),
}
await _persistRun(run, { step_state: state })
return advanceFlow(runName)
}
/**
* Build the execution context: pulls in the trigger doc + customer.
* Kept narrow to limit payload size on downstream template renders.
*/
async function _buildContext (run, template) {
const ctx = {
run: { name: run.name, variables: run.variables, step_state: run.step_state },
template: { name: template.name, version: template.version },
variables: run.variables || {},
now: new Date().toISOString(),
}
if (run.context_doctype && run.context_docname) {
try {
const r = await erpFetch(`/api/resource/${encodeURIComponent(run.context_doctype)}/${encodeURIComponent(run.context_docname)}`)
if (r.status === 200) {
ctx.doc = r.data.data
ctx.doctype = run.context_doctype
ctx.docname = run.context_docname
}
} catch (e) { log('[flow] context fetch error:', e.message) }
}
if (run.customer) {
try {
const r = await erpFetch(`/api/resource/Customer/${encodeURIComponent(run.customer)}`)
if (r.status === 200) ctx.customer = r.data.data
} catch (e) { log('[flow] customer fetch error:', e.message) }
} else if (ctx.doc?.customer) {
try {
const r = await erpFetch(`/api/resource/Customer/${encodeURIComponent(ctx.doc.customer)}`)
if (r.status === 200) ctx.customer = r.data.data
} catch { /* noop */ }
}
return ctx
}
// ───────────────────────────────────────────────────────────────────────────
// Trigger helpers — called from event hooks (contracts, payments, …)
// ───────────────────────────────────────────────────────────────────────────
/**
* Find all active templates listening for `eventName` and start them.
* Templates with a trigger_condition must match the provided context
* (simple ==/!= JSON check, expanded later).
*
* @returns {Array} list of { template, run, executed, scheduled } results
*/
async function dispatchEvent (eventName, opts = {}) {
const filters = JSON.stringify([['trigger_event', '=', eventName], ['is_active', '=', 1]])
const r = await erpFetch(`/api/resource/${ENC_FT}?filters=${encodeURIComponent(filters)}&fields=${encodeURIComponent(JSON.stringify(['name', 'template_name', 'trigger_condition']))}&limit_page_length=50`)
if (r.status !== 200) {
log('[flow] dispatchEvent list failed:', r.status)
return []
}
const templates = r.data.data || []
const results = []
for (const t of templates) {
if (t.trigger_condition && !_matchCondition(t.trigger_condition, opts)) continue
try {
const res = await startFlow(t.name, { ...opts, triggerEvent: eventName })
results.push({ template: t.name, ...res })
} catch (e) {
log(`[flow] dispatchEvent "${eventName}" failed for ${t.name}:`, e.message)
results.push({ template: t.name, error: e.message })
}
}
return results
}
/** Minimal condition match (JSON == check). Extend later for full JSONLogic. */
function _matchCondition (condJson, opts) {
if (!condJson) return true
try {
const c = typeof condJson === 'string' ? JSON.parse(condJson) : condJson
if (c['==']) {
const [a, b] = c['==']
const left = a?.var ? getPath(opts, a.var) : a
return String(left) === String(b)
}
return true
} catch { return true }
}
// ───────────────────────────────────────────────────────────────────────────
module.exports = {
startFlow,
advanceFlow,
completeStep,
dispatchEvent,
// for testing
KIND_HANDLERS,
evalCondition,
render,
renderDeep,
isStepReady,
resolveTriggerAt,
STATUS,
}