feat(workflows): implement prepare_event so booking_full/booking_simple are enableable

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.
This commit is contained in:
Luca
2026-06-27 00:08:05 +02:00
parent cf424efb4a
commit 4faf5a344a
4 changed files with 77 additions and 15 deletions
@@ -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();
@@ -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 () => {
+28 -10
View File
@@ -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) {
+27 -3
View File
@@ -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' };