From fa7b1bae951222de506ab8a3c8aee158b471efd1 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:58:48 +0200 Subject: [PATCH] feat(workflows): admin review gates before sends + migrate lifecycle/time triggers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../integration/workflowEngine.test.js | 10 ++- backend/src/services/_workflowSeedBoot.js | 88 ++++++++++++------- backend/src/services/contractService.js | 38 ++++++++ .../src/services/customerAccountsService.js | 19 ++++ backend/src/services/eventService.js | 23 +++++ backend/src/services/expirationChecker.js | 47 +++++++++- backend/src/services/invoiceService.js | 21 +++++ backend/src/services/quoteService.js | 5 ++ .../admin/workflows/WorkflowEditorPage.tsx | 8 +- 9 files changed, 221 insertions(+), 38 deletions(-) diff --git a/backend/__tests__/integration/workflowEngine.test.js b/backend/__tests__/integration/workflowEngine.test.js index e57894e5..38abaca3 100644 --- a/backend/__tests__/integration/workflowEngine.test.js +++ b/backend/__tests__/integration/workflowEngine.test.js @@ -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(); diff --git a/backend/src/services/_workflowSeedBoot.js b/backend/src/services/_workflowSeedBoot.js index d1137acc..741323ed 100644 --- a/backend/src/services/_workflowSeedBoot.js +++ b/backend/src/services/_workflowSeedBoot.js @@ -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(), }, { diff --git a/backend/src/services/contractService.js b/backend/src/services/contractService.js index 54af7e41..31a959ea 100644 --- a/backend/src/services/contractService.js +++ b/backend/src/services/contractService.js @@ -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 }; } diff --git a/backend/src/services/customerAccountsService.js b/backend/src/services/customerAccountsService.js index 041790e0..8b8061d1 100644 --- a/backend/src/services/customerAccountsService.js +++ b/backend/src/services/customerAccountsService.js @@ -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 }; } diff --git a/backend/src/services/eventService.js b/backend/src/services/eventService.js index 3f7faea7..bb732247 100644 --- a/backend/src/services/eventService.js +++ b/backend/src/services/eventService.js @@ -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, diff --git a/backend/src/services/expirationChecker.js b/backend/src/services/expirationChecker.js index 1666e1d3..58d67688 100644 --- a/backend/src/services/expirationChecker.js +++ b/backend/src/services/expirationChecker.js @@ -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); diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js index 72f5beb4..6528698b 100644 --- a/backend/src/services/invoiceService.js +++ b/backend/src/services/invoiceService.js @@ -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 diff --git a/backend/src/services/quoteService.js b/backend/src/services/quoteService.js index 3db3614b..8f1a1db2 100644 --- a/backend/src/services/quoteService.js +++ b/backend/src/services/quoteService.js @@ -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 }; } diff --git a/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx b/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx index 2ef79c3e..a3b4ce05 100644 --- a/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx +++ b/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx @@ -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 = {