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