diff --git a/backend/__tests__/integration/bookingCutover.test.js b/backend/__tests__/integration/bookingCutover.test.js new file mode 100644 index 00000000..563d4fd6 --- /dev/null +++ b/backend/__tests__/integration/bookingCutover.test.js @@ -0,0 +1,170 @@ +/** + * 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_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('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('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 }); + 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(); + 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..c5126c84 100644 --- a/backend/__tests__/integration/workflowRoutes.test.js +++ b/backend/__tests__/integration/workflowRoutes.test.js @@ -71,16 +71,37 @@ 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_invoice' } }], + 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_invoice/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 () => { + 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/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/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/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/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/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/invoiceService.js b/backend/src/services/invoiceService.js index 497ce2a6..13258ccb 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(); @@ -1075,8 +1077,16 @@ 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'; - const rowScheduledSendAt = isDeliveryTrigger ? null : scheduledSendAt; + // `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); const dueDate = computeDueDate(scheduledSendAt, resolvedNetDays).toISOString().slice(0, 10); @@ -1219,8 +1229,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..a29bcc6d 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 }); @@ -1100,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); @@ -1183,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 }; } @@ -1282,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 }; } @@ -1347,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 }; } @@ -1415,12 +1495,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 +1539,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 +1554,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 = {}) { @@ -1487,7 +1577,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. @@ -1510,7 +1609,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 @@ -1531,7 +1630,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 @@ -1591,37 +1690,46 @@ async function convertToEvent(quoteId, adminId, options = {}) { ? paymentTermSnapshot.installments : [{ percent: 100, trigger: 'after_delivery', offset_days: 0, label: 'Total' }]; - 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, - }); + // `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', @@ -1629,13 +1737,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) { @@ -1975,6 +2088,7 @@ module.exports = { recordResponse, adminAcceptQuote, adminDeclineQuote, + finalizeQuoteResponses, convertToEvent, convertToInvoiceOnly, diff --git a/backend/src/services/workflows/actions.js b/backend/src/services/workflows/actions.js index 468a32bb..91412acf 100644 --- a/backend/src/services/workflows/actions.js +++ b/backend/src/services/workflows/actions.js @@ -16,16 +16,6 @@ */ const registry = require('./registry'); -const DOCUMENT_ACTIONS = [ - 'prepare_quote', - 'prepare_contract', - 'prepare_event', - 'prepare_gallery', - 'prepare_invoice', - 'send_document', - 'reserve_date', -]; - // --- Conditions --- // True once the run's invoice entity is settled (paid_at set, status paid, or @@ -208,13 +198,139 @@ registry.registerAction('webhook', async (ctx) => { : { skipped: true, reason: res.reason }; }); -// 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` }; - }); +// --- 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; } } -module.exports = { DOCUMENT_ACTIONS }; +// 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 }; +}); + +// 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: `${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, 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 +// 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' }; + 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` }; +}); + +module.exports = {}; 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); 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/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}
+ )}
- - - ))} + + ); + return ( +
  • + {href ? ( + + ) : ( +
    {meta}
    + )} + + +
  • + ); + })} )}