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