feat(workflows): per-quote booking-workflow picker + quote→invoice (no gallery) built-in

A quote can now choose which flow runs on acceptance instead of every enabled
quote.accepted flow firing. Migration 147 adds quotes.booking_workflow_id; the
editor shows a "Booking workflow (on acceptance)" dropdown listing the
quote.accepted flows (workflow-engine flag only); emitQuoteEvent passes it as
the new emitWorkflowEvent targetWorkflowId so ONLY the picked flow runs (still
gated on enabled + trigger match → a disabled/None selection runs nothing).

Adds the booking_invoice_only built-in (quote.accepted → prepare invoice →
review gate → send; no event/gallery, no wait), the variant requested for
shoots billed without an online gallery. Disabled stub like the other booking
flows until the prepare_*/send_document cutover.

Tests: targetWorkflowId runs only the selected flow; invoice-only built-in has
no wait/prepare_event.
This commit is contained in:
Luca
2026-06-23 23:15:31 +02:00
parent eec262b0a7
commit d14f1d850c
10 changed files with 170 additions and 2 deletions
@@ -305,6 +305,15 @@ describe('workflow engine', () => {
const expiredNodes = await db('workflow_nodes').where({ workflow_id: expired.id, version: expired.version });
expect(expiredNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_gallery_expired')).toBe(true);
// Invoice-only booking variant (quote → invoice, no gallery).
const invoiceOnly = await db('workflows').where({ builtin_key: 'booking_invoice_only' }).first();
expect(invoiceOnly).toBeTruthy();
expect(!!invoiceOnly.enabled).toBe(false);
expect(invoiceOnly.trigger_type).toBe('quote.accepted');
const ioNodes = await db('workflow_nodes').where({ workflow_id: invoiceOnly.id, version: invoiceOnly.version });
expect(ioNodes.some((n) => n.type === 'wait')).toBe(false); // no event wait — sends on approval
expect(ioNodes.some((n) => JSON.parse(n.config || '{}').action === 'prepare_event')).toBe(false); // no gallery
const bookingFull = await db('workflows').where({ builtin_key: 'booking_full' }).first();
expect(bookingFull).toBeTruthy();
expect(!!bookingFull.enabled).toBe(false); // illustrative/stub — stays disabled
@@ -390,6 +399,27 @@ describe('workflow engine', () => {
expect(res.sent).toBe(0);
});
test('targetWorkflowId runs only the selected flow, not every matching one', async () => {
// Two enabled flows on the same trigger — the quote picks one.
const chosen = await makeWorkflow({
trigger: 'pick.event', enabled: true,
nodes: [{ key: 'c1', type: 'trigger' }, { key: 'c2', type: 'action', config: { action: 'noop' } }],
edges: [{ from: 'c1', to: 'c2' }],
});
const other = await makeWorkflow({
trigger: 'pick.event', enabled: true,
nodes: [{ key: 'o1', type: 'trigger' }, { key: 'o2', type: 'action', config: { action: 'noop' } }],
edges: [{ from: 'o1', to: 'o2' }],
});
const runIds = await engine.emitWorkflowEvent('pick.event', { entityType: 'quote', entityId: 99, targetWorkflowId: chosen });
expect(runIds.length).toBe(1);
const chosenRuns = await db('workflow_runs').where({ workflow_id: chosen, entity_id: 99 });
const otherRuns = await db('workflow_runs').where({ workflow_id: other, entity_id: 99 });
expect(chosenRuns.length).toBe(1); // only the selected flow ran
expect(otherRuns.length).toBe(0); // the other matching flow did NOT
});
test('admin confirms a gate early; the following wait holds dispatch until its date', async () => {
// The booking pattern: prepare → REVIEW GATE → WAIT(event date) → send. The
// admin can approve at the gate whenever; the run then parks at the wait and
@@ -0,0 +1,25 @@
/**
* Migration 147: let a quote pick the booking workflow it runs on acceptance.
*
* Today quote.accepted fans out to every enabled flow with that trigger. This
* column lets the admin choose ONE workflow per quote (e.g. "with contract" vs
* "invoice only, no gallery"); emitQuoteEvent passes it as targetWorkflowId so
* only the chosen flow runs. Plain nullable integer (not a hard FK) — the emit
* re-checks the workflow exists + is enabled + matches the trigger at fire time,
* so a deleted/disabled selection just runs nothing.
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('quotes'))) return;
if (!(await knex.schema.hasColumn('quotes', 'booking_workflow_id'))) {
await knex.schema.alterTable('quotes', (t) => {
t.integer('booking_workflow_id');
});
}
};
exports.down = async function (knex) {
if (!(await knex.schema.hasTable('quotes'))) return;
if (await knex.schema.hasColumn('quotes', 'booking_workflow_id')) {
await knex.schema.alterTable('quotes', (t) => t.dropColumn('booking_workflow_id'));
}
};
+2
View File
@@ -87,6 +87,7 @@ function transformQuote(q) {
eventName: q.event_name,
eventDate: q.event_date,
eventType: q.event_type ?? null,
bookingWorkflowId: q.booking_workflow_id ?? null,
eventTimeStart: q.event_time_start,
eventTimeEnd: q.event_time_end,
expectedDurationHours: q.expected_duration_hours == null ? null : Number(q.expected_duration_hours),
@@ -214,6 +215,7 @@ function mapPayloadToService(body) {
language: 'language', currency: 'currency',
issueDate: 'issueDate', validUntil: 'validUntil',
eventName: 'eventName', eventDate: 'eventDate', eventType: 'eventType',
bookingWorkflowId: 'bookingWorkflowId',
eventTimeStart: 'eventTimeStart', eventTimeEnd: 'eventTimeEnd',
expectedDurationHours: 'expectedDurationHours',
paymentTermTemplateId: 'paymentTermTemplateId',
+37
View File
@@ -130,6 +130,29 @@ function buildBookingSimpleGraph() {
return { nodes, edges };
}
// Booking — quote accepted → prepare invoice → admin review gate → send. No
// event/gallery and no wait: the invoice goes out as soon as the admin approves
// it. For shoots billed without a delivered online gallery. Same stub caveat as
// the other booking flows (prepare_invoice/send_document not yet wired).
function buildBookingInvoiceOnlyGraph() {
const nodes = [
{ node_key: 't', type: 'trigger', config: {}, pos_x: 320, pos_y: 0 },
{ node_key: 'prepInvoice', type: 'action', config: { action: 'prepare_invoice' }, pos_x: 320, pos_y: 110 },
{ node_key: 'reviewInvoice', type: 'gate', config: { label: 'Review invoice before sending' }, pos_x: 320, pos_y: 220 },
{ node_key: 'sendInvoice', type: 'action', config: { action: 'send_document', document: 'invoice', recipient: 'customer' }, pos_x: 320, pos_y: 330 },
{ node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 320, pos_y: 440 },
{ node_key: 'cancelInvoice', type: 'action', config: { action: 'noop' }, pos_x: 620, pos_y: 220 },
];
const edges = [
{ from_node: 't', to_node: 'prepInvoice' },
{ from_node: 'prepInvoice', to_node: 'reviewInvoice' },
{ from_node: 'reviewInvoice', from_handle: 'confirm', to_node: 'sendInvoice' },
{ from_node: 'reviewInvoice', from_handle: 'deny', to_node: 'cancelInvoice' },
{ from_node: 'sendInvoice', to_node: 'done' },
];
return { nodes, edges };
}
// Pre-event reminder — fired by the scheduler at event_date daysBefore (see
// emitDueEventReminders). The notify_pre_event action DELEGATES to
// eventReminderService.sendReminderForEvent, so the email is byte-identical to
@@ -290,6 +313,20 @@ const BUILTINS = [
+ 'full booking flow; disabled by default.',
build: async () => buildBookingSimpleGraph(),
},
{
key: 'booking_invoice_only',
version: 1,
enabled: false,
name: 'Booking — quote → invoice, no gallery (built-in)',
trigger_type: 'quote.accepted',
trigger_config: {},
description:
'For shoots billed without an online gallery: on quote acceptance prepare the invoice, the '
+ 'admin reviews + approves it, and it is sent right away (no event/gallery, no wait). Pick '
+ 'this flow per quote via the booking-workflow selector. Same review-before-send rule and '
+ 'stub caveat as the other booking flows; disabled by default.',
build: async () => buildBookingInvoiceOnlyGraph(),
},
];
let booted = false;
+17
View File
@@ -587,6 +587,10 @@ async function createQuote(payload, adminId) {
if (payload.eventType !== undefined && await hasColumnCached('quotes', 'event_type')) {
row.event_type = payload.eventType ? String(payload.eventType).slice(0, 64) : null;
}
// Migration 147 — the booking workflow this quote runs on acceptance.
if (payload.bookingWorkflowId !== undefined && await hasColumnCached('quotes', 'booking_workflow_id')) {
row.booking_workflow_id = payload.bookingWorkflowId || null;
}
const inserted = await trx('quotes').insert(row).returning('id');
const quoteId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
@@ -719,6 +723,10 @@ async function updateQuote(id, payload, adminId) {
if (Object.prototype.hasOwnProperty.call(payload, 'eventType') && await hasColumnCached('quotes', 'event_type')) {
updates.event_type = payload.eventType ? String(payload.eventType).slice(0, 64) : null;
}
// Migration 147 — selected booking workflow.
if (Object.prototype.hasOwnProperty.call(payload, 'bookingWorkflowId') && await hasColumnCached('quotes', 'booking_workflow_id')) {
updates.booking_workflow_id = payload.bookingWorkflowId || null;
}
await trx('quotes').where({ id }).update(updates);
// When linked to a project, cascade across the deal lineage so the linked
@@ -1067,9 +1075,16 @@ async function emitQuoteEvent(quote, status) {
const c = await db('customer_accounts').where({ id: quote.customer_account_id }).first();
customerEmail = c?.email || null;
}
// On acceptance, if the admin picked a booking workflow on the quote, run
// ONLY that flow (instead of fanning out to every enabled quote.accepted
// flow). Other statuses keep the normal fan-out.
const targetWorkflowId = (status === 'accepted' && quote.booking_workflow_id)
? quote.booking_workflow_id
: null;
await require('./workflows').emitWorkflowEvent(`quote.${status}`, {
entityType: 'quote',
entityId: quote.id,
targetWorkflowId,
payload: {
quoteId: quote.id,
quoteNumber: quote.quote_number,
@@ -1077,7 +1092,9 @@ async function emitQuoteEvent(quote, status) {
customerEmail,
eventName: quote.event_name || null,
eventDate: quote.event_date || null,
eventType: quote.event_type || null,
totalMinor: quote.total_amount_minor ?? null,
bookingWorkflowId: quote.booking_workflow_id || null,
},
});
} catch (_) { /* best-effort */ }
+8 -2
View File
@@ -240,7 +240,7 @@ async function resumeRun(runId, { decisionHandle = null } = {}) {
* 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 = {} } = {}) {
async function emitWorkflowEvent(triggerType, { entityType = null, entityId = null, payload = {}, targetWorkflowId = null } = {}) {
try {
const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag');
let enabled = false;
@@ -250,7 +250,13 @@ async function emitWorkflowEvent(triggerType, { entityType = null, entityId = nu
}
if (!enabled) return [];
const workflows = await db('workflows').where({ enabled: true, trigger_type: triggerType });
// 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, {});