screenshot: admin github button (#778)
This commit is contained in:
@@ -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 = {};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user