From cf424efb4a3a5e0df3a4bb1c753ca24368446816 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Fri, 26 Jun 2026 23:48:12 +0200 Subject: [PATCH 1/7] feat(workflows): wire booking document actions (prepare_invoice/contract + send_document) Implements the draft-seam booking cutover so the booking_invoice_only flow becomes enableable. The booking flows trigger on quote.accepted, so the run entity is the quote: - prepare_invoice: convertToInvoiceOnly({draft:true}) creates the invoice(s) on HOLD (scheduled_send_at NULL, status stays 'scheduled') so the scheduler never auto-sends before the review gate; crash-recovery recovers drafts by the quote's deal_uuid. Stores ids in ctx.vars.preparedInvoiceIds. - prepare_contract: createFromQuote (idempotent via converted_contract_id). - send_document: dispatches the prepared draft (invoice -> sendInvoice each id, contract -> sendContract). - resolveActor: quote creator -> workflow creator -> first admin. - prepare_contract/prepare_invoice/send_document removed from the enable-guard list; prepare_event/prepare_quote/prepare_gallery/reserve_date still guarded, so booking_full/booking_simple stay blocked until the event-path increment. Fixes a latent single-connection SQLite deadlock these unattended paths would hit: getAppSetting/logActivity/adminActor read or write the global db, which deadlocks when issued inside an open knex transaction. Thread the active trx through getAppSetting, logActivity, nextInvoiceNumber, nextContractNumber, the spawnInstallmentInvoices audit log, and hoist adminActor before createFromQuote's transaction. convertToInvoiceOnly now logs after commit and returns invoiceIds. Adds bookingCutover integration test (hold-mode null send-at, normal scheduled contrast, contract path no-deadlock) and a route test that the now-implemented booking invoice actions can be enabled. --- .../integration/bookingCutover.test.js | 68 ++++++++++++++ .../integration/workflowRoutes.test.js | 25 +++++- backend/src/database/db.js | 10 ++- backend/src/services/contractService.js | 14 ++- backend/src/services/invoiceService.js | 19 ++-- backend/src/services/quoteService.js | 28 ++++-- backend/src/services/workflows/actions.js | 89 ++++++++++++++++++- backend/src/utils/appSettings.js | 7 +- 8 files changed, 235 insertions(+), 25 deletions(-) create mode 100644 backend/__tests__/integration/bookingCutover.test.js diff --git a/backend/__tests__/integration/bookingCutover.test.js b/backend/__tests__/integration/bookingCutover.test.js new file mode 100644 index 00000000..6ecb9f9e --- /dev/null +++ b/backend/__tests__/integration/bookingCutover.test.js @@ -0,0 +1,68 @@ +/** + * Booking cutover — prepare_invoice's draft seam. convertToInvoiceOnly({draft}) + * must create the invoice(s) but leave scheduled_send_at NULL so the scheduler + * never auto-sends them before the workflow's review gate + explicit + * send_document. + */ +const crypto = require('crypto'); +const { bootCrmDb, seedMinimal } = require('./helpers/crmDb'); + +jest.setTimeout(30000); + +describe('booking cutover — draft invoices on hold', () => { + let db; let cleanup; let adminId; let customerId; let quoteService; + + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + ({ adminId, customerId } = await seedMinimal(db)); + quoteService = require('../../src/services/quoteService'); + }, 120000); + + afterAll(async () => { if (cleanup) await cleanup(); }); + + async function acceptedQuote() { + const dealUuid = crypto.randomUUID(); + const [id] = await db('quotes').insert({ + quote_number: `Q-${dealUuid.slice(0, 8)}`, + customer_account_id: customerId, + status: 'accepted', + currency: 'CHF', + issue_date: '2026-01-01', + net_amount_minor: 100000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 100000, + // A non-delivery installment so the contrast (scheduled date vs null) is meaningful. + payment_term_snapshot: JSON.stringify({ installments: [{ percent: 100, trigger: 'quote_accepted', offset_days: 0, label: 'Total' }], net_days: 30 }), + deal_uuid: dealUuid, + created_by_admin_id: adminId, + }); + return id; + } + + it('draft mode creates the invoice with scheduled_send_at = NULL (held), and returns its id', async () => { + const quoteId = await acceptedQuote(); + const res = await quoteService.convertToInvoiceOnly(quoteId, adminId, { draft: true }); + expect(Array.isArray(res.invoiceIds)).toBe(true); + expect(res.invoiceIds.length).toBeGreaterThanOrEqual(1); + + const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first(); + expect(inv.status).toBe('scheduled'); // editable + sendInvoice can issue it + expect(inv.scheduled_send_at == null).toBe(true); // held — scheduler won't auto-send + }); + + it('without draft, the same installment IS scheduled (scheduled_send_at set)', async () => { + const quoteId = await acceptedQuote(); + const res = await quoteService.convertToInvoiceOnly(quoteId, adminId); + const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first(); + expect(inv.status).toBe('scheduled'); + expect(inv.scheduled_send_at == null).toBe(false); // normal convert → auto-send date set + }); + + it('prepare_contract path (createFromQuote) completes under SQLite — no in-trx deadlock', async () => { + const contractService = require('../../src/services/contractService'); + const quoteId = await acceptedQuote(); + const res = await contractService.createFromQuote(quoteId, adminId); + expect(res.contractId).toBeGreaterThanOrEqual(1); + expect(res.alreadyConverted).toBe(false); + const c = await db('contracts').where({ id: res.contractId }).first(); + expect(c).toBeTruthy(); + }); +}); diff --git a/backend/__tests__/integration/workflowRoutes.test.js b/backend/__tests__/integration/workflowRoutes.test.js index 8b283db9..2a1fb357 100644 --- a/backend/__tests__/integration/workflowRoutes.test.js +++ b/backend/__tests__/integration/workflowRoutes.test.js @@ -74,13 +74,34 @@ 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_invoice' } }], + nodes: [{ node_key: 't', type: 'trigger' }, { node_key: 'a', type: 'action', config: { action: 'prepare_event' } }], 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_invoice/i); + expect(res.body.error).toMatch(/not.*implemented|prepare_event/i); + }); + + test('allows enabling a flow using the now-implemented booking invoice actions', async () => { + const create = await request(app).post('/api/admin/workflows').set(auth(token)).send({ + name: 'Invoice-only booking', trigger_type: 'quote.accepted', enabled: false, + nodes: [ + { node_key: 't', type: 'trigger' }, + { node_key: 'p', type: 'action', config: { action: 'prepare_invoice' } }, + { node_key: 'g', type: 'gate', config: {} }, + { node_key: 's', type: 'action', config: { action: 'send_document', document: 'invoice' } }, + ], + edges: [ + { from_node: 't', to_node: 'p' }, + { from_node: 'p', to_node: 'g' }, + { from_node: 'g', from_handle: 'confirm', to_node: 's' }, + ], + }); + 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(200); + expect(res.body.enabled).toBe(true); }); test('get one returns the graph', async () => { diff --git a/backend/src/database/db.js b/backend/src/database/db.js index e9657402..982ea4eb 100644 --- a/backend/src/database/db.js +++ b/backend/src/database/db.js @@ -645,8 +645,14 @@ async function ensureGlobalCategories() { } // Helper function to log activities -async function logActivity(activityType, metadata = {}, eventId = null, actor = null) { +async function logActivity(activityType, metadata = {}, eventId = null, actor = null, executor = null) { try { + // Callers issuing the log from inside a knex transaction must pass that + // trx as `executor`, otherwise the global-`db` insert tries to grab a + // second connection from the single-connection SQLite pool while the + // trx still holds it → deadlock. Defaults to the global db for the + // common after-commit / outside-trx callers. + const conn = executor || db; // actor_id is integer-typed; some legacy callers pass a hex-string // identifier (e.g. a 16-char guest fingerprint) which makes Postgres // throw "invalid input syntax for type integer" and drop the entire @@ -659,7 +665,7 @@ async function logActivity(activityType, metadata = {}, eventId = null, actor = const actorName = actor?.name || (actorIdInt === null && rawId !== undefined && rawId !== null ? String(rawId) : null); - await db('activity_logs').insert({ + await conn('activity_logs').insert({ activity_type: activityType, actor_type: actor?.type || 'system', actor_id: actorIdInt, diff --git a/backend/src/services/contractService.js b/backend/src/services/contractService.js index 31a959ea..88de2ad2 100644 --- a/backend/src/services/contractService.js +++ b/backend/src/services/contractService.js @@ -167,7 +167,10 @@ function formatNumberInTemplate(format, year, seq) { * emit `C-2026-AB12C3` after 5 retries. */ async function nextContractNumber(trx) { - const format = (await getAppSetting('crm_contracts_number_format')) || 'C-{YEAR}-{SEQ:04d}'; + // Read through `trx` when present — getAppSetting on the global db inside an + // open transaction deadlocks the single-connection SQLite pool (the booking + // flow's prepare_contract action runs createFromQuote unattended). + const format = (await getAppSetting('crm_contracts_number_format', null, trx || db)) || 'C-{YEAR}-{SEQ:04d}'; const year = new Date().getFullYear(); const seq = await claimNextSequence('contract', year, trx); return formatNumberInTemplate(format, year, seq); @@ -1661,6 +1664,11 @@ async function createFromQuote(quoteId, adminId) { const hasQuoteContractBackPointer = await hasColumnCached('quotes', 'converted_contract_id'); const hasContractEventCols = await hasColumnCached('contracts', 'event_name'); + // Resolve the actor BEFORE opening the transaction — adminActor reads + // admin_users via the global db, which deadlocks the single-connection + // SQLite pool if evaluated inside the trx (prepare_contract runs unattended). + const actor = await adminActor(adminId); + return await db.transaction(async (trx) => { // Pass trx so the sequence claim joins our outer transaction — // SQLite deadlocks otherwise (1-connection default). @@ -1735,9 +1743,11 @@ async function createFromQuote(quoteId, adminId) { } try { + // Pass `trx` so the audit insert rides the transaction's connection; + // the global db here deadlocks the single-connection SQLite pool. await logActivity('contract_created_from_quote', { contractId, contractNumber, quoteId: quote.id, quoteNumber: quote.quote_number }, - null, await adminActor(adminId)); + null, actor, trx); } catch (_) { /* logging is best-effort */ } logger.info('Contract created from quote', { adminId, contractId, contractNumber, quoteId: quote.id }); return { contractId, alreadyConverted: false }; diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js index 497ce2a6..426c5370 100644 --- a/backend/src/services/invoiceService.js +++ b/backend/src/services/invoiceService.js @@ -59,7 +59,9 @@ function formatNumberInTemplate(format, year, seq) { // admin creates and emitted a random `R-2026-AB12C3` after 5 retries, // breaking the §14 UStG single-sequence requirement. async function nextInvoiceNumber(trx) { - const format = (await getAppSetting('crm_invoices_number_format')) || 'R-{YEAR}-{SEQ:04d}'; + // Read through `trx` when present — getAppSetting on the global db inside an + // open transaction deadlocks the single-connection SQLite pool. + const format = (await getAppSetting('crm_invoices_number_format', null, trx || db)) || 'R-{YEAR}-{SEQ:04d}'; const year = new Date().getFullYear(); const seq = await claimNextSequence('invoice', year, trx); return formatNumberInTemplate(format, year, seq); @@ -998,7 +1000,7 @@ async function spawnInstallmentInvoices({ trx, eventId, quoteId, customer, curre ccPdfEmail, netDays, eventName, eventTimeStart, eventTimeEnd, paymentNetDaysTemplateId, paymentTimingTemplateId, - paymentTermSnapshot, dealUuid }) { + paymentTermSnapshot, dealUuid, hold = false }) { // Monthly-billing intercept (migration 128). Quote → invoice // conversion for a monthly-mode customer doesn't fan out N // installment invoices — the customer pays one consolidated bill @@ -1029,7 +1031,7 @@ async function spawnInstallmentInvoices({ trx, eventId, quoteId, customer, curre // absent we fall back to the crm_payment_default_net_days setting // (then 30) rather than silently using 30, matching createInvoice. const resolvedNetDays = ensureInt(netDays) - || ensureInt(await getAppSetting('crm_payment_default_net_days')) + || ensureInt(await getAppSetting('crm_payment_default_net_days', null, trx || db)) || 30; const total = installments.length; const acceptanceTime = new Date(); @@ -1076,7 +1078,11 @@ async function spawnInstallmentInvoices({ trx, eventId, quoteId, customer, curre // "Release for delivery" on the invoice detail page. const isDeliveryTrigger = inst.trigger === 'after_delivery'; const rowStatus = isDeliveryTrigger ? 'pending_delivery' : 'scheduled'; - const rowScheduledSendAt = isDeliveryTrigger ? null : scheduledSendAt; + // `hold` (workflow draft-seam): create the invoice but leave scheduled_send_at + // NULL so the scheduler never auto-sends it — a draft awaiting an explicit + // send_document after the admin's review gate. Status stays 'scheduled' so + // it's editable and sendInvoice can later issue it. + const rowScheduledSendAt = (isDeliveryTrigger || hold) ? null : scheduledSendAt; const invoiceNumber = await nextInvoiceNumber(trx); const dueDate = computeDueDate(scheduledSendAt, resolvedNetDays).toISOString().slice(0, 10); @@ -1219,8 +1225,11 @@ async function spawnInstallmentInvoices({ trx, eventId, quoteId, customer, curre } try { + // Pass `trx` so the audit insert rides the transaction's connection — + // logging via the global db here deadlocks the single-connection SQLite + // pool (this runs unattended from the booking flow's prepare_invoice). await logActivity('invoice_scheduled', { invoiceId, invoiceNumber, eventId, quoteId, scheduledSendAt }, - eventId, `admin:${adminId}`); + eventId, `admin:${adminId}`, trx); } catch (_) {} invoiceIds.push(invoiceId); } diff --git a/backend/src/services/quoteService.js b/backend/src/services/quoteService.js index 98aefae9..0d9435ba 100644 --- a/backend/src/services/quoteService.js +++ b/backend/src/services/quoteService.js @@ -1415,12 +1415,12 @@ async function convertToInvoiceOnly(quoteId, adminId, options = {}) { const invoiceService = require('./invoiceService'); - return await db.transaction(async (trx) => { + const result = await db.transaction(async (trx) => { const installments = Array.isArray(paymentTermSnapshot?.installments) ? paymentTermSnapshot.installments : [{ percent: 100, trigger: 'after_delivery', offset_days: 0, label: 'Total' }]; - await invoiceService.scheduleInvoicesForEvent({ + const spawnResult = await invoiceService.scheduleInvoicesForEvent({ trx, // eventId omitted → invoices have source_quote_id but no event_id. eventId: null, @@ -1459,6 +1459,10 @@ async function convertToInvoiceOnly(quoteId, adminId, options = {}) { // Migration 140 — every spawned invoice inherits the source // quote's deal_uuid so quote + N invoices group under one deal. dealUuid: quote.deal_uuid, + // Workflow draft-seam: when called by the booking flow's prepare_invoice + // action, create the invoices on HOLD (no scheduled_send_at) so they wait + // for the explicit send_document after the review gate. + hold: options.draft === true, }); // Mark quote `converted` without a converted_event_id so the @@ -1470,14 +1474,20 @@ async function convertToInvoiceOnly(quoteId, adminId, options = {}) { updated_at: new Date(), }); - try { - await logActivity('quote_converted_invoices_only', { quoteId: quote.id, installments: installments.length }, - null, `admin:${adminId}`); - } catch (_) {} - - logger.info('Quote converted to invoices only (no event)', { adminId, quoteId: quote.id, installments: installments.length }); - return { installmentsCreated: installments.length }; + return { installmentsCreated: installments.length, 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 + // (the booking flow's prepare_invoice action runs this unattended, so a + // hang here would wedge the workflow executor, not just a request). + try { + await logActivity('quote_converted_invoices_only', { quoteId: quote.id, installments: result.installmentsCreated }, + null, `admin:${adminId}`); + } catch (_) {} + + logger.info('Quote converted to invoices only (no event)', { adminId, quoteId: quote.id, installments: result.installmentsCreated }); + return result; } async function convertToEvent(quoteId, adminId, options = {}) { diff --git a/backend/src/services/workflows/actions.js b/backend/src/services/workflows/actions.js index 468a32bb..11ae3e7d 100644 --- a/backend/src/services/workflows/actions.js +++ b/backend/src/services/workflows/actions.js @@ -16,13 +16,13 @@ */ 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. const DOCUMENT_ACTIONS = [ 'prepare_quote', - 'prepare_contract', 'prepare_event', 'prepare_gallery', - 'prepare_invoice', - 'send_document', 'reserve_date', ]; @@ -208,6 +208,89 @@ registry.registerAction('webhook', async (ctx) => { : { skipped: true, reason: res.reason }; }); +// --- Booking document actions (draft-seam cutover) --- +// +// The booking flows trigger on quote.accepted, so the run entity is the QUOTE. +// prepare_* create DRAFT documents (idempotent, reusing the proven converters) +// and stash the created ids in the run context; send_document then dispatches +// the matching draft. Flows run system-side, so the actor is resolved from the +// quote's creator (else the workflow's creator, else the first admin). + +async function resolveActor(ctx) { + try { + if (ctx.run.entity_type === 'quote' && ctx.run.entity_id) { + const q = await ctx.db('quotes').where({ id: ctx.run.entity_id }).first('created_by_admin_id'); + if (q?.created_by_admin_id) return q.created_by_admin_id; + } + const wf = await ctx.db('workflows').where({ id: ctx.run.workflow_id }).first('created_by'); + if (wf?.created_by) return wf.created_by; + const admin = await ctx.db('admin_users').orderBy('id', 'asc').first('id'); + return admin?.id || null; + } catch (_) { return null; } +} + +// Prepare a DRAFT contract from the accepted quote (idempotent via the quote's +// converted_contract_id back-pointer). +registry.registerAction('prepare_contract', async (ctx) => { + const quoteId = ctx.run.entity_id; + if (ctx.run.entity_type !== 'quote' || !quoteId) return { skipped: true, reason: 'prepare_contract needs a quote entity' }; + if (ctx.vars?.__dryRun) return { dryRun: true, would: 'prepare_contract', quoteId }; + const adminId = await resolveActor(ctx); + const res = await require('../contractService').createFromQuote(quoteId, adminId); + ctx.vars.preparedContractId = res.contractId; + return { contract_prepared: res.contractId, 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. +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' }; + if (ctx.vars?.__dryRun) return { dryRun: true, would: 'prepare_invoice', quoteId }; + if (Array.isArray(ctx.vars.preparedInvoiceIds) && ctx.vars.preparedInvoiceIds.length) { + return { already: true, invoiceIds: ctx.vars.preparedInvoiceIds }; + } + const adminId = await resolveActor(ctx); + let invoiceIds; + try { + const res = await require('../quoteService').convertToInvoiceOnly(quoteId, adminId, { draft: true }); + invoiceIds = res.invoiceIds || []; + } catch (err) { + // Crash-recovery re-run: the quote may already be 'converted' (convert + // throws). Recover the drafts by the quote's deal_uuid so we don't lose them. + const quote = await ctx.db('quotes').where({ id: quoteId }).first('deal_uuid'); + invoiceIds = quote?.deal_uuid + ? (await ctx.db('invoices').where({ deal_uuid: quote.deal_uuid }).select('id')).map((r) => r.id) + : []; + if (!invoiceIds.length) throw err; + } + ctx.vars.preparedInvoiceIds = invoiceIds; + return { invoice_prepared: invoiceIds }; +}); + +// Send a prepared draft document (config.document = 'invoice' | 'contract'). +registry.registerAction('send_document', async (ctx) => { + const doc = ctx.node.config?.document || 'invoice'; + if (ctx.vars?.__dryRun) return { dryRun: true, would: 'send_document', document: doc }; + const adminId = await resolveActor(ctx); + + if (doc === 'invoice') { + const ids = ctx.vars.preparedInvoiceIds || []; + if (!ids.length) return { skipped: true, reason: 'no prepared invoice to send' }; + const invoiceService = require('../invoiceService'); + let sent = 0; + for (const id of ids) { await invoiceService.sendInvoice(id, adminId); sent += 1; } + return { invoices_sent: sent }; + } + if (doc === 'contract') { + const cid = ctx.vars.preparedContractId; + if (!cid) return { skipped: true, reason: 'no prepared contract to send' }; + await require('../contractService').sendContract(cid, adminId); + return { contract_sent: cid }; + } + return { skipped: true, reason: `send_document for '${doc}' not implemented yet` }; +}); + // Create/prepare-document actions — registered so flows referencing them are // valid; service wiring is a follow-up. Records a skipped step (observable). for (const key of DOCUMENT_ACTIONS) { diff --git a/backend/src/utils/appSettings.js b/backend/src/utils/appSettings.js index 29a989d6..df88036e 100644 --- a/backend/src/utils/appSettings.js +++ b/backend/src/utils/appSettings.js @@ -25,8 +25,11 @@ const { db } = require('../database/db'); * JSON.parse on the way out. Falls back to the raw string on * malformed JSON so legacy text values still work. */ -async function getAppSetting(key, defaultValue = null) { - const row = await db('app_settings').where({ setting_key: key }).first(); +async function getAppSetting(key, defaultValue = null, conn = db) { + // Callers reading from inside a knex transaction must pass that trx as + // `conn`, otherwise the global-`db` read grabs a second connection from + // the single-connection SQLite pool while the trx holds it → deadlock. + const row = await conn('app_settings').where({ setting_key: key }).first(); if (!row || row.setting_value == null) return defaultValue; try { return JSON.parse(row.setting_value); 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 2/7] 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' }; From 9414b42b7fae9f5447c1879e9b2c34d05f7c888d Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sat, 27 Jun 2026 00:49:35 +0200 Subject: [PATCH 3/7] feat(workflows): implement remaining stub actions (prepare_quote, prepare_gallery, reserve_date) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These were the last guard-stubbed actions — offered in the builder palette but refused on enable. Now all three are real, backed by existing converters: - prepare_gallery: alias of prepare_event (a gallery IS an event in picpeak). - reserve_date: convertToEvent({ skipInvoices: true }) — a pure draft date hold with no money documents (new skipInvoices option on convertToEvent). - prepare_quote: createQuote (customer entity) or duplicateQuote (quote entity), producing a status='draft' quote; idempotent via ctx.vars.preparedQuoteId. With no stubs left, the enable-guard switches from a hardcoded DOCUMENT_ACTIONS list to a registry lookup: an action node whose config.action has no registered handler is unimplementable. This can't drift from what the engine can run and also catches typo'd/future actions. (Fixes the enable-route node mapping to carry node.type so the action-node filter matches.) Extends the single-connection SQLite in-trx deadlock fixes to the quote-create path (prepare_quote runs unattended): nextQuoteNumber reads getAppSetting through trx, createQuote logs via trx and hoists its hasColumnCached schema-drift checks before the transaction. Adds tests for reserve_date (no invoices), prepare_quote (draft, no deadlock), and registry coverage; retargets the enable-guard refusal test at a genuinely unregistered action. Full backend suite: 985 passed, 1 skipped. --- .../integration/bookingCutover.test.js | 25 +++++ .../integration/workflowRoutes.test.js | 6 +- backend/src/routes/adminWorkflows.js | 16 ++- backend/src/services/quoteService.js | 101 +++++++++++------- backend/src/services/workflows/actions.js | 69 ++++++------ 5 files changed, 134 insertions(+), 83 deletions(-) diff --git a/backend/__tests__/integration/bookingCutover.test.js b/backend/__tests__/integration/bookingCutover.test.js index 60347196..ce938f23 100644 --- a/backend/__tests__/integration/bookingCutover.test.js +++ b/backend/__tests__/integration/bookingCutover.test.js @@ -76,6 +76,31 @@ describe('booking cutover — draft invoices on hold', () => { expect(q.converted_event_id).toBe(res.eventId); }); + it('reserve_date path (convertToEvent skipInvoices) creates a draft event with NO invoices', async () => { + const quoteId = await acceptedQuote(); + const res = await quoteService.convertToEvent(quoteId, adminId, { hold: true, skipInvoices: true }); + expect(res.eventId).toBeGreaterThanOrEqual(1); + expect(res.invoiceIds).toEqual([]); + const invCount = await db('invoices').where({ event_id: res.eventId }).count({ c: '*' }).first(); + expect(Number(invCount.c)).toBe(0); // pure date hold — no money documents + }); + + it('prepare_quote path (duplicateQuote) creates a new DRAFT quote — no in-trx deadlock', async () => { + const quoteId = await acceptedQuote(); + const newId = await quoteService.duplicateQuote(quoteId, adminId); + expect(newId).toBeGreaterThanOrEqual(1); + expect(newId).not.toBe(quoteId); + const q = await db('quotes').where({ id: newId }).first(); + expect(q.status).toBe('draft'); + }); + + it('registers prepare_gallery / reserve_date / prepare_quote as real actions', () => { + const { registry } = require('../../src/services/workflows'); // loads actions.js (side-effect registration) + for (const a of ['prepare_gallery', 'reserve_date', 'prepare_quote', 'prepare_event', 'prepare_invoice', 'send_document']) { + expect(typeof registry.getAction(a)).toBe('function'); + } + }); + 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 1ae6a831..c5126c84 100644 --- a/backend/__tests__/integration/workflowRoutes.test.js +++ b/backend/__tests__/integration/workflowRoutes.test.js @@ -71,16 +71,16 @@ describe('admin workflows API', () => { expect(res.body.error).toMatch(/unknown node type/i); }); - test('refuses to enable a flow that uses an unimplemented action', async () => { + test('refuses to enable a flow that uses an unregistered 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_gallery' } }], + nodes: [{ node_key: 't', type: 'trigger' }, { node_key: 'a', type: 'action', config: { action: 'totally_not_a_real_action' } }], 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_gallery/i); + expect(res.body.error).toMatch(/not.*implemented|totally_not_a_real_action/i); }); test('allows enabling a flow using the now-implemented booking invoice actions', async () => { diff --git a/backend/src/routes/adminWorkflows.js b/backend/src/routes/adminWorkflows.js index 0b17babf..5823a0ad 100644 --- a/backend/src/routes/adminWorkflows.js +++ b/backend/src/routes/adminWorkflows.js @@ -24,7 +24,6 @@ const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); const { requireFeatureFlag } = require('../middleware/requireFeatureFlag'); const workflows = require('../services/workflows'); -const { DOCUMENT_ACTIONS } = require('../services/workflows/actions'); const { hasColumnCached } = require('../utils/schemaCache'); router.use(adminAuth, requireFeatureFlag('workflows')); @@ -35,9 +34,6 @@ const MAX_NODES = 200; const MAX_EDGES = 500; const MAX_NODE_CONFIG_BYTES = 16 * 1024; const VALID_NODE_TYPES = new Set(['trigger', 'action', 'condition', 'branch', 'loop', 'wait', 'gate', 'webhook']); -// Actions registered but not yet wired (return {skipped:true}); a flow that -// uses any of these can't be meaningfully enabled. -const UNIMPLEMENTED_ACTIONS = new Set(DOCUMENT_ACTIONS); function parseJson(v, fallback) { if (v == null) return fallback; @@ -65,13 +61,15 @@ function validateGraph(body) { return null; } -// The unimplemented (stub) actions a graph references — used to refuse enabling -// a flow that would silently no-op (e.g. the booking built-ins' prepare_*/send). +// The unimplemented actions a graph references — any action node whose +// `config.action` has no registered handler. Used to refuse enabling a flow +// that would silently no-op at runtime (typo'd or future-but-unwired actions). +// Registry-driven so it can't drift from what the engine can actually run. function unimplementedActionsIn(nodes = []) { const found = new Set(); for (const n of nodes) { - const action = n && n.config && n.config.action; - if (action && UNIMPLEMENTED_ACTIONS.has(action)) found.add(action); + const action = n && n.type === 'action' && n.config && n.config.action; + if (action && !workflows.registry.getAction(action)) found.add(action); } return [...found]; } @@ -246,7 +244,7 @@ router.patch('/:id/enabled', requirePermission('workflows.manage'), async (req, // prepare_*/send_document stubs). Concern #5 from review. if (enabled) { const rows = await db('workflow_nodes').where({ workflow_id: id, version: wf.version }); - const stubs = unimplementedActionsIn(rows.map((n) => ({ config: parseJson(n.config, {}) }))); + const stubs = unimplementedActionsIn(rows.map((n) => ({ type: n.type, config: parseJson(n.config, {}) }))); if (stubs.length) { return res.status(409).json({ error: `This flow can't be enabled yet — it uses actions that aren't implemented: ${stubs.join(', ')}.` }); } diff --git a/backend/src/services/quoteService.js b/backend/src/services/quoteService.js index 8578d316..ac1b2f2d 100644 --- a/backend/src/services/quoteService.js +++ b/backend/src/services/quoteService.js @@ -299,7 +299,10 @@ function formatNumberInTemplate(format, year, seq) { // The previous SELECT-MAX-then-INSERT path raced under concurrent // admin creates and could emit `Q-2026-AB12C3` after 5 retries. async function nextQuoteNumber(trx) { - const format = (await getAppSetting('crm_quotes_number_format')) || 'Q-{YEAR}-{SEQ:04d}'; + // Read through `trx` when present — getAppSetting on the global db inside an + // open transaction deadlocks the single-connection SQLite pool (prepare_quote + // runs createQuote unattended from a workflow). + const format = (await getAppSetting('crm_quotes_number_format', null, trx || db)) || 'Q-{YEAR}-{SEQ:04d}'; const year = new Date().getFullYear(); const seq = await claimNextSequence('quote', year, trx); return formatNumberInTemplate(format, year, seq); @@ -521,6 +524,15 @@ async function createQuote(payload, adminId) { // Resolve bank account for the chosen currency. const bank = await businessProfileService.resolveBankAccountForCurrency(currency, payload.businessBankAccountId); + // Resolve schema-drift column checks BEFORE the transaction — a cold + // hasColumnCached lookup hits the global db, which deadlocks the single- + // connection SQLite pool if issued inside the trx (prepare_quote runs this + // unattended from a workflow). + const hasProjectId = await hasColumnCached('quotes', 'project_id'); + const hasVatCode = await hasColumnCached('quotes', 'vat_code'); + const hasEventType = await hasColumnCached('quotes', 'event_type'); + const hasBookingWorkflowId = await hasColumnCached('quotes', 'booking_workflow_id'); + return await db.transaction(async (trx) => { // SQLite's 1-connection default deadlocks when claimNextSequence // opens its own micro-transaction inside this outer one — thread @@ -574,21 +586,21 @@ async function createQuote(payload, adminId) { updated_at: new Date(), }; // Migration 121 — optional link to a Project Overview project. - if (payload.projectId !== undefined && await hasColumnCached('quotes', 'project_id')) { + if (payload.projectId !== undefined && hasProjectId) { row.project_id = payload.projectId || null; } // Migration 130 — snapshot the chosen output VAT code (immutable; the export // emits exactly this rather than re-deriving from the mutable rate→code map). - if (payload.vatCode !== undefined && await hasColumnCached('quotes', 'vat_code')) { + if (payload.vatCode !== undefined && hasVatCode) { row.vat_code = payload.vatCode ? String(payload.vatCode).slice(0, 16) : null; } // Migration 146 — event type (event_types.slug_prefix). Drives the type of // the event the quote converts into, instead of the old hardcoded 'wedding'. - if (payload.eventType !== undefined && await hasColumnCached('quotes', 'event_type')) { + if (payload.eventType !== undefined && hasEventType) { row.event_type = payload.eventType ? String(payload.eventType).slice(0, 64) : null; } // Migration 147 — the booking workflow this quote runs on acceptance. - if (payload.bookingWorkflowId !== undefined && await hasColumnCached('quotes', 'booking_workflow_id')) { + if (payload.bookingWorkflowId !== undefined && hasBookingWorkflowId) { row.booking_workflow_id = payload.bookingWorkflowId || null; } const inserted = await trx('quotes').insert(row).returning('id'); @@ -620,7 +632,9 @@ async function createQuote(payload, adminId) { } try { - await logActivity('quote_created', { quoteId, quoteNumber, customerAccountId: payload.customerAccountId }, null, `admin:${adminId}`); + // Pass `trx` so the audit insert rides the transaction's connection — + // the global db here deadlocks the single-connection SQLite pool. + await logActivity('quote_created', { quoteId, quoteNumber, customerAccountId: payload.customerAccountId }, null, `admin:${adminId}`, trx); } catch (_) {} logger.info('Quote created', { adminId, quoteId, quoteNumber }); @@ -1610,41 +1624,46 @@ async function convertToEvent(quoteId, adminId, options = {}) { ? paymentTermSnapshot.installments : [{ percent: 100, trigger: 'after_delivery', offset_days: 0, label: 'Total' }]; - const spawnResult = await invoiceService.scheduleInvoicesForEvent({ - trx, - eventId, - quoteId: quote.id, - customer, - currency: quote.currency, - language: quote.language, - lineItems, - totals: { - net: quote.net_amount_minor, - vatRate: quote.vat_rate, - vat: quote.vat_amount_minor, - shipping: quote.shipping_amount_minor, - total: quote.total_amount_minor, - }, - installments, - eventDate: quote.event_date, - // Inline event snapshot — same rationale as convertToInvoiceOnly - // above (migration 123). - eventName: quote.event_name, - eventTimeStart: quote.event_time_start, - eventTimeEnd: quote.event_time_end, - adminId, - ccPdfEmail: quote.cc_pdf_email, - // Net 14 / 30 / 60 / 90 carry through from the quote's - // payment-term template (same as convertToInvoiceOnly). - netDays: paymentTermSnapshot?.net_days, - // 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, - }); + // `skipInvoices` (workflow reserve_date): create the event as a pure date + // hold — no invoices scheduled at all. The other booking actions handle + // money documents separately. + const spawnResult = options.skipInvoices === true + ? { invoiceIds: [] } + : await invoiceService.scheduleInvoicesForEvent({ + trx, + eventId, + quoteId: quote.id, + customer, + currency: quote.currency, + language: quote.language, + lineItems, + totals: { + net: quote.net_amount_minor, + vatRate: quote.vat_rate, + vat: quote.vat_amount_minor, + shipping: quote.shipping_amount_minor, + total: quote.total_amount_minor, + }, + installments, + eventDate: quote.event_date, + // Inline event snapshot — same rationale as convertToInvoiceOnly + // above (migration 123). + eventName: quote.event_name, + eventTimeStart: quote.event_time_start, + eventTimeEnd: quote.event_time_end, + adminId, + ccPdfEmail: quote.cc_pdf_email, + // Net 14 / 30 / 60 / 90 carry through from the quote's + // payment-term template (same as convertToInvoiceOnly). + netDays: paymentTermSnapshot?.net_days, + // 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({ status: 'converted', diff --git a/backend/src/services/workflows/actions.js b/backend/src/services/workflows/actions.js index f4eb7e18..91412acf 100644 --- a/backend/src/services/workflows/actions.js +++ b/backend/src/services/workflows/actions.js @@ -16,16 +16,6 @@ */ const registry = require('./registry'); -// Document actions still awaiting wiring (the enable-guard refuses flows that use -// 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_gallery', - 'reserve_date', -]; - // --- Conditions --- // True once the run's invoice entity is settled (paid_at set, status paid, or @@ -241,26 +231,54 @@ 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) => { +// Create a DRAFT event/gallery from the accepted quote. convertToEvent creates +// the event as is_draft=true AND (unless skipInvoices) 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). +// Shared by prepare_event, prepare_gallery (alias — a gallery IS an event in +// picpeak), and reserve_date (skipInvoices: a pure date hold, no money docs). +async function doPrepareEvent(ctx, label, { skipInvoices = false } = {}) { 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.run.entity_type !== 'quote' || !quoteId) return { skipped: true, reason: `${label} needs a quote entity` }; + if (ctx.vars?.__dryRun) return { dryRun: true, would: label, 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 }); + const res = await require('../quoteService').convertToEvent(quoteId, adminId, { hold: true, skipInvoices }); 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 }; +} + +registry.registerAction('prepare_event', (ctx) => doPrepareEvent(ctx, 'prepare_event')); +// A gallery IS an event in picpeak — same draft-seam behaviour. +registry.registerAction('prepare_gallery', (ctx) => doPrepareEvent(ctx, 'prepare_gallery')); +// Reserve the date only: create the draft event as a pure calendar hold with NO +// invoices. A flow can invoice later (or never). +registry.registerAction('reserve_date', (ctx) => doPrepareEvent(ctx, 'reserve_date', { skipInvoices: true })); + +// Create a DRAFT quote. From a quote entity (e.g. quote.declined → re-quote) it +// duplicates that quote; from a customer entity (customer.created) it opens a +// blank draft quote for them. Idempotent via ctx.vars.preparedQuoteId. +registry.registerAction('prepare_quote', async (ctx) => { + if (ctx.vars?.__dryRun) return { dryRun: true, would: 'prepare_quote', entity: ctx.run.entity_type }; + if (ctx.vars.preparedQuoteId) return { already: true, quoteId: ctx.vars.preparedQuoteId }; + const adminId = await resolveActor(ctx); + const quoteService = require('../quoteService'); + let quoteId; + if (ctx.run.entity_type === 'quote' && ctx.run.entity_id) { + quoteId = await quoteService.duplicateQuote(ctx.run.entity_id, adminId); + } else if (ctx.run.entity_type === 'customer' && ctx.run.entity_id) { + quoteId = await quoteService.createQuote({ customerAccountId: ctx.run.entity_id }, adminId); + } else { + return { skipped: true, reason: 'prepare_quote needs a quote or customer entity' }; + } + ctx.vars.preparedQuoteId = quoteId; + return { quote_prepared: quoteId }; }); // Prepare DRAFT invoice(s) from the accepted quote — created on HOLD (no @@ -315,13 +333,4 @@ registry.registerAction('send_document', async (ctx) => { return { skipped: true, reason: `send_document for '${doc}' not implemented yet` }; }); -// Create/prepare-document actions — registered so flows referencing them are -// valid; service wiring is a follow-up. Records a skipped step (observable). -for (const key of DOCUMENT_ACTIONS) { - registry.registerAction(key, async (ctx) => { - ctx.logger?.warn?.('[workflow] document action not yet wired', { action: key, runId: ctx.run.id }); - return { skipped: true, reason: `action ${key} not yet implemented` }; - }); -} - -module.exports = { DOCUMENT_ACTIONS }; +module.exports = {}; From 7727b6714b5654bab067b041ca9c33cf7d9270ab Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sat, 27 Jun 2026 01:03:31 +0200 Subject: [PATCH 4/7] feat(workflows): make approval rows clickable to open the underlying document Each approval asks the admin to confirm/deny, but they couldn't see what they were approving. The row's prompt/meta area is now a clickable button that navigates to the run entity's detail page (quote -> /admin/quotes/:id, invoice -> /admin/bills/:id, event/contract/customer likewise) so the admin can review before deciding. Confirm/Deny stay as separate buttons; rows whose entity has no detail route (or no entity) render as plain, non-clickable text. Adds the approvals.openEntity tooltip string (en + de). --- frontend/src/i18n/locales/de.json | 1 + frontend/src/i18n/locales/en.json | 1 + .../admin/workflows/WorkflowApprovalsPage.tsx | 52 ++++++++++++++----- 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index a0ea71e3..fd40a049 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -238,6 +238,7 @@ "defaultPrompt": "Ein Workflow benötigt deine Bestätigung.", "pendingTitle": "Offene Freigaben", "viewAll": "Alle Freigaben ansehen", + "openEntity": "{{type}} #{{id}} öffnen", "acted": "Erledigt" }, "editor": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index fd5708d0..76f9d374 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -238,6 +238,7 @@ "defaultPrompt": "A workflow needs your confirmation.", "pendingTitle": "Pending approvals", "viewAll": "View all approvals", + "openEntity": "Open {{type}} #{{id}}", "acted": "Done" }, "editor": { diff --git a/frontend/src/pages/admin/workflows/WorkflowApprovalsPage.tsx b/frontend/src/pages/admin/workflows/WorkflowApprovalsPage.tsx index 6f4f7eed..b036363b 100644 --- a/frontend/src/pages/admin/workflows/WorkflowApprovalsPage.tsx +++ b/frontend/src/pages/admin/workflows/WorkflowApprovalsPage.tsx @@ -36,6 +36,17 @@ export const WorkflowApprovalsPage: React.FC = () => { const promptOf = (a: WorkflowApproval) => (a.payload && (a.payload.prompt as string)) || t('workflows.approvals.defaultPrompt', 'A workflow needs your confirmation.'); + // The admin detail route for the run's entity, so a row click opens the + // document they're being asked to approve. `invoice` lives under /bills. + const entityHref = (a: WorkflowApproval): string | null => { + if (!a.entity_type || a.entity_id == null) return null; + const base: Record = { + quote: 'quotes', invoice: 'bills', event: 'events', contract: 'contracts', customer: 'customers', + }; + const seg = base[a.entity_type]; + return seg ? `/admin/${seg}/${a.entity_id}` : null; + }; + return (
@@ -55,24 +66,41 @@ export const WorkflowApprovalsPage: React.FC = () => {
{t('workflows.approvals.empty', 'Nothing waiting for you right now.')}
) : (
    - {approvals.map((a) => ( -
  • -
    + {approvals.map((a) => { + const href = entityHref(a); + const meta = ( + <>
    {promptOf(a)}
    {a.workflow_name} {a.entity_type ? ` · ${a.entity_type} #${a.entity_id}` : ''} {a.created_at ? ` · ${formatDateTime(a.created_at)}` : ''}
    -
    - - -
  • - ))} + + ); + return ( +
  • + {href ? ( + + ) : ( +
    {meta}
    + )} + + +
  • + ); + })}
)} From 882cfc0661b02602bae91b928a46ceea754f7f29 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sat, 27 Jun 2026 12:36:35 +0200 Subject: [PATCH 5/7] =?UTF-8?q?fix(workflows):=20held=20booking=20invoices?= =?UTF-8?q?=20are=20'scheduled',=20not=20'pending=5Fdelivery'=20=E2=80=94?= =?UTF-8?q?=20so=20send=5Fdocument=20can=20issue=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A quote with no explicit payment timing falls back to a single after_delivery installment. spawnInstallmentInvoices marked those 'pending_delivery' even in hold mode, so the booking flow's send_document -> sendInvoice threw 'Cannot send invoice with status pending_delivery', the run failed, and no invoice email went out (the symptom: approve the quote->invoice flow, receive nothing). In hold mode the flow's review gate + explicit send_document IS the delivery release, so a held invoice is always 'scheduled' (editable + sendable) regardless of trigger; scheduled_send_at stays null so the scheduler never auto-sends it. Non-hold after_delivery invoices keep 'pending_delivery' as before. Adds a regression test (default after_delivery term -> draft -> scheduled+null). --- .../integration/bookingCutover.test.js | 23 +++++++++++++++++++ backend/src/services/invoiceService.js | 14 +++++++---- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/backend/__tests__/integration/bookingCutover.test.js b/backend/__tests__/integration/bookingCutover.test.js index ce938f23..3f4d7764 100644 --- a/backend/__tests__/integration/bookingCutover.test.js +++ b/backend/__tests__/integration/bookingCutover.test.js @@ -76,6 +76,29 @@ describe('booking cutover — draft invoices on hold', () => { expect(q.converted_event_id).toBe(res.eventId); }); + it('draft mode with the DEFAULT (after_delivery) payment term yields a SENDABLE scheduled invoice, not pending_delivery', async () => { + // Reproduces the booking_invoice_only flow on a quote with no explicit + // payment timing: the default installment is after_delivery, which would + // otherwise be pending_delivery — a status sendInvoice (send_document) rejects. + const dealUuid = crypto.randomUUID(); + const [quoteId] = await db('quotes').insert({ + quote_number: `Q-${dealUuid.slice(0, 8)}`, + customer_account_id: customerId, + status: 'accepted', + currency: 'CHF', + issue_date: '2026-01-01', + net_amount_minor: 50000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 50000, + // No payment_term_snapshot → spawnInstallmentInvoices falls back to a single + // 100% after_delivery installment. + deal_uuid: dealUuid, + created_by_admin_id: adminId, + }); + const res = await quoteService.convertToInvoiceOnly(quoteId, adminId, { draft: true }); + const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first(); + expect(inv.status).toBe('scheduled'); // sendInvoice accepts this + expect(inv.scheduled_send_at == null).toBe(true); // still held — no auto-send + }); + it('reserve_date path (convertToEvent skipInvoices) creates a draft event with NO invoices', async () => { const quoteId = await acceptedQuote(); const res = await quoteService.convertToEvent(quoteId, adminId, { hold: true, skipInvoices: true }); diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js index 426c5370..13258ccb 100644 --- a/backend/src/services/invoiceService.js +++ b/backend/src/services/invoiceService.js @@ -1077,11 +1077,15 @@ async function spawnInstallmentInvoices({ trx, eventId, quoteId, customer, curre // status `scheduled`, so they sit idle until the admin clicks // "Release for delivery" on the invoice detail page. const isDeliveryTrigger = inst.trigger === 'after_delivery'; - const rowStatus = isDeliveryTrigger ? 'pending_delivery' : 'scheduled'; - // `hold` (workflow draft-seam): create the invoice but leave scheduled_send_at - // NULL so the scheduler never auto-sends it — a draft awaiting an explicit - // send_document after the admin's review gate. Status stays 'scheduled' so - // it's editable and sendInvoice can later issue it. + // `hold` (workflow draft-seam): the booking flow's review gate + explicit + // send_document IS the release, so a held invoice is always `scheduled` + // (editable + sendable via sendInvoice) regardless of trigger — never + // `pending_delivery`, which sendInvoice refuses. Without hold, an + // after_delivery invoice stays `pending_delivery` as before. + const rowStatus = (isDeliveryTrigger && !hold) ? 'pending_delivery' : 'scheduled'; + // Held invoices carry no scheduled_send_at so the scheduler never auto-sends + // them — they wait for send_document. after_delivery rows are likewise null + // (the scheduler can't infer a delivery date). const rowScheduledSendAt = (isDeliveryTrigger || hold) ? null : scheduledSendAt; const invoiceNumber = await nextInvoiceNumber(trx); From 539a83711d1996dc9c262365f2c511e7bc445add Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:30:26 +0200 Subject: [PATCH 6/7] fix(workflows): defer quote.accepted/declined emit until the 15-min response window locks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The customer's accept/decline can be toggled for crm_quotes_accept_window_minutes (default 15) before it locks, and the public page promises exactly that. But the booking workflow fired on the FIRST accept click and immediately converted the quote (status -> 'converted'), so a decline within the window was rejected ('Quote cannot be responded to in status converted') — the grace period was dead on arrival. recordResponse / adminAcceptQuote now DEFER the workflow emit while the toggle window is open; the new scheduler sweep finalizeQuoteResponses fires the FINAL status once response_locked_at passes (idempotent via the new quotes.workflow_response_emitted_at column, migration 149). A response recorded with the window already closed (0-min window, or admin decline which locks immediately) still emits inline. So toggling accept->decline->accept inside the window converts at most once, for the final state, after the customer's grace period — and a plain decline never converts. Trade-off: with the hourly CRM scheduler, the booking flow now starts up to ~1h after the window locks instead of instantly. Acceptable — the flow gates on admin review anyway, and the alternative (graph-level wait) wouldn't reach already- enabled built-ins (admin_toggled_at blocks re-seed). Adds a finalize sweep test (deferred while open, fires + stamps once locked, idempotent). --- .../integration/bookingCutover.test.js | 34 +++++++++ .../core/149_add_quote_workflow_emitted_at.js | 30 ++++++++ .../src/services/invoiceSchedulerService.js | 10 +++ backend/src/services/quoteService.js | 73 ++++++++++++++++++- 4 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 backend/migrations/core/149_add_quote_workflow_emitted_at.js diff --git a/backend/__tests__/integration/bookingCutover.test.js b/backend/__tests__/integration/bookingCutover.test.js index 3f4d7764..563d4fd6 100644 --- a/backend/__tests__/integration/bookingCutover.test.js +++ b/backend/__tests__/integration/bookingCutover.test.js @@ -99,6 +99,40 @@ describe('booking cutover — draft invoices on hold', () => { expect(inv.scheduled_send_at == null).toBe(true); // still held — no auto-send }); + it('finalizeQuoteResponses only fires once the 15-min response window has locked', async () => { + const mk = async (lockOffsetMs) => { + const dealUuid = crypto.randomUUID(); + const [id] = await db('quotes').insert({ + quote_number: `Q-${dealUuid.slice(0, 8)}`, + customer_account_id: customerId, + status: 'accepted', + currency: 'CHF', issue_date: '2026-01-01', + net_amount_minor: 1000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 1000, + responded_at: new Date().toISOString(), + response_locked_at: new Date(Date.now() + lockOffsetMs).toISOString(), + accepted_at: new Date().toISOString(), + deal_uuid: dealUuid, + created_by_admin_id: adminId, + }); + return id; + }; + const openId = await mk(15 * 60 * 1000); // still inside the window + const lockedId = await mk(-60 * 1000); // window already closed + + const emitted = await quoteService.finalizeQuoteResponses(); + expect(emitted).toBeGreaterThanOrEqual(1); + + const open = await db('quotes').where({ id: openId }).first(); + const locked = await db('quotes').where({ id: lockedId }).first(); + expect(open.workflow_response_emitted_at == null).toBe(true); // deferred — not yet fired + expect(locked.workflow_response_emitted_at == null).toBe(false); // fired + stamped + + // Idempotent: a second sweep doesn't re-fire the already-stamped one. + const again = await db('quotes').where({ id: lockedId }) + .whereNull('workflow_response_emitted_at').update({ workflow_response_emitted_at: new Date() }); + expect(again).toBe(0); + }); + it('reserve_date path (convertToEvent skipInvoices) creates a draft event with NO invoices', async () => { const quoteId = await acceptedQuote(); const res = await quoteService.convertToEvent(quoteId, adminId, { hold: true, skipInvoices: true }); diff --git a/backend/migrations/core/149_add_quote_workflow_emitted_at.js b/backend/migrations/core/149_add_quote_workflow_emitted_at.js new file mode 100644 index 00000000..e3689521 --- /dev/null +++ b/backend/migrations/core/149_add_quote_workflow_emitted_at.js @@ -0,0 +1,30 @@ +/** + * Migration 149: defer the quote.accepted/declined workflow emit past the + * 15-min response window. + * + * A customer's accept/decline can be toggled for crm_quotes_accept_window_minutes + * (default 15) before it locks. The booking workflow used to fire on the FIRST + * click and immediately convert the quote (status -> 'converted'), which made the + * quote un-declinable inside that window — defeating the grace period the public + * page promises ("you can change your answer within 15 minutes"). + * + * The fix moves the response emit to AFTER the window locks: the scheduler sweeps + * locked-but-not-yet-emitted responses and fires quote. once. This + * column is the idempotency marker so each response is emitted exactly once, + * regardless of how many times the customer toggled inside the window. + */ +exports.up = async function (knex) { + if (!(await knex.schema.hasTable('quotes'))) return; + if (!(await knex.schema.hasColumn('quotes', 'workflow_response_emitted_at'))) { + await knex.schema.alterTable('quotes', (t) => { + t.timestamp('workflow_response_emitted_at'); + }); + } +}; + +exports.down = async function (knex) { + if (!(await knex.schema.hasTable('quotes'))) return; + if (await knex.schema.hasColumn('quotes', 'workflow_response_emitted_at')) { + await knex.schema.alterTable('quotes', (t) => t.dropColumn('workflow_response_emitted_at')); + } +}; diff --git a/backend/src/services/invoiceSchedulerService.js b/backend/src/services/invoiceSchedulerService.js index a6ec2644..d10f4ace 100644 --- a/backend/src/services/invoiceSchedulerService.js +++ b/backend/src/services/invoiceSchedulerService.js @@ -27,6 +27,7 @@ const cron = require('node-cron'); const invoiceService = require('./invoiceService'); const eventReminderService = require('./eventReminderService'); +const quoteService = require('./quoteService'); const logger = require('../utils/logger'); let task = null; @@ -42,6 +43,15 @@ async function runTick() { } catch (err) { logger.error('Event reminder pass failed', { err: err.message }); } + try { + // Fire workflow events for quote responses whose 15-min toggle window has + // now locked (deferred at response time so accepting can't convert the quote + // before the customer's grace period to change their mind expires). + const finalized = await quoteService.finalizeQuoteResponses(); + if (finalized) logger.info('CRM scheduler: finalized locked quote responses', { finalized }); + } catch (err) { + logger.error('Quote response finalize pass failed', { err: err.message }); + } try { // Resume workflow runs whose wait has elapsed. No-op (fails closed) when // the `workflows` feature flag is off. Independent try/catch so a workflow diff --git a/backend/src/services/quoteService.js b/backend/src/services/quoteService.js index ac1b2f2d..a29bcc6d 100644 --- a/backend/src/services/quoteService.js +++ b/backend/src/services/quoteService.js @@ -1114,6 +1114,65 @@ async function emitQuoteEvent(quote, status) { } catch (_) { /* best-effort */ } } +/** + * Emit a quote accept/decline to the workflow engine — but only once the + * customer's response window has LOCKED. While the window is open (the public + * page lets them flip accept↔decline for crm_quotes_accept_window_minutes), an + * immediate emit would let the booking flow convert the quote right away, + * defeating the grace period (the quote went straight to 'converted' and could + * no longer be declined). So: + * - window already closed (0-minute window, or admin decline) → emit now and + * stamp `workflow_response_emitted_at` (idempotent claim). + * - window still open → defer; `finalizeQuoteResponses` (scheduler) fires the + * FINAL status once it locks, so toggling inside the window never converts. + * Returns true if it emitted, false if deferred / already emitted. + */ +async function maybeEmitQuoteResponse(quote, status, responseLockedAt) { + const locked = !responseLockedAt || new Date(responseLockedAt).getTime() <= Date.now(); + if (!locked) return false; // deferred to the finalize sweep + const hasCol = await hasColumnCached('quotes', 'workflow_response_emitted_at'); + if (hasCol) { + // Atomically claim the emit so a concurrent finalize sweep can't double-fire. + const claimed = await db('quotes').where({ id: quote.id }) + .whereNull('workflow_response_emitted_at') + .update({ workflow_response_emitted_at: new Date() }); + if (!claimed) return false; // already emitted elsewhere + } + await emitQuoteEvent(quote, status); + return true; +} + +/** + * Scheduler sweep: fire the workflow event for quote responses whose toggle + * window has now locked but which were deferred at response time. Idempotent via + * `workflow_response_emitted_at` (atomic claim). Called from the CRM scheduler + * tick. Returns the number emitted. + */ +async function finalizeQuoteResponses(limit = 200) { + const hasCol = await hasColumnCached('quotes', 'workflow_response_emitted_at'); + if (!hasCol) return 0; // pre-migration install — nothing to finalise + // The unemitted accept/decline set is naturally small (a row leaves it the + // moment it's emitted), so fetch the candidates and compare the lock time in + // JS — avoids SQLite/Postgres date-string comparison pitfalls. + const now = Date.now(); + const candidates = await db('quotes') + .whereIn('status', ['accepted', 'declined']) + .whereNull('workflow_response_emitted_at') + .whereNotNull('response_locked_at') + .limit(limit); + const rows = candidates.filter((q) => new Date(q.response_locked_at).getTime() <= now); + let emitted = 0; + for (const q of rows) { + const claimed = await db('quotes').where({ id: q.id }) + .whereNull('workflow_response_emitted_at') + .update({ workflow_response_emitted_at: new Date() }); + if (!claimed) continue; // raced with another tick / the inline emit + await emitQuoteEvent(q, q.status); + emitted += 1; + } + return emitted; +} + async function recordResponse({ token, action, ip, tosAccepted }) { if (!['accept', 'decline'].includes(action)) { throw new AppError('Invalid action', 400); @@ -1197,7 +1256,10 @@ async function recordResponse({ token, action, ip, tosAccepted }) { await logActivity(`quote_${newStatus}`, { quoteId: quote.id, token: tokenRow.token }, null, 'customer:public'); } catch (_) {} - await emitQuoteEvent(quote, newStatus); + // Defer the workflow emit until the 15-min toggle window locks — so accepting + // (then converting) can't strip the customer's ability to decline. The + // scheduler's finalize sweep fires the final status once it locks. + await maybeEmitQuoteResponse(quote, newStatus, responseLockedAt); return { status: newStatus, lockedAt: responseLockedAt }; } @@ -1296,7 +1358,9 @@ async function adminAcceptQuote(id, adminId) { logger.warn('quote_accepted_customer email queue failed', { quoteId: id, err: err.message }); } - await emitQuoteEvent(quote, 'accepted'); + // Same deferral as the public path — an admin "accept on behalf" also opens + // the toggle window, so don't convert until it locks. + await maybeEmitQuoteResponse(quote, 'accepted', responseLockedAt); return { status: 'accepted', lockedAt: responseLockedAt }; } @@ -1361,7 +1425,9 @@ async function adminDeclineQuote(id, adminId, reason = null) { await logActivity('quote_declined_by_admin', { quoteId: id, reason: cleanReason }, null, `admin:${adminId}`); } catch (_) {} - await emitQuoteEvent(quote, 'declined'); + // Admin decline locks the window immediately (response_locked_at = now), so + // this emits straight away (and stamps emitted) rather than deferring. + await maybeEmitQuoteResponse(quote, 'declined', now); return { status: 'declined', declinedAt: now }; } @@ -2022,6 +2088,7 @@ module.exports = { recordResponse, adminAcceptQuote, adminDeclineQuote, + finalizeQuoteResponses, convertToEvent, convertToInvoiceOnly, From 6e20d58487c5e20b08e1d1b4ddd4e76f9e922a79 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:34:36 +0200 Subject: [PATCH 7/7] fix(workflows): make the dashboard pending-approvals card items clickable too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dedicated Approvals page rows open the underlying document on click, but the identical card on the admin dashboard didn't — so 'clickable approvals' only half worked depending on where you looked. Apply the same treatment: the info area is now a button that navigates to the run entity's detail page (quote -> /admin/quotes/:id, invoice -> /admin/bills/:id, etc.), reusing the workflows.approvals.openEntity tooltip. Confirm/Deny stay separate; items with no mappable entity render as plain text. --- frontend/src/pages/admin/AdminDashboard.tsx | 38 +++++++++++++++++---- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/admin/AdminDashboard.tsx b/frontend/src/pages/admin/AdminDashboard.tsx index 142dc4f3..d03aca71 100644 --- a/frontend/src/pages/admin/AdminDashboard.tsx +++ b/frontend/src/pages/admin/AdminDashboard.tsx @@ -96,6 +96,17 @@ export const AdminDashboard: React.FC = () => { onError: (err: any) => toast.error(err?.response?.data?.error || (t('common.error', 'Something went wrong') as string)), }); + // Admin detail route for an approval's run entity, so clicking opens the + // document under review. Mirrors WorkflowApprovalsPage (`invoice` → /bills). + const approvalEntityHref = (a: { entity_type?: string | null; entity_id?: number | null }): string | null => { + if (!a.entity_type || a.entity_id == null) return null; + const base: Record = { + quote: 'quotes', invoice: 'bills', event: 'events', contract: 'contracts', customer: 'customers', + }; + const seg = base[a.entity_type]; + return seg ? `/admin/${seg}/${a.entity_id}` : null; + }; + const isLoading = statsLoading || eventsLoading; if (isLoading) { @@ -277,14 +288,29 @@ export const AdminDashboard: React.FC = () => {
{pendingApprovals.slice(0, 5).map((a) => { const prompt = (a.payload as any)?.prompt as string | undefined; + const href = approvalEntityHref(a); + const info = ( + <> +

{a.workflow_name}

+

+ {prompt || a.type}{a.entity_type ? ` · ${a.entity_type} #${a.entity_id}` : ''} +

+ + ); return (
-
-

{a.workflow_name}

-

- {prompt || a.type}{a.entity_type ? ` · ${a.entity_type} #${a.entity_id}` : ''} -

-
+ {href ? ( + + ) : ( +
{info}
+ )}