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.
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 = {}) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user