feat(workflows): admin review gates before sends + migrate lifecycle/time triggers

Booking built-ins now gate every outbound document on an explicit admin OK:
prepare_* drafts the doc, the admin adjusts line items/terms, confirms the
"Review … before sending" gate, and only then does send_document fire. Added to
booking_full (contract + invoice) and booking_simple (invoice); seed versions
bumped so the disabled built-ins self-heal.

Migrated the remaining time- and event-driven triggers into the engine, all
additive / best-effort / fail-closed (no behaviour change when the flag is off):
- gallery.published (event creation)
- gallery.expiring + gallery.expired (expiration checker, alongside the email)
- quote.sent (was queued but never emitted — gap closed)
- contract.sent + contract.signed (sent, fully-signed via counter-sign or wet upload)
- customer.created (direct add + invitation accept)
- invoice.overdue (status→overdue flip, deduped per invoice)

Editor trigger list extended to match. Tests assert the review gates wire
confirm→send on both booking flows.
This commit is contained in:
Luca
2026-06-23 14:58:48 +02:00
parent b38c216f22
commit fa7b1bae95
9 changed files with 221 additions and 38 deletions
@@ -294,12 +294,20 @@ describe('workflow engine', () => {
expect(!!bookingFull.enabled).toBe(false);
expect(bookingFull.trigger_type).toBe('quote.accepted');
const fullNodes = await db('workflow_nodes').where({ workflow_id: bookingFull.id, version: bookingFull.version });
expect(fullNodes.some((n) => n.type === 'gate')).toBe(true); // contract-signed gate
expect(fullNodes.some((n) => JSON.parse(n.config || '{}').action === 'prepare_contract')).toBe(true);
// Admin review gate guards BOTH document sends (adjust line items, then OK).
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
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);
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);
const preEvent = await db('workflows').where({ builtin_key: 'pre_event_email' }).first();
expect(preEvent).toBeTruthy();