feat(workflows): invoice prepared+approved early, dispatch waits; daysBefore in editor; dashboard approvals

- Booking built-ins reordered: prepare the invoice EARLY (admin adjusts line
  items), admin approves at the review gate whenever, then the wait holds
  dispatch until the event date and it sends itself. prepInvoice → reviewInvoice
  → waitEvent → sendInvoice (both booking_full and booking_simple; v3).
- Flow editor now reads/edits/saves trigger_config; the pre-event "days before
  event" lead time is editable in the canvas toolbar (was only in settings,
  which the cutover removed — closing that gap).
- Dashboard: pending-approvals card under "Events Expiring Soon" (workflows flag
  + non-empty only), with inline Confirm/Deny.

Confirms the design: a gate's confirm edge can feed a wait, so an admin OK
before the event parks the run at the wait and the scheduler dispatches on the
date. New test covers confirm-early-then-wait-dispatches.
This commit is contained in:
Luca
2026-06-23 16:11:18 +02:00
parent 0b6c33e59a
commit 182e655fcf
6 changed files with 170 additions and 30 deletions
@@ -315,15 +315,19 @@ describe('workflow engine', () => {
const fullGateKeys = fullNodes.filter((n) => n.type === 'gate').map((n) => n.node_key);
expect(fullGateKeys).toEqual(expect.arrayContaining(['reviewContract', 'reviewInvoice']));
const fullEdges = await db('workflow_edges').where({ workflow_id: bookingFull.id, version: bookingFull.version });
// reviewContract --confirm--> sendContract ; reviewInvoice --confirm--> sendInvoice
// reviewContract --confirm--> sendContract. The invoice is prepared + approved
// EARLY; reviewInvoice --confirm--> waitEvent, and the wait --> sendInvoice, so
// dispatch is held until the event date after the admin's early OK.
expect(fullEdges.some((e) => e.from_node === 'reviewContract' && e.from_handle === 'confirm' && e.to_node === 'sendContract')).toBe(true);
expect(fullEdges.some((e) => e.from_node === 'reviewInvoice' && e.from_handle === 'confirm' && e.to_node === 'sendInvoice')).toBe(true);
expect(fullEdges.some((e) => e.from_node === 'reviewInvoice' && e.from_handle === 'confirm' && e.to_node === 'waitEvent')).toBe(true);
expect(fullEdges.some((e) => e.from_node === 'waitEvent' && e.to_node === 'sendInvoice')).toBe(true);
const bookingSimple = await db('workflows').where({ builtin_key: 'booking_simple' }).first();
expect(bookingSimple).toBeTruthy();
expect(bookingSimple.trigger_type).toBe('quote.accepted');
const simpleEdges = await db('workflow_edges').where({ workflow_id: bookingSimple.id, version: bookingSimple.version });
expect(simpleEdges.some((e) => e.from_node === 'reviewInvoice' && e.from_handle === 'confirm' && e.to_node === 'sendInvoice')).toBe(true);
expect(simpleEdges.some((e) => e.from_node === 'reviewInvoice' && e.from_handle === 'confirm' && e.to_node === 'waitEvent')).toBe(true);
expect(simpleEdges.some((e) => e.from_node === 'waitEvent' && e.to_node === 'sendInvoice')).toBe(true);
const preEvent = await db('workflows').where({ builtin_key: 'pre_event_email' }).first();
expect(preEvent).toBeTruthy();
@@ -386,6 +390,43 @@ describe('workflow engine', () => {
expect(res.sent).toBe(0);
});
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
// the scheduler dispatches when the date arrives.
const wfId = await makeWorkflow({
trigger: 'gatewait.event',
nodes: [
{ key: 'g0', type: 'trigger' },
{ key: 'g1', type: 'gate', config: { prompt: 'Approve invoice?' } },
{ key: 'g2', type: 'wait', config: { delayDays: 5 } },
{ key: 'g3', type: 'action', config: { action: 'noop' } },
],
edges: [
{ from: 'g0', to: 'g1' },
{ from: 'g1', handle: 'confirm', to: 'g2' },
{ from: 'g2', to: 'g3' },
],
});
const [runId] = await engine.emitWorkflowEvent('gatewait.event', { entityType: 'invoice', entityId: 7 });
let run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('waiting');
expect(run.current_node).toBe('g1'); // parked at the review gate
// Admin confirms EARLY (before the wait date).
const approval = await db('workflow_approvals').where({ run_id: runId, status: 'pending' }).first();
await engine.actById(approval.id, 'confirm');
run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('waiting');
expect(run.current_node).toBe('g2'); // now holding at the wait, not yet dispatched
// Date arrives → scheduler dispatches.
await db('workflow_runs').where({ id: runId }).update({ wake_at: new Date(Date.now() - 1000).toISOString() });
await engine.runDueWaits();
run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('done');
});
test('recoverStaleRuns resumes a run orphaned mid-flow (crash recovery)', async () => {
const wfId = await makeWorkflow({
trigger: 'recover.event',
+28 -23
View File
@@ -73,14 +73,14 @@ function buildBookingFullGraph() {
{ node_key: 'sendContract', type: 'action', config: { action: 'send_document', document: 'contract', recipient: 'customer' }, pos_x: 320, pos_y: 330 },
{ node_key: 'gateSigned', type: 'gate', config: { label: 'Contract signed?' }, pos_x: 320, pos_y: 440 },
{ node_key: 'prepEvent', type: 'action', config: { action: 'prepare_event' }, pos_x: 320, pos_y: 550 },
{ node_key: 'waitEvent', type: 'wait', config: { untilVar: 'eventDate' }, pos_x: 320, pos_y: 660 },
{ node_key: 'prepInvoice', type: 'action', config: { action: 'prepare_invoice' }, pos_x: 320, pos_y: 770 },
{ node_key: 'reviewInvoice', type: 'gate', config: { label: 'Review invoice before sending' }, pos_x: 320, pos_y: 880 },
{ node_key: 'prepInvoice', type: 'action', config: { action: 'prepare_invoice' }, pos_x: 320, pos_y: 660 },
{ node_key: 'reviewInvoice', type: 'gate', config: { label: 'Review invoice (early — dispatch waits for the event)' }, pos_x: 320, pos_y: 770 },
{ node_key: 'waitEvent', type: 'wait', config: { untilVar: 'eventDate' }, pos_x: 320, pos_y: 880 },
{ node_key: 'sendInvoice', type: 'action', config: { action: 'send_document', document: 'invoice', recipient: 'customer' }, pos_x: 320, pos_y: 990 },
{ node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 320, pos_y: 1100 },
{ node_key: 'cancelContract', type: 'action', config: { action: 'noop' }, pos_x: 620, pos_y: 220 },
{ node_key: 'declined', type: 'action', config: { action: 'noop' }, pos_x: 620, pos_y: 440 },
{ node_key: 'cancelInvoice', type: 'action', config: { action: 'noop' }, pos_x: 620, pos_y: 880 },
{ node_key: 'cancelInvoice', type: 'action', config: { action: 'noop' }, pos_x: 620, pos_y: 770 },
];
const edges = [
{ from_node: 't', to_node: 'prepContract' },
@@ -90,11 +90,13 @@ function buildBookingFullGraph() {
{ from_node: 'sendContract', to_node: 'gateSigned' },
{ from_node: 'gateSigned', from_handle: 'confirm', to_node: 'prepEvent' },
{ from_node: 'gateSigned', from_handle: 'deny', to_node: 'declined' },
{ from_node: 'prepEvent', to_node: 'waitEvent' },
{ from_node: 'waitEvent', to_node: 'prepInvoice' },
// Prepare + approve the invoice EARLY (admin can adjust line items now);
// then the wait holds dispatch until the event date, and it sends itself.
{ from_node: 'prepEvent', to_node: 'prepInvoice' },
{ from_node: 'prepInvoice', to_node: 'reviewInvoice' },
{ from_node: 'reviewInvoice', from_handle: 'confirm', to_node: 'sendInvoice' },
{ from_node: 'reviewInvoice', from_handle: 'confirm', to_node: 'waitEvent' },
{ from_node: 'reviewInvoice', from_handle: 'deny', to_node: 'cancelInvoice' },
{ from_node: 'waitEvent', to_node: 'sendInvoice' },
{ from_node: 'sendInvoice', to_node: 'done' },
];
return { nodes, edges };
@@ -108,20 +110,21 @@ function buildBookingSimpleGraph() {
const nodes = [
{ node_key: 't', type: 'trigger', config: {}, pos_x: 320, pos_y: 0 },
{ node_key: 'prepEvent', type: 'action', config: { action: 'prepare_event' }, pos_x: 320, pos_y: 110 },
{ node_key: 'waitEvent', type: 'wait', config: { untilVar: 'eventDate' }, pos_x: 320, pos_y: 220 },
{ node_key: 'prepInvoice', type: 'action', config: { action: 'prepare_invoice' }, pos_x: 320, pos_y: 330 },
{ node_key: 'reviewInvoice', type: 'gate', config: { label: 'Review invoice before sending' }, pos_x: 320, pos_y: 440 },
{ node_key: 'prepInvoice', type: 'action', config: { action: 'prepare_invoice' }, pos_x: 320, pos_y: 220 },
{ node_key: 'reviewInvoice', type: 'gate', config: { label: 'Review invoice (early — dispatch waits for the event)' }, pos_x: 320, pos_y: 330 },
{ node_key: 'waitEvent', type: 'wait', config: { untilVar: 'eventDate' }, pos_x: 320, pos_y: 440 },
{ node_key: 'sendInvoice', type: 'action', config: { action: 'send_document', document: 'invoice', recipient: 'customer' }, pos_x: 320, pos_y: 550 },
{ node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 320, pos_y: 660 },
{ node_key: 'cancelInvoice', type: 'action', config: { action: 'noop' }, pos_x: 620, pos_y: 440 },
{ node_key: 'cancelInvoice', type: 'action', config: { action: 'noop' }, pos_x: 620, pos_y: 330 },
];
const edges = [
{ from_node: 't', to_node: 'prepEvent' },
{ from_node: 'prepEvent', to_node: 'waitEvent' },
{ from_node: 'waitEvent', to_node: 'prepInvoice' },
// Prepare + approve the invoice early; the wait holds dispatch to the event date.
{ from_node: 'prepEvent', to_node: 'prepInvoice' },
{ from_node: 'prepInvoice', to_node: 'reviewInvoice' },
{ from_node: 'reviewInvoice', from_handle: 'confirm', to_node: 'sendInvoice' },
{ from_node: 'reviewInvoice', from_handle: 'confirm', to_node: 'waitEvent' },
{ from_node: 'reviewInvoice', from_handle: 'deny', to_node: 'cancelInvoice' },
{ from_node: 'waitEvent', to_node: 'sendInvoice' },
{ from_node: 'sendInvoice', to_node: 'done' },
];
return { nodes, edges };
@@ -258,7 +261,7 @@ const BUILTINS = [
},
{
key: 'booking_full',
version: 2,
version: 3,
enabled: false,
name: 'Booking — quote → contract → event → invoice (built-in)',
trigger_type: 'quote.accepted',
@@ -266,23 +269,25 @@ const BUILTINS = [
description:
'On quote acceptance: prepare the contract, let the admin review it (adjust line items / '
+ 'terms) and confirm before it is sent, wait for the admin to confirm it is signed, then '
+ 'create the event/gallery, wait to the shoot date, prepare the invoice and — after a '
+ 'second admin review gate — send it. No document is ever sent without an explicit admin '
+ 'OK. Disabled by default — the document actions are stubs until the booking cutover, so '
+ 'an enabled run just records observable skipped steps. A starting point to edit.',
+ 'create the event/gallery and prepare the invoice EARLY so the admin can adjust it. The '
+ 'admin approves the invoice at the review gate whenever they like; dispatch then waits '
+ 'until the event date and sends itself. No document is sent without an explicit admin OK. '
+ 'Disabled by default — the document actions are stubs until the booking cutover, so an '
+ 'enabled run just records observable skipped steps. A starting point to edit.',
build: async () => buildBookingFullGraph(),
},
{
key: 'booking_simple',
version: 2,
version: 3,
enabled: false,
name: 'Booking — quote → event → invoice (built-in)',
trigger_type: 'quote.accepted',
trigger_config: {},
description:
'The no-contract booking path: on quote acceptance create the event/gallery, wait to the '
+ 'shoot date, prepare the invoice and — after an admin review gate — send it. Same '
+ 'review-before-send rule and stub caveat as the full booking flow; disabled by default.',
'The no-contract booking path: on quote acceptance create the event/gallery and prepare the '
+ 'invoice early. The admin approves it at the review gate ahead of time; dispatch then waits '
+ 'until the event date and sends itself. Same review-before-send rule and stub caveat as the '
+ 'full booking flow; disabled by default.',
build: async () => buildBookingSimpleGraph(),
},
];