screenshot: admin github button (#778)

This commit is contained in:
Paul Nothaft
2026-07-10 09:50:18 +02:00
commit e94e440858
1160 changed files with 291466 additions and 0 deletions
+336
View File
@@ -0,0 +1,336 @@
/**
* Workflow action + condition handlers that touch real picpeak data.
*
* Registered at load time (index.js requires this module). Kept separate from
* registry.js (which holds only primitives) so the I/O-coupled handlers don't
* bloat the pure core.
*
* Email routing rule (locked requirement): INTERNAL/admin mail sends
* immediately; EXTERNAL/customer mail respects the business-hours floor. The
* action sets queueEmail's `respectBusinessHours` from the recipient class.
*
* The create/prepare-document actions (quote/contract/event/gallery/invoice)
* are registered so flows validate, but are intentionally NOT wired to the
* services yet — they record a `skipped` step with a clear reason so the gap
* is observable rather than silent. Wiring is a follow-up commit.
*/
const registry = require('./registry');
// --- Conditions ---
// True once the run's invoice entity is settled (paid_at set, status paid, or
// the cumulative paid amount covers the total).
registry.registerCondition('invoice_paid', async (ctx) => {
const id = ctx.run.entity_id;
if (!id) return false;
const inv = await ctx.db('invoices').where({ id }).first();
if (!inv) return false;
if (inv.paid_at) return true;
if (inv.status === 'paid') return true;
const paid = Number(inv.paid_amount_minor) || 0;
const total = Number(inv.total_amount_minor);
return Number.isFinite(total) && total > 0 && paid >= total;
});
// --- Actions ---
// Queue an email. recipientClass 'admin' (internal) sends immediately;
// anything else (customer/external) respects the business-hours floor.
registry.registerAction('send_email', async (ctx) => {
const cfg = ctx.node.config || {};
if (ctx.vars?.__dryRun) return { dryRun: true, would: 'send_email', recipientClass: cfg.recipientClass || cfg.recipient || 'customer', emailType: cfg.emailType || cfg.template };
const recipientClass = cfg.recipientClass || cfg.recipient || 'customer';
const isInternal = recipientClass === 'admin' || recipientClass === 'internal';
const to = cfg.to
|| ctx.vars[isInternal ? 'adminEmail' : 'customerEmail']
|| ctx.vars.recipientEmail;
if (!to) return { skipped: true, reason: 'no recipient resolved' };
const emailProcessor = require('../emailProcessor');
const eventId = ctx.vars.eventId || null;
const emailType = cfg.emailType || cfg.template || 'workflow_notification';
const emailData = { ...(cfg.emailData || {}), ...(ctx.vars.emailData || {}) };
// INTERNAL/admin = immediate; EXTERNAL/customer = business-hours floor.
const respectBusinessHours = !isInternal;
await emailProcessor.queueEmail(eventId, to, emailType, emailData, { respectBusinessHours });
return { sent_to: to, recipientClass, respectBusinessHours };
});
// Fire the existing admin payment-check email (the dunning gate). Delegates to
// invoiceService.queuePaymentCheckEmail so the proven escalation +
// Mahngebühr / reminder_level state machine (recordPaymentCheckAction) stays
// the single source of truth — the workflow only decides WHEN it fires. This
// is what makes the built-in dunning flow a faithful replacement for the
// hardcoded ladder (paired with the mutual-exclusion guard in runScheduledTasks).
registry.registerAction('queue_payment_check', async (ctx) => {
const id = ctx.run.entity_id;
if (!id) return { skipped: true, reason: 'no invoice entity' };
if (ctx.vars?.__dryRun) return { dryRun: true, would: 'queue_payment_check', invoiceId: id };
await require('../invoiceService').queuePaymentCheckEmail(id);
return { payment_check_queued: id };
});
// After the dunning loop exhausts (e.g. 3 unpaid reminders), consolidate
// everything collections needs into ONE email to the admin: customer data, the
// outstanding total (invoice + late fees paid) and the invoice PDF attached —
// ready to forward to an Inkasso agency / for Betreibung. Internal mail → sent
// immediately. Does NOT touch the invoice.
registry.registerAction('escalate_to_collections', async (ctx) => {
const id = ctx.run.entity_id;
if (!id) return { skipped: true, reason: 'no invoice entity' };
if (ctx.vars?.__dryRun) return { dryRun: true, would: 'escalate_to_collections', invoiceId: id };
const { db } = ctx;
const invoice = await db('invoices').where({ id }).first();
if (!invoice) return { skipped: true, reason: 'invoice not found' };
const customer = invoice.customer_account_id
? await db('customer_accounts').where({ id: invoice.customer_account_id }).first()
: null;
const profile = await db('business_profile').where({ id: 1 }).first();
const adminEmail = ctx.vars?.adminEmail || profile?.email || null;
if (!adminEmail) return { skipped: true, reason: 'no admin email' };
const currency = invoice.currency || 'CHF';
const fmt = (m) => `${currency} ${(Number(m || 0) / 100).toFixed(2)}`;
const total = Number(invoice.total_amount_minor || 0);
const fee = Number(invoice.late_fee_amount_minor || 0);
const paid = Number(invoice.paid_amount_minor || 0);
const outstanding = Math.max(0, total + fee - paid);
const address = [customer?.address, customer?.postal_code, customer?.city, customer?.country_name]
.filter(Boolean).join(', ');
const attachments = [];
try {
const fs = require('fs');
if (invoice.pdf_path && fs.existsSync(invoice.pdf_path)) {
attachments.push({ filename: `${invoice.invoice_number}.pdf`, contentPath: invoice.pdf_path, contentType: 'application/pdf' });
}
} catch (_) { /* attachment is best-effort */ }
await require('../emailProcessor').queueEmail(invoice.event_id || null, adminEmail, 'invoice_collections_handoff', {
invoice_number: invoice.invoice_number,
customer_name: customer?.display_name || customer?.email || '—',
customer_email: customer?.email || '',
customer_address: address,
event_name: invoice.event_name || '',
original_amount: fmt(total),
late_fee_amount: fee ? fmt(fee) : '',
paid_amount: fmt(paid),
outstanding_amount: fmt(outstanding),
due_date: invoice.due_date ? String(invoice.due_date).slice(0, 10) : '',
reminder_level: invoice.reminder_level || 0,
attachments,
}, { respectBusinessHours: false }); // internal/admin → immediate
return { collections_handoff_to: adminEmail, outstanding };
});
// --- Gallery / pre-event notification actions (cutover) ---
//
// These DELEGATE to the existing service send functions, so the engine path is
// byte-identical to the legacy hourly checker/pass it replaces (same templates,
// recipients, variables, dedup). The legacy path stands down when the matching
// built-in flow is enabled (isBuiltinFlowActive guard), so exactly one email
// goes out.
// Send the gallery expiration-warning email for the run's event entity.
registry.registerAction('notify_gallery_expiring', async (ctx) => {
const id = ctx.run.entity_id;
if (!id) return { skipped: true, reason: 'no event entity' };
if (ctx.vars?.__dryRun) return { dryRun: true, would: 'notify_gallery_expiring', eventId: id };
const event = await ctx.db('events').where({ id }).first();
if (!event) return { skipped: true, reason: 'event not found' };
await require('../expirationChecker').queueExpirationWarning(event);
return { warning_queued: id };
});
// Send the gallery_expired email(s) for the run's event entity.
registry.registerAction('notify_gallery_expired', async (ctx) => {
const id = ctx.run.entity_id;
if (!id) return { skipped: true, reason: 'no event entity' };
if (ctx.vars?.__dryRun) return { dryRun: true, would: 'notify_gallery_expired', eventId: id };
const event = await ctx.db('events').where({ id }).first();
if (!event) return { skipped: true, reason: 'event not found' };
await require('../expirationChecker').sendGalleryExpiredEmails(event);
return { expired_email_queued: id };
});
// Send the pre-event customer reminder for the run's event entity. Delegates to
// eventReminderService so per-event overrides + sent_at idempotency are honoured.
registry.registerAction('notify_pre_event', async (ctx) => {
const id = ctx.run.entity_id;
if (!id) return { skipped: true, reason: 'no event entity' };
// The template GROUP is chosen on THIS block (config.templateGroup, e.g.
// 'event_reminder'); the exact template is still auto-picked by event type
// within that group. Blank → the default group.
const templateGroup = ctx.node.config?.templateGroup || null;
if (ctx.vars?.__dryRun) return { dryRun: true, would: 'notify_pre_event', eventId: id, templateGroup };
const res = await require('../eventReminderService').sendReminderForEvent(id, { templateGroup });
return res;
});
// Call a webhook (the `webhook` node type + the "Call a webhook" action both
// resolve here). The flow author picks a CONFIGURED webhook subscription
// (config.webhookId, managed in Settings → Webhooks); this enqueues a real
// delivery for it, so it rides the same worker pipeline as every other webhook:
// per-delivery SSRF re-validation (validateExternalUrl / GHSA-wmjx-pc37-272r),
// HMAC signing with the subscription's secret, retries/backoff, and the audit
// log — all inherited, nothing reimplemented. Best-effort: an unset / missing /
// inactive webhook records an observable skipped step.
registry.registerAction('webhook', async (ctx) => {
const webhookId = ctx.node.config?.webhookId ? Number(ctx.node.config.webhookId) : null;
if (!webhookId) return { skipped: true, reason: 'no webhook selected (pick one in Settings → Webhooks)' };
if (ctx.vars?.__dryRun) return { dryRun: true, would: 'webhook', webhookId };
const eventType = `workflow.${ctx.run.trigger_event || 'webhook'}`;
const res = await require('../webhookService').enqueueForWebhook(webhookId, eventType, {
workflow: { id: ctx.run.workflow_id, version: ctx.run.version },
run: {
id: ctx.run.id,
trigger_event: ctx.run.trigger_event,
entity_type: ctx.run.entity_type,
entity_id: ctx.run.entity_id,
},
vars: ctx.vars || {},
});
return res.enqueued
? { webhook_enqueued: res.webhookId, deliveryId: res.deliveryId }
: { skipped: true, reason: res.reason };
});
// --- Booking document actions (draft-seam cutover) ---
//
// The booking flows trigger on quote.accepted, so the run entity is the QUOTE.
// prepare_* create DRAFT documents (idempotent, reusing the proven converters)
// and stash the created ids in the run context; send_document then dispatches
// the matching draft. Flows run system-side, so the actor is resolved from the
// quote's creator (else the workflow's creator, else the first admin).
async function resolveActor(ctx) {
try {
if (ctx.run.entity_type === 'quote' && ctx.run.entity_id) {
const q = await ctx.db('quotes').where({ id: ctx.run.entity_id }).first('created_by_admin_id');
if (q?.created_by_admin_id) return q.created_by_admin_id;
}
const wf = await ctx.db('workflows').where({ id: ctx.run.workflow_id }).first('created_by');
if (wf?.created_by) return wf.created_by;
const admin = await ctx.db('admin_users').orderBy('id', 'asc').first('id');
return admin?.id || null;
} catch (_) { return null; }
}
// Prepare a DRAFT contract from the accepted quote (idempotent via the quote's
// converted_contract_id back-pointer).
registry.registerAction('prepare_contract', async (ctx) => {
const quoteId = ctx.run.entity_id;
if (ctx.run.entity_type !== 'quote' || !quoteId) return { skipped: true, reason: 'prepare_contract needs a quote entity' };
if (ctx.vars?.__dryRun) return { dryRun: true, would: 'prepare_contract', quoteId };
const adminId = await resolveActor(ctx);
const res = await require('../contractService').createFromQuote(quoteId, adminId);
ctx.vars.preparedContractId = res.contractId;
return { contract_prepared: res.contractId, alreadyConverted: !!res.alreadyConverted };
});
// Create a DRAFT event/gallery from the accepted quote. convertToEvent creates
// the event as is_draft=true AND (unless skipInvoices) schedules its invoices —
// on HOLD here so they wait for the review gate + send_document. The created
// invoice ids are stashed so the flow's downstream prepare_invoice ADOPTS them
// (instead of double-creating, which would also throw ALREADY_CONVERTED_TO_EVENT).
// Shared by prepare_event, prepare_gallery (alias — a gallery IS an event in
// picpeak), and reserve_date (skipInvoices: a pure date hold, no money docs).
async function doPrepareEvent(ctx, label, { skipInvoices = false } = {}) {
const quoteId = ctx.run.entity_id;
if (ctx.run.entity_type !== 'quote' || !quoteId) return { skipped: true, reason: `${label} needs a quote entity` };
if (ctx.vars?.__dryRun) return { dryRun: true, would: label, quoteId };
if (ctx.vars.preparedEventId) {
return { already: true, eventId: ctx.vars.preparedEventId, invoiceIds: ctx.vars.preparedInvoiceIds || [] };
}
const adminId = await resolveActor(ctx);
const res = await require('../quoteService').convertToEvent(quoteId, adminId, { hold: true, skipInvoices });
ctx.vars.preparedEventId = res.eventId;
// The flow's prepare_invoice short-circuits on a populated preparedInvoiceIds,
// so the event's held invoices flow straight through to send_document.
ctx.vars.preparedInvoiceIds = res.invoiceIds || [];
return { event_prepared: res.eventId, invoiceIds: ctx.vars.preparedInvoiceIds, alreadyConverted: !!res.alreadyConverted };
}
registry.registerAction('prepare_event', (ctx) => doPrepareEvent(ctx, 'prepare_event'));
// A gallery IS an event in picpeak — same draft-seam behaviour.
registry.registerAction('prepare_gallery', (ctx) => doPrepareEvent(ctx, 'prepare_gallery'));
// Reserve the date only: create the draft event as a pure calendar hold with NO
// invoices. A flow can invoice later (or never).
registry.registerAction('reserve_date', (ctx) => doPrepareEvent(ctx, 'reserve_date', { skipInvoices: true }));
// Create a DRAFT quote. From a quote entity (e.g. quote.declined → re-quote) it
// duplicates that quote; from a customer entity (customer.created) it opens a
// blank draft quote for them. Idempotent via ctx.vars.preparedQuoteId.
registry.registerAction('prepare_quote', async (ctx) => {
if (ctx.vars?.__dryRun) return { dryRun: true, would: 'prepare_quote', entity: ctx.run.entity_type };
if (ctx.vars.preparedQuoteId) return { already: true, quoteId: ctx.vars.preparedQuoteId };
const adminId = await resolveActor(ctx);
const quoteService = require('../quoteService');
let quoteId;
if (ctx.run.entity_type === 'quote' && ctx.run.entity_id) {
quoteId = await quoteService.duplicateQuote(ctx.run.entity_id, adminId);
} else if (ctx.run.entity_type === 'customer' && ctx.run.entity_id) {
quoteId = await quoteService.createQuote({ customerAccountId: ctx.run.entity_id }, adminId);
} else {
return { skipped: true, reason: 'prepare_quote needs a quote or customer entity' };
}
ctx.vars.preparedQuoteId = quoteId;
return { quote_prepared: quoteId };
});
// Prepare DRAFT invoice(s) from the accepted quote — created on HOLD (no
// scheduled_send_at) so the scheduler won't auto-send before the review gate.
// When prepare_event already ran in this flow, the event's held invoices are
// already in ctx.vars.preparedInvoiceIds and this adopts them (no double-create).
registry.registerAction('prepare_invoice', async (ctx) => {
const quoteId = ctx.run.entity_id;
if (ctx.run.entity_type !== 'quote' || !quoteId) return { skipped: true, reason: 'prepare_invoice needs a quote entity' };
if (ctx.vars?.__dryRun) return { dryRun: true, would: 'prepare_invoice', quoteId };
if (Array.isArray(ctx.vars.preparedInvoiceIds) && ctx.vars.preparedInvoiceIds.length) {
return { already: true, invoiceIds: ctx.vars.preparedInvoiceIds };
}
const adminId = await resolveActor(ctx);
let invoiceIds;
try {
const res = await require('../quoteService').convertToInvoiceOnly(quoteId, adminId, { draft: true });
invoiceIds = res.invoiceIds || [];
} catch (err) {
// Crash-recovery re-run: the quote may already be 'converted' (convert
// throws). Recover the drafts by the quote's deal_uuid so we don't lose them.
const quote = await ctx.db('quotes').where({ id: quoteId }).first('deal_uuid');
invoiceIds = quote?.deal_uuid
? (await ctx.db('invoices').where({ deal_uuid: quote.deal_uuid }).select('id')).map((r) => r.id)
: [];
if (!invoiceIds.length) throw err;
}
ctx.vars.preparedInvoiceIds = invoiceIds;
return { invoice_prepared: invoiceIds };
});
// Send a prepared draft document (config.document = 'invoice' | 'contract').
registry.registerAction('send_document', async (ctx) => {
const doc = ctx.node.config?.document || 'invoice';
if (ctx.vars?.__dryRun) return { dryRun: true, would: 'send_document', document: doc };
const adminId = await resolveActor(ctx);
if (doc === 'invoice') {
const ids = ctx.vars.preparedInvoiceIds || [];
if (!ids.length) return { skipped: true, reason: 'no prepared invoice to send' };
const invoiceService = require('../invoiceService');
let sent = 0;
for (const id of ids) { await invoiceService.sendInvoice(id, adminId); sent += 1; }
return { invoices_sent: sent };
}
if (doc === 'contract') {
const cid = ctx.vars.preparedContractId;
if (!cid) return { skipped: true, reason: 'no prepared contract to send' };
await require('../contractService').sendContract(cid, adminId);
return { contract_sent: cid };
}
return { skipped: true, reason: `send_document for '${doc}' not implemented yet` };
});
module.exports = {};
+140
View File
@@ -0,0 +1,140 @@
/**
* Workflow approval gates — the human-in-the-loop step.
*
* When the engine hits a `gate` node it calls the registered `gate_setup`
* action, which creates a workflow_approvals row (single-use token stored as a
* SHA-256 hash) and emails the admin a confirm/deny link. The run stays
* `waiting` until the admin acts — via the email link (actByToken) or the
* webview pending-approvals inbox (actById) — at which point the run resumes
* down the matching confirm/deny edge.
*
* Internal/admin mail → sent immediately (respectBusinessHours: false).
*/
const crypto = require('crypto');
const { db } = require('../../database/db');
const logger = require('../../utils/logger');
const registry = require('./registry');
const engine = require('./engine');
function hashToken(raw) {
return crypto.createHash('sha256').update(String(raw)).digest('hex');
}
/**
* gate_setup action — create the approval + email the admin. Called by the
* engine when a gate node is reached. Best-effort on the email; the approval
* row (and thus the inbox path) is always created.
*/
async function createApproval(ctx) {
const { run, node } = ctx;
const cfg = node.config || {};
const raw = crypto.randomBytes(32).toString('hex');
const expiresAt = cfg.timeoutDays
? new Date(Date.now() + Number(cfg.timeoutDays) * 86400000).toISOString()
: null;
await db('workflow_approvals').insert({
run_id: run.id,
node_key: node.node_key,
type: cfg.type || 'payment_confirm',
status: 'pending',
token_hash: hashToken(raw),
payload: JSON.stringify({ prompt: cfg.prompt || null, vars: ctx.vars || {} }),
expires_at: expiresAt,
created_at: db.fn.now(),
});
try {
const { getFrontendBaseUrl } = require('../../utils/frontendUrl');
const base = (await getFrontendBaseUrl()) || '';
const confirmUrl = `${base}/api/public/workflow-approvals/${raw}/confirm`;
const denyUrl = `${base}/api/public/workflow-approvals/${raw}/deny`;
let adminEmail = ctx.vars?.adminEmail || null;
if (!adminEmail) {
const bp = await db('business_profile').where({ id: 1 }).first('email');
adminEmail = bp?.email || null;
}
if (adminEmail) {
const emailProcessor = require('../emailProcessor');
await emailProcessor.queueEmail(
ctx.vars?.eventId || null,
adminEmail,
cfg.emailType || 'workflow_approval',
{
prompt: cfg.prompt || 'A workflow needs your confirmation.',
confirm_url: confirmUrl,
deny_url: denyUrl,
...(ctx.vars?.emailData || {}),
},
{ respectBusinessHours: false }, // internal/admin → immediate
);
} else {
logger.warn('[workflow] approval created but no admin email to notify', { runId: run.id });
}
} catch (e) {
logger.error('[workflow] approval email failed', { runId: run.id, error: e.message });
}
return { approval: true };
}
registry.registerAction('gate_setup', createApproval);
async function finalizeApproval(approval, decision, actorPatch) {
if (!approval) return { ok: false, reason: 'not_found' };
if (approval.status !== 'pending') return { ok: true, already: true, status: approval.status };
if (approval.expires_at && new Date(approval.expires_at).getTime() < Date.now()) {
await db('workflow_approvals').where({ id: approval.id }).update({ status: 'expired' });
return { ok: false, reason: 'expired' };
}
const status = decision === 'confirm' ? 'confirmed' : 'denied';
await db('workflow_approvals').where({ id: approval.id })
.update({ status, acted_at: db.fn.now(), ...actorPatch });
// Resume down the matching edge (handles 'confirm' | 'deny').
await engine.resumeRun(approval.run_id, { decisionHandle: decision });
return { ok: true, status };
}
/** Act on an approval via the emailed single-use token. */
async function actByToken(rawToken, decision) {
const approval = await db('workflow_approvals').where({ token_hash: hashToken(rawToken) }).first();
return finalizeApproval(approval, decision, { acted_via: 'email' });
}
/**
* Read-only lookup for the emailed token — used to render the confirm/deny
* interstitial WITHOUT mutating state (so email-client prefetchers can't
* advance the gate). Never resumes the run.
*/
async function peekApproval(rawToken) {
const a = await db('workflow_approvals').where({ token_hash: hashToken(rawToken) }).first();
if (!a) return { found: false };
let prompt = null;
try { prompt = (JSON.parse(a.payload || '{}') || {}).prompt || null; } catch (_) { /* ignore */ }
const expired = !!(a.expires_at && new Date(a.expires_at).getTime() < Date.now());
return { found: true, status: a.status, prompt, expired };
}
/** Act on an approval from the admin webview inbox. */
async function actById(id, decision, adminId) {
const approval = await db('workflow_approvals').where({ id }).first();
return finalizeApproval(approval, decision, { acted_via: 'web', acted_by: adminId || null });
}
/** Pending approvals for the webview inbox, newest first, with workflow name. */
async function listPending(limit = 100) {
return db('workflow_approvals as a')
.join('workflow_runs as r', 'r.id', 'a.run_id')
.join('workflows as w', 'w.id', 'r.workflow_id')
.where('a.status', 'pending')
.select(
'a.id', 'a.type', 'a.payload', 'a.created_at', 'a.expires_at',
'r.id as run_id', 'r.entity_type', 'r.entity_id',
'w.id as workflow_id', 'w.name as workflow_name',
)
.orderBy('a.created_at', 'desc')
.limit(limit);
}
module.exports = { hashToken, createApproval, actByToken, actById, listPending, peekApproval };
+561
View File
@@ -0,0 +1,561 @@
/**
* 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,
};
+22
View File
@@ -0,0 +1,22 @@
/**
* Workflow engine public surface.
*
* const { emitWorkflowEvent } = require('./services/workflows');
*
* `engine` holds the executor (start/advance/resume), `registry` the catalog
* of conditions/actions. Action/condition handler modules require `registry`
* and call registerAction/registerCondition at load time.
*/
const engine = require('./engine');
const registry = require('./registry');
// Side-effect import: registers the data-touching action/condition handlers
// (send_email, invoice_paid, prepare_* document actions) onto the registry.
require('./actions');
// Registers the gate_setup action + exposes approval helpers.
const approvals = require('./approvals');
module.exports = {
...engine,
...approvals,
registry,
};
@@ -0,0 +1,61 @@
/**
* Workflow registry — the curated catalog of CONDITIONS and ACTIONS the engine
* can run. Node `config.condition` / `config.action` keys map to handlers here.
*
* Handlers are async `(ctx) => result`, where ctx = { run, node, vars, db,
* logger }. `vars` is the run's mutable context bag (loop counters, accumulated
* values, the trigger payload). A condition returns a boolean; an action may
* return `{ set: {...} }` to merge values back into `vars`.
*
* Keep handlers curated and typed — this is NOT arbitrary code execution. New
* triggers/actions register here; the canvas palette is derived from these.
*/
const conditions = new Map();
const actions = new Map();
function registerCondition(key, fn) { conditions.set(key, fn); }
function registerAction(key, fn) { actions.set(key, fn); }
function getCondition(key) { return conditions.get(key); }
function getAction(key) { return actions.get(key); }
function listConditions() { return Array.from(conditions.keys()); }
function listActions() { return Array.from(actions.keys()); }
// --- Primitive conditions ---
registerCondition('always', async () => true);
registerCondition('never', async () => false);
// Generic field/op/value compare against the run's `vars` bag.
registerCondition('expr', async (ctx) => {
const { field, op = 'truthy', value } = ctx.node.config || {};
const actual = field != null ? ctx.vars[field] : undefined;
switch (op) {
case 'eq': return actual == value; // eslint-disable-line eqeqeq
case 'neq': return actual != value; // eslint-disable-line eqeqeq
case 'gt': return Number(actual) > Number(value);
case 'gte': return Number(actual) >= Number(value);
case 'lt': return Number(actual) < Number(value);
case 'lte': return Number(actual) <= Number(value);
case 'falsy': return !actual;
case 'truthy':
default: return Boolean(actual);
}
});
// --- Primitive actions ---
registerAction('noop', async () => ({}));
registerAction('log', async (ctx) => {
ctx.logger?.info?.('[workflow] log action', { runId: ctx.run.id, message: ctx.node.config?.message });
return { logged: true };
});
// Merge a static object into the run context (handy for tests + seeding flags).
registerAction('set_context', async (ctx) => ({ set: ctx.node.config?.set || {} }));
module.exports = {
registerCondition,
registerAction,
getCondition,
getAction,
listConditions,
listActions,
conditions,
actions,
};