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();
+54 -34
View File
@@ -55,55 +55,73 @@ function buildDunningGraph({ firstDays, gapDays, maxReminders }) {
return { nodes, edges };
}
// Booking — quote accepted → prepare + send contract → admin gate "signed?" →
// create the event/gallery → wait to the event date → prepare + send invoice.
// The signing step is an admin gate (no e-sign webhook yet); the document
// actions are stubs until the booking cutover, so an enabled run records
// observable skipped steps rather than acting.
// Booking — quote accepted → prepare contract → ADMIN REVIEW GATE → send
// contract → admin gate "signed?" → create the event/gallery → wait to the
// event date → prepare invoice → ADMIN REVIEW GATE → send invoice.
//
// A document is never sent without an explicit admin OK: prepare_* creates a
// DRAFT, the admin adjusts line items / terms in the CRM, then confirms the
// review gate, and only then does send_document fire. The "signed?" gate models
// the external signing step (no e-sign webhook yet). The document actions are
// stubs until the booking cutover, so an enabled run records observable skipped
// steps rather than acting.
function buildBookingFullGraph() {
const nodes = [
{ node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 },
{ node_key: 'prepContract', type: 'action', config: { action: 'prepare_contract' }, pos_x: 240, pos_y: 110 },
{ node_key: 'sendContract', type: 'action', config: { action: 'send_document', document: 'contract', recipient: 'customer' }, pos_x: 240, pos_y: 220 },
{ node_key: 'gateSigned', type: 'gate', config: { label: 'Contract signed?' }, pos_x: 240, pos_y: 330 },
{ node_key: 'prepEvent', type: 'action', config: { action: 'prepare_event' }, pos_x: 240, pos_y: 440 },
{ node_key: 'waitEvent', type: 'wait', config: { untilVar: 'eventDate' }, pos_x: 240, pos_y: 550 },
{ node_key: 'prepInvoice', type: 'action', config: { action: 'prepare_invoice' }, pos_x: 240, pos_y: 660 },
{ node_key: 'sendInvoice', type: 'action', config: { action: 'send_document', document: 'invoice', recipient: 'customer' }, pos_x: 240, pos_y: 770 },
{ node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 240, pos_y: 880 },
{ node_key: 'declined', type: 'action', config: { action: 'noop' }, pos_x: 520, pos_y: 330 },
{ node_key: 't', type: 'trigger', config: {}, pos_x: 320, pos_y: 0 },
{ node_key: 'prepContract', type: 'action', config: { action: 'prepare_contract' }, pos_x: 320, pos_y: 110 },
{ node_key: 'reviewContract', type: 'gate', config: { label: 'Review contract before sending' }, pos_x: 320, pos_y: 220 },
{ 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: '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 },
];
const edges = [
{ from_node: 't', to_node: 'prepContract' },
{ from_node: 'prepContract', to_node: 'sendContract' },
{ from_node: 'prepContract', to_node: 'reviewContract' },
{ from_node: 'reviewContract', from_handle: 'confirm', to_node: 'sendContract' },
{ from_node: 'reviewContract', from_handle: 'deny', to_node: 'cancelContract' },
{ 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' },
{ from_node: 'prepInvoice', to_node: 'sendInvoice' },
{ 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 };
}
// Booking — quote accepted → create the event/gallery → wait to the event date
// → prepare + send invoice. The no-contract path (e.g. small shoots). Same stub
// caveat as the full booking flow.
// → prepare invoice → ADMIN REVIEW GATE → send invoice. The no-contract path
// (e.g. small shoots). Same review-before-send rule and stub caveat as the full
// booking flow.
function buildBookingSimpleGraph() {
const nodes = [
{ node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 },
{ node_key: 'prepEvent', type: 'action', config: { action: 'prepare_event' }, pos_x: 240, pos_y: 110 },
{ node_key: 'waitEvent', type: 'wait', config: { untilVar: 'eventDate' }, pos_x: 240, pos_y: 220 },
{ node_key: 'prepInvoice', type: 'action', config: { action: 'prepare_invoice' }, pos_x: 240, pos_y: 330 },
{ node_key: 'sendInvoice', type: 'action', config: { action: 'send_document', document: 'invoice', recipient: 'customer' }, pos_x: 240, pos_y: 440 },
{ node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 240, pos_y: 550 },
{ 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: '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 },
];
const edges = [
{ from_node: 't', to_node: 'prepEvent' },
{ from_node: 'prepEvent', to_node: 'waitEvent' },
{ from_node: 'waitEvent', to_node: 'prepInvoice' },
{ from_node: 'prepInvoice', to_node: 'sendInvoice' },
{ 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 };
@@ -155,27 +173,29 @@ const BUILTINS = [
},
{
key: 'booking_full',
version: 1,
version: 2,
name: 'Booking — quote → contract → event → invoice (built-in)',
trigger_type: 'quote.accepted',
trigger_config: {},
description:
'On quote acceptance: prepare and send the contract, wait for the admin to confirm it is '
+ 'signed, then create the event/gallery, wait to the shoot date and prepare + send the '
+ 'invoice. 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.',
'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.',
build: async () => buildBookingFullGraph(),
},
{
key: 'booking_simple',
version: 1,
version: 2,
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 and prepare + send the invoice. Same stub caveat as the full booking flow; '
+ 'disabled by default.',
+ '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.',
build: async () => buildBookingSimpleGraph(),
},
{
+38
View File
@@ -87,6 +87,36 @@ function customerPublicActor() {
return { type: 'customer', name: 'Customer (public link)' };
}
/**
* Fire a contract lifecycle event for the workflow engine. Best-effort:
* resolves the customer email (so send_email actions have a recipient) and
* never throws into the caller. No-op when the workflows flag is off (emit
* fails closed). Mirrors quoteService.emitQuoteEvent.
*/
async function emitContractEvent(contract, status) {
try {
let customerEmail = null;
if (contract.customer_account_id) {
const c = await db('customer_accounts').where({ id: contract.customer_account_id }).first();
customerEmail = c?.email || null;
}
await require('./workflows').emitWorkflowEvent(`contract.${status}`, {
entityType: 'contract',
entityId: contract.id,
payload: {
contractId: contract.id,
contractNumber: contract.contract_number,
customerAccountId: contract.customer_account_id || null,
customerEmail,
eventName: contract.event_name || null,
title: contract.title || null,
},
});
} catch (err) {
logger.warn('Failed to emit contract workflow event', { contractId: contract.id, status, error: err.message });
}
}
/**
* Privacy gate for the customer/admin IP captured at signing time.
* The `crm_contracts_store_ip` setting (default true) controls
@@ -1065,6 +1095,8 @@ async function sendContract(id, adminId) {
await logActivity('contract_sent', { contractId: id, token }, null, await adminActor(adminId));
} catch (_) { /* logging is best-effort */ }
await emitContractEvent(contract, 'sent');
logger.info('Contract sent', { adminId, contractId: id });
return { token, pdfPath };
}
@@ -1460,6 +1492,10 @@ async function recordAdminCountersignature(contractId, { name, ip, signatureData
await logActivity(`contract_${newStatus}`, { contractId: contract.id }, null, await adminActor(adminId));
} catch (_) { /* logging is best-effort */ }
// The binding moment — fire contract.signed once the contract is fully signed
// (matches the editor's trigger). Best-effort / fail-closed.
if (newStatus === 'fully_signed') await emitContractEvent(contract, 'signed');
return { status: newStatus, signedAt: now };
}
@@ -1565,6 +1601,8 @@ async function attachSignedPdfUpload(contractId, filePath, uploaderRole) {
uploaderRole === 'admin' ? { type: 'admin', name: 'Admin (PDF upload)' } : customerPublicActor());
} catch (_) { /* logging is best-effort */ }
await emitContractEvent(contract, 'signed');
return { status: 'fully_signed', signedPdfPath: filePath };
}
@@ -22,6 +22,23 @@ const { ConflictError, NotFoundError, ValidationError } = require('../utils/erro
const INVITATION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days, matches admin invites
/**
* Fire customer.created for the workflow engine, from every creation path
* (direct add + invitation accept). Best-effort / fail-closed; never throws
* into the caller.
*/
async function emitCustomerCreated(id, email) {
try {
await require('./workflows').emitWorkflowEvent('customer.created', {
entityType: 'customer',
entityId: id,
payload: { customerAccountId: id, customerEmail: email || null },
});
} catch (err) {
logger.warn('Failed to emit customer.created workflow event', { customerId: id, error: err.message });
}
}
/**
* Whitelist of customer profile fields the admin is allowed to pre-fill on
* an invitation (and that the customer can then edit on accept). Centralised
@@ -252,6 +269,7 @@ async function createDirect({ email, prefill, createdByAdminId }) {
);
logger.info('Passive customer created', { id, email: normalisedEmail, createdByAdminId });
await emitCustomerCreated(id, normalisedEmail);
return { id };
}
@@ -420,6 +438,7 @@ async function acceptInvitation({ token, name, password, profile }) {
);
logger.info('Customer invitation accepted', { customerId, email: invitation.email });
await emitCustomerCreated(customerId, invitation.email);
return { customerId, email: invitation.email };
}
+23
View File
@@ -10,6 +10,7 @@ const crypto = require('crypto');
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { formatBoolean } = require('../utils/dbCompat');
const { hasColumnCached } = require('../utils/schemaCache');
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
@@ -283,6 +284,28 @@ const createEvent = async (eventData) => {
const insertResult = await db('events').insert(insertData).returning('id');
const eventId = insertResult[0]?.id || insertResult[0];
// Fire gallery.published — a gallery goes live the moment it's created (active
// + share link). Best-effort; emit is fail-closed when the workflows flag is
// off and never throws into the create path.
try {
await require('./workflows').emitWorkflowEvent('gallery.published', {
entityType: 'event',
entityId: eventId,
payload: {
eventId,
slug,
eventName: event_name,
eventDate: event_date,
customerEmail: customer_email || null,
adminEmail: admin_email || null,
galleryLink: shareUrl,
expiresAt: expires_at,
},
});
} catch (err) {
logger.warn('Failed to emit gallery.published workflow event', { eventId, error: err.message });
}
return {
id: eventId,
slug,
+46 -1
View File
@@ -88,6 +88,30 @@ async function queueExpirationWarning(event) {
// Relationship mail — hold to business hours (no-op unless configured).
}, { respectBusinessHours: true });
// Fire gallery.expiring so admins can build flows on the warning window
// (e.g. a final download nudge). Best-effort; emit is fail-closed when the
// workflows flag is off, and deduped per (workflow, event) so the hourly
// sweep fires the trigger at most once per gallery.
try {
await require('./workflows').emitWorkflowEvent('gallery.expiring', {
entityType: 'event',
entityId: event.id,
payload: {
eventId: event.id,
slug: event.slug,
eventName: event.event_name,
eventDate: event.event_date,
expiresAt: event.expires_at,
daysRemaining,
customerEmail: recipientEmail,
adminEmail: event.admin_email || null,
galleryLink: shareUrl,
},
});
} catch (err) {
logger.warn('Failed to emit gallery.expiring workflow event', { eventId: event.id, error: err.message });
}
logger.info(`Queued expiration warning for event ${event.slug}`);
}
@@ -152,9 +176,30 @@ async function handleExpiredEvent(event) {
});
}
// Fire gallery.expired for the workflow engine (sibling to the
// event.expired webhook above). Best-effort / fail-closed; emitted BEFORE
// archiveEvent so flows see expired→archived in order.
try {
await require('./workflows').emitWorkflowEvent('gallery.expired', {
entityType: 'event',
entityId: event.id,
payload: {
eventId: event.id,
slug: event.slug,
eventName: event.event_name,
eventDate: event.event_date,
expiresAt: event.expires_at,
customerEmail: recipientEmail,
adminEmail: event.admin_email || null,
},
});
} catch (err) {
logger.warn('Failed to emit gallery.expired workflow event', { eventId: event.id, error: err.message });
}
// Start archiving process
await archiveEvent(event);
logger.info(`Handled expiration for event ${event.slug}`);
} catch (error) {
logger.error(`Error handling expired event ${event.slug}:`, error);
+21
View File
@@ -2710,6 +2710,27 @@ async function applyReminder(invoice, lineItems, level, adminId) {
if (await hasColumnCached('invoices', 'late_fee_vat_minor')) update.late_fee_vat_minor = lateFeeVat;
await db('invoices').where({ id: invoice.id }).update(update);
// Fire invoice.overdue at the status→overdue flip. Deduped per (workflow,
// invoice), so across the reminder ladder it triggers a flow at most once.
// Best-effort / fail-closed.
try {
await require('./workflows').emitWorkflowEvent('invoice.overdue', {
entityType: 'invoice',
entityId: invoice.id,
payload: {
invoiceId: invoice.id,
invoiceNumber: invoice.invoice_number,
eventId: invoice.event_id || null,
customerAccountId: invoice.customer_account_id,
customerEmail: customer?.email || null,
dueDate: invoice.due_date,
reminderLevel: level,
totalMinor: invoice.total_amount_minor,
currency: invoice.currency,
},
});
} catch (_) {}
// Render the MAHNUNG (reminder letter). The original invoice PDF is left
// UNTOUCHED (immutable). The Mahnung reuses the invoice layout via a
// 'mahnung' kind: same line items + the Mahngebühr row + the new total, with
+5
View File
@@ -981,6 +981,11 @@ async function sendQuote(id, adminId) {
await logActivity('quote_sent', { quoteId: id, token }, null, `admin:${adminId}`);
} catch (_) {}
// Fire the quote.sent workflow trigger (best-effort; emit is fail-closed when
// the workflows flag is off). The accepted/declined emits already exist; this
// closes the gap so flows can react to a quote going out.
await emitQuoteEvent(quote, 'sent');
logger.info('Quote sent', { adminId, quoteId: id });
return { token, pdfPath };
}
@@ -26,8 +26,12 @@ import { NodeConfigPanel } from './NodeConfigPanel';
const PALETTE: WorkflowNodeType[] = ['trigger', 'condition', 'branch', 'loop', 'wait', 'action', 'gate', 'webhook'];
const TRIGGERS = [
'invoice.sent', 'invoice.paid', 'invoice.overdue', 'quote.accepted', 'quote.declined',
'contract.signed', 'event.date_approaching', 'gallery.published', 'gallery.expiring', 'customer.created',
'invoice.sent', 'invoice.paid', 'invoice.overdue',
'quote.sent', 'quote.accepted', 'quote.declined',
'contract.sent', 'contract.signed',
'event.date_approaching',
'gallery.published', 'gallery.expiring', 'gallery.expired',
'customer.created',
];
const COLORS: Record<string, string> = {