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:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
@@ -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, {});
|
||||
|
||||
Reference in New Issue
Block a user