From 4faf5a344a8d6d03cd9f374e093cc9bd8317fa48 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sat, 27 Jun 2026 00:08:05 +0200 Subject: [PATCH] feat(workflows): implement prepare_event so booking_full/booking_simple are enableable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The booking_full / booking_simple flows go prepare_event -> prepare_invoice, but prepare_event was still a guard-stub, so enabling either flow returned 409 'uses actions that aren't implemented: prepare_event'. prepare_event now calls convertToEvent({ hold: true }): convertToEvent already creates the event as is_draft=true AND schedules its invoices, so this creates those invoices on HOLD (scheduled_send_at NULL) and stashes their ids in ctx.vars.preparedInvoiceIds. The downstream prepare_invoice already short- circuits on a populated preparedInvoiceIds, so it ADOPTS the event's held invoices instead of calling convertToInvoiceOnly again (which would both double-create and throw ALREADY_CONVERTED_TO_EVENT). The review gate, the wait-until-event-date, and send_document then issue those same invoices. send_document(event)=publish is intentionally left a graceful skip — the gallery is published manually after photos are uploaded, not auto-published on an empty draft. convertToEvent gains the same single-connection SQLite deadlock fixes as convertToInvoiceOnly (getAppSetting reads through trx; logActivity moved after commit) since prepare_event runs unattended, returns invoiceIds (incl. the idempotent already-converted re-entry, which recovers them by event_id), and removes prepare_event from the enable-guard list. Adds a convertToEvent hold-mode test (draft event + held invoices + quote linkage) and updates the enable-guard test to a still-stub action (prepare_gallery). Full backend suite: 982 passed, 1 skipped. --- .../integration/bookingCutover.test.js | 20 ++++++++++ .../integration/workflowRoutes.test.js | 4 +- backend/src/services/quoteService.js | 38 ++++++++++++++----- backend/src/services/workflows/actions.js | 30 +++++++++++++-- 4 files changed, 77 insertions(+), 15 deletions(-) diff --git a/backend/__tests__/integration/bookingCutover.test.js b/backend/__tests__/integration/bookingCutover.test.js index 6ecb9f9e..60347196 100644 --- a/backend/__tests__/integration/bookingCutover.test.js +++ b/backend/__tests__/integration/bookingCutover.test.js @@ -56,6 +56,26 @@ describe('booking cutover — draft invoices on hold', () => { expect(inv.scheduled_send_at == null).toBe(false); // normal convert → auto-send date set }); + it('prepare_event path (convertToEvent hold) creates a DRAFT event with held invoices', async () => { + const quoteId = await acceptedQuote(); + const res = await quoteService.convertToEvent(quoteId, adminId, { hold: true }); + expect(res.eventId).toBeGreaterThanOrEqual(1); + expect(Array.isArray(res.invoiceIds)).toBe(true); + expect(res.invoiceIds.length).toBeGreaterThanOrEqual(1); + + const ev = await db('events').where({ id: res.eventId }).first(); + expect(ev.is_draft == true || ev.is_draft === 1).toBe(true); // created as a draft gallery + + // Every invoice the event scheduled is held (no auto-send before the gate). + const invs = await db('invoices').whereIn('id', res.invoiceIds); + for (const inv of invs) expect(inv.scheduled_send_at == null).toBe(true); + + // Quote is now linked to the event — convertToInvoiceOnly must NOT be called + // again for it (the flow's prepare_invoice adopts these ids instead). + const q = await db('quotes').where({ id: quoteId }).first(); + expect(q.converted_event_id).toBe(res.eventId); + }); + it('prepare_contract path (createFromQuote) completes under SQLite — no in-trx deadlock', async () => { const contractService = require('../../src/services/contractService'); const quoteId = await acceptedQuote(); diff --git a/backend/__tests__/integration/workflowRoutes.test.js b/backend/__tests__/integration/workflowRoutes.test.js index 2a1fb357..1ae6a831 100644 --- a/backend/__tests__/integration/workflowRoutes.test.js +++ b/backend/__tests__/integration/workflowRoutes.test.js @@ -74,13 +74,13 @@ describe('admin workflows API', () => { test('refuses to enable a flow that uses an unimplemented action', async () => { const create = await request(app).post('/api/admin/workflows').set(auth(token)).send({ name: 'Stub flow', trigger_type: 'quote.accepted', enabled: false, - nodes: [{ node_key: 't', type: 'trigger' }, { node_key: 'a', type: 'action', config: { action: 'prepare_event' } }], + nodes: [{ node_key: 't', type: 'trigger' }, { node_key: 'a', type: 'action', config: { action: 'prepare_gallery' } }], edges: [{ from_node: 't', to_node: 'a' }], }); expect(create.status).toBe(201); const res = await request(app).patch(`/api/admin/workflows/${create.body.id}/enabled`).set(auth(token)).send({ enabled: true }); expect(res.status).toBe(409); - expect(res.body.error).toMatch(/not.*implemented|prepare_event/i); + expect(res.body.error).toMatch(/not.*implemented|prepare_gallery/i); }); test('allows enabling a flow using the now-implemented booking invoice actions', async () => { diff --git a/backend/src/services/quoteService.js b/backend/src/services/quoteService.js index 0d9435ba..8578d316 100644 --- a/backend/src/services/quoteService.js +++ b/backend/src/services/quoteService.js @@ -1497,7 +1497,16 @@ async function convertToEvent(quoteId, adminId, options = {}) { throw new AppError(`Cannot convert a quote with status '${quote.status}'`, 409); } if (quote.converted_event_id) { - return { eventId: quote.converted_event_id, alreadyConverted: true }; + // Idempotent re-entry (e.g. workflow crash-recovery): hand back the + // already-created event and its scheduled invoices so the caller can + // adopt them instead of double-creating. + const existingInvoices = await db('invoices') + .where({ event_id: quote.converted_event_id }).select('id'); + return { + eventId: quote.converted_event_id, + alreadyConverted: true, + invoiceIds: existingInvoices.map((r) => r.id), + }; } // Same guard as convertToInvoiceOnly — refuse if a contract is in // flight unless the contract→event button re-entered this path. @@ -1520,7 +1529,7 @@ async function convertToEvent(quoteId, adminId, options = {}) { // Lazy import to avoid the circular dep. const invoiceService = require('./invoiceService'); - return await db.transaction(async (trx) => { + const result = await db.transaction(async (trx) => { // The events table schema has drifted across migrations: // installs that ran the original 060 series have // host_name/host_email; later ones renamed to customer_*; some @@ -1541,7 +1550,7 @@ async function convertToEvent(quoteId, adminId, options = {}) { // else a configurable org default, else the resolved catch-all (an ACTIVE // type — never a hardcoded slug the admin may have disabled). const eventType = (quote.event_type && String(quote.event_type).trim()) - || (await getAppSetting('crm_default_event_type')) + || (await getAppSetting('crm_default_event_type', null, trx)) || (await resolveDefaultEventType(trx)); // Each candidate column is paired with the value we'd write. We @@ -1601,7 +1610,7 @@ async function convertToEvent(quoteId, adminId, options = {}) { ? paymentTermSnapshot.installments : [{ percent: 100, trigger: 'after_delivery', offset_days: 0, label: 'Total' }]; - await invoiceService.scheduleInvoicesForEvent({ + const spawnResult = await invoiceService.scheduleInvoicesForEvent({ trx, eventId, quoteId: quote.id, @@ -1631,6 +1640,10 @@ async function convertToEvent(quoteId, adminId, options = {}) { // Migration 140 — propagate the quote's deal_uuid down through // every spawned invoice (same as convertToInvoiceOnly above). dealUuid: quote.deal_uuid, + // Workflow draft-seam: the booking flow's prepare_event creates the + // event's invoices on HOLD (no scheduled_send_at) so they wait for the + // review gate + explicit send_document after the event date. + hold: options.hold === true, }); await trx('quotes').where({ id: quote.id }).update({ @@ -1639,13 +1652,18 @@ async function convertToEvent(quoteId, adminId, options = {}) { updated_at: new Date(), }); - try { - await logActivity('quote_converted', { quoteId: quote.id, eventId }, eventId, `admin:${adminId}`); - } catch (_) {} - - logger.info('Quote converted to event', { adminId, quoteId: quote.id, eventId }); - return { eventId, alreadyConverted: false }; + return { eventId, alreadyConverted: false, invoiceIds: spawnResult?.invoiceIds || [] }; }); + + // Audit log AFTER commit — logActivity writes via the global `db`, which + // deadlocks the single-connection SQLite pool if issued inside the trx + // (prepare_event runs this unattended from the booking flow). + try { + await logActivity('quote_converted', { quoteId: quote.id, eventId: result.eventId }, result.eventId, `admin:${adminId}`); + } catch (_) {} + + logger.info('Quote converted to event', { adminId, quoteId: quote.id, eventId: result.eventId }); + return result; } async function duplicateQuote(id, adminId) { diff --git a/backend/src/services/workflows/actions.js b/backend/src/services/workflows/actions.js index 11ae3e7d..f4eb7e18 100644 --- a/backend/src/services/workflows/actions.js +++ b/backend/src/services/workflows/actions.js @@ -17,11 +17,11 @@ const registry = require('./registry'); // Document actions still awaiting wiring (the enable-guard refuses flows that use -// any of these). prepare_contract / prepare_invoice / send_document are now -// implemented below (draft-seam booking cutover), so they're off this list. +// any of these). prepare_contract / prepare_event / prepare_invoice / +// send_document are now implemented below (draft-seam booking cutover), so +// they're off this list — which makes booking_full / booking_simple enableable. const DOCUMENT_ACTIONS = [ 'prepare_quote', - 'prepare_event', 'prepare_gallery', 'reserve_date', ]; @@ -241,8 +241,32 @@ registry.registerAction('prepare_contract', async (ctx) => { return { contract_prepared: res.contractId, alreadyConverted: !!res.alreadyConverted }; }); +// Prepare a DRAFT event/gallery from the accepted quote. convertToEvent creates +// the event as is_draft=true AND schedules its invoices — on HOLD here so they +// wait for the review gate + send_document. The created invoice ids are stashed +// so the flow's downstream prepare_invoice ADOPTS them (instead of double- +// creating, which would also throw ALREADY_CONVERTED_TO_EVENT). Idempotent: a +// re-entry returns the existing event + invoices. +registry.registerAction('prepare_event', async (ctx) => { + const quoteId = ctx.run.entity_id; + if (ctx.run.entity_type !== 'quote' || !quoteId) return { skipped: true, reason: 'prepare_event needs a quote entity' }; + if (ctx.vars?.__dryRun) return { dryRun: true, would: 'prepare_event', quoteId }; + if (ctx.vars.preparedEventId) { + return { already: true, eventId: ctx.vars.preparedEventId, invoiceIds: ctx.vars.preparedInvoiceIds || [] }; + } + const adminId = await resolveActor(ctx); + const res = await require('../quoteService').convertToEvent(quoteId, adminId, { hold: true }); + ctx.vars.preparedEventId = res.eventId; + // The flow's prepare_invoice short-circuits on a populated preparedInvoiceIds, + // so the event's held invoices flow straight through to send_document. + ctx.vars.preparedInvoiceIds = res.invoiceIds || []; + return { event_prepared: res.eventId, invoiceIds: ctx.vars.preparedInvoiceIds, alreadyConverted: !!res.alreadyConverted }; +}); + // Prepare DRAFT invoice(s) from the accepted quote — created on HOLD (no // scheduled_send_at) so the scheduler won't auto-send before the review gate. +// When prepare_event already ran in this flow, the event's held invoices are +// already in ctx.vars.preparedInvoiceIds and this adopts them (no double-create). registry.registerAction('prepare_invoice', async (ctx) => { const quoteId = ctx.run.entity_id; if (ctx.run.entity_type !== 'quote' || !quoteId) return { skipped: true, reason: 'prepare_invoice needs a quote entity' };