Merge pull request #679 from Luca-Timo/feat/booking-cutover
feat(workflows): booking cutover — wire booking actions + hold documents behind approval gates
This commit is contained in:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -71,16 +71,37 @@ describe('admin workflows API', () => {
|
|||||||
expect(res.body.error).toMatch(/unknown node type/i);
|
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({
|
const create = await request(app).post('/api/admin/workflows').set(auth(token)).send({
|
||||||
name: 'Stub flow', trigger_type: 'quote.accepted', enabled: false,
|
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' }],
|
edges: [{ from_node: 't', to_node: 'a' }],
|
||||||
});
|
});
|
||||||
expect(create.status).toBe(201);
|
expect(create.status).toBe(201);
|
||||||
const res = await request(app).patch(`/api/admin/workflows/${create.body.id}/enabled`).set(auth(token)).send({ enabled: true });
|
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.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 () => {
|
test('get one returns the graph', async () => {
|
||||||
|
|||||||
@@ -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.<final status> 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'));
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -645,8 +645,14 @@ async function ensureGlobalCategories() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to log activities
|
// 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 {
|
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
|
// actor_id is integer-typed; some legacy callers pass a hex-string
|
||||||
// identifier (e.g. a 16-char guest fingerprint) which makes Postgres
|
// identifier (e.g. a 16-char guest fingerprint) which makes Postgres
|
||||||
// throw "invalid input syntax for type integer" and drop the entire
|
// 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
|
const actorName = actor?.name
|
||||||
|| (actorIdInt === null && rawId !== undefined && rawId !== null ? String(rawId) : null);
|
|| (actorIdInt === null && rawId !== undefined && rawId !== null ? String(rawId) : null);
|
||||||
|
|
||||||
await db('activity_logs').insert({
|
await conn('activity_logs').insert({
|
||||||
activity_type: activityType,
|
activity_type: activityType,
|
||||||
actor_type: actor?.type || 'system',
|
actor_type: actor?.type || 'system',
|
||||||
actor_id: actorIdInt,
|
actor_id: actorIdInt,
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ const { adminAuth } = require('../middleware/auth');
|
|||||||
const { requirePermission } = require('../middleware/permissions');
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
|
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
|
||||||
const workflows = require('../services/workflows');
|
const workflows = require('../services/workflows');
|
||||||
const { DOCUMENT_ACTIONS } = require('../services/workflows/actions');
|
|
||||||
const { hasColumnCached } = require('../utils/schemaCache');
|
const { hasColumnCached } = require('../utils/schemaCache');
|
||||||
|
|
||||||
router.use(adminAuth, requireFeatureFlag('workflows'));
|
router.use(adminAuth, requireFeatureFlag('workflows'));
|
||||||
@@ -35,9 +34,6 @@ const MAX_NODES = 200;
|
|||||||
const MAX_EDGES = 500;
|
const MAX_EDGES = 500;
|
||||||
const MAX_NODE_CONFIG_BYTES = 16 * 1024;
|
const MAX_NODE_CONFIG_BYTES = 16 * 1024;
|
||||||
const VALID_NODE_TYPES = new Set(['trigger', 'action', 'condition', 'branch', 'loop', 'wait', 'gate', 'webhook']);
|
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) {
|
function parseJson(v, fallback) {
|
||||||
if (v == null) return fallback;
|
if (v == null) return fallback;
|
||||||
@@ -65,13 +61,15 @@ function validateGraph(body) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The unimplemented (stub) actions a graph references — used to refuse enabling
|
// The unimplemented actions a graph references — any action node whose
|
||||||
// a flow that would silently no-op (e.g. the booking built-ins' prepare_*/send).
|
// `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 = []) {
|
function unimplementedActionsIn(nodes = []) {
|
||||||
const found = new Set();
|
const found = new Set();
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
const action = n && n.config && n.config.action;
|
const action = n && n.type === 'action' && n.config && n.config.action;
|
||||||
if (action && UNIMPLEMENTED_ACTIONS.has(action)) found.add(action);
|
if (action && !workflows.registry.getAction(action)) found.add(action);
|
||||||
}
|
}
|
||||||
return [...found];
|
return [...found];
|
||||||
}
|
}
|
||||||
@@ -246,7 +244,7 @@ router.patch('/:id/enabled', requirePermission('workflows.manage'), async (req,
|
|||||||
// prepare_*/send_document stubs). Concern #5 from review.
|
// prepare_*/send_document stubs). Concern #5 from review.
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
const rows = await db('workflow_nodes').where({ workflow_id: id, version: wf.version });
|
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) {
|
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(', ')}.` });
|
return res.status(409).json({ error: `This flow can't be enabled yet — it uses actions that aren't implemented: ${stubs.join(', ')}.` });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,7 +167,10 @@ function formatNumberInTemplate(format, year, seq) {
|
|||||||
* emit `C-2026-AB12C3` after 5 retries.
|
* emit `C-2026-AB12C3` after 5 retries.
|
||||||
*/
|
*/
|
||||||
async function nextContractNumber(trx) {
|
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 year = new Date().getFullYear();
|
||||||
const seq = await claimNextSequence('contract', year, trx);
|
const seq = await claimNextSequence('contract', year, trx);
|
||||||
return formatNumberInTemplate(format, year, seq);
|
return formatNumberInTemplate(format, year, seq);
|
||||||
@@ -1661,6 +1664,11 @@ async function createFromQuote(quoteId, adminId) {
|
|||||||
const hasQuoteContractBackPointer = await hasColumnCached('quotes', 'converted_contract_id');
|
const hasQuoteContractBackPointer = await hasColumnCached('quotes', 'converted_contract_id');
|
||||||
const hasContractEventCols = await hasColumnCached('contracts', 'event_name');
|
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) => {
|
return await db.transaction(async (trx) => {
|
||||||
// Pass trx so the sequence claim joins our outer transaction —
|
// Pass trx so the sequence claim joins our outer transaction —
|
||||||
// SQLite deadlocks otherwise (1-connection default).
|
// SQLite deadlocks otherwise (1-connection default).
|
||||||
@@ -1735,9 +1743,11 @@ async function createFromQuote(quoteId, adminId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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',
|
await logActivity('contract_created_from_quote',
|
||||||
{ contractId, contractNumber, quoteId: quote.id, quoteNumber: quote.quote_number },
|
{ contractId, contractNumber, quoteId: quote.id, quoteNumber: quote.quote_number },
|
||||||
null, await adminActor(adminId));
|
null, actor, trx);
|
||||||
} catch (_) { /* logging is best-effort */ }
|
} catch (_) { /* logging is best-effort */ }
|
||||||
logger.info('Contract created from quote', { adminId, contractId, contractNumber, quoteId: quote.id });
|
logger.info('Contract created from quote', { adminId, contractId, contractNumber, quoteId: quote.id });
|
||||||
return { contractId, alreadyConverted: false };
|
return { contractId, alreadyConverted: false };
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
const cron = require('node-cron');
|
const cron = require('node-cron');
|
||||||
const invoiceService = require('./invoiceService');
|
const invoiceService = require('./invoiceService');
|
||||||
const eventReminderService = require('./eventReminderService');
|
const eventReminderService = require('./eventReminderService');
|
||||||
|
const quoteService = require('./quoteService');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
let task = null;
|
let task = null;
|
||||||
@@ -42,6 +43,15 @@ async function runTick() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Event reminder pass failed', { err: err.message });
|
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 {
|
try {
|
||||||
// Resume workflow runs whose wait has elapsed. No-op (fails closed) when
|
// Resume workflow runs whose wait has elapsed. No-op (fails closed) when
|
||||||
// the `workflows` feature flag is off. Independent try/catch so a workflow
|
// the `workflows` feature flag is off. Independent try/catch so a workflow
|
||||||
|
|||||||
@@ -59,7 +59,9 @@ function formatNumberInTemplate(format, year, seq) {
|
|||||||
// admin creates and emitted a random `R-2026-AB12C3` after 5 retries,
|
// admin creates and emitted a random `R-2026-AB12C3` after 5 retries,
|
||||||
// breaking the §14 UStG single-sequence requirement.
|
// breaking the §14 UStG single-sequence requirement.
|
||||||
async function nextInvoiceNumber(trx) {
|
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 year = new Date().getFullYear();
|
||||||
const seq = await claimNextSequence('invoice', year, trx);
|
const seq = await claimNextSequence('invoice', year, trx);
|
||||||
return formatNumberInTemplate(format, year, seq);
|
return formatNumberInTemplate(format, year, seq);
|
||||||
@@ -998,7 +1000,7 @@ async function spawnInstallmentInvoices({ trx, eventId, quoteId, customer, curre
|
|||||||
ccPdfEmail, netDays,
|
ccPdfEmail, netDays,
|
||||||
eventName, eventTimeStart, eventTimeEnd,
|
eventName, eventTimeStart, eventTimeEnd,
|
||||||
paymentNetDaysTemplateId, paymentTimingTemplateId,
|
paymentNetDaysTemplateId, paymentTimingTemplateId,
|
||||||
paymentTermSnapshot, dealUuid }) {
|
paymentTermSnapshot, dealUuid, hold = false }) {
|
||||||
// Monthly-billing intercept (migration 128). Quote → invoice
|
// Monthly-billing intercept (migration 128). Quote → invoice
|
||||||
// conversion for a monthly-mode customer doesn't fan out N
|
// conversion for a monthly-mode customer doesn't fan out N
|
||||||
// installment invoices — the customer pays one consolidated bill
|
// 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
|
// absent we fall back to the crm_payment_default_net_days setting
|
||||||
// (then 30) rather than silently using 30, matching createInvoice.
|
// (then 30) rather than silently using 30, matching createInvoice.
|
||||||
const resolvedNetDays = ensureInt(netDays)
|
const resolvedNetDays = ensureInt(netDays)
|
||||||
|| ensureInt(await getAppSetting('crm_payment_default_net_days'))
|
|| ensureInt(await getAppSetting('crm_payment_default_net_days', null, trx || db))
|
||||||
|| 30;
|
|| 30;
|
||||||
const total = installments.length;
|
const total = installments.length;
|
||||||
const acceptanceTime = new Date();
|
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
|
// status `scheduled`, so they sit idle until the admin clicks
|
||||||
// "Release for delivery" on the invoice detail page.
|
// "Release for delivery" on the invoice detail page.
|
||||||
const isDeliveryTrigger = inst.trigger === 'after_delivery';
|
const isDeliveryTrigger = inst.trigger === 'after_delivery';
|
||||||
const rowStatus = isDeliveryTrigger ? 'pending_delivery' : 'scheduled';
|
// `hold` (workflow draft-seam): the booking flow's review gate + explicit
|
||||||
const rowScheduledSendAt = isDeliveryTrigger ? null : scheduledSendAt;
|
// 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 invoiceNumber = await nextInvoiceNumber(trx);
|
||||||
const dueDate = computeDueDate(scheduledSendAt, resolvedNetDays).toISOString().slice(0, 10);
|
const dueDate = computeDueDate(scheduledSendAt, resolvedNetDays).toISOString().slice(0, 10);
|
||||||
@@ -1219,8 +1229,11 @@ async function spawnInstallmentInvoices({ trx, eventId, quoteId, customer, curre
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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 },
|
await logActivity('invoice_scheduled', { invoiceId, invoiceNumber, eventId, quoteId, scheduledSendAt },
|
||||||
eventId, `admin:${adminId}`);
|
eventId, `admin:${adminId}`, trx);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
invoiceIds.push(invoiceId);
|
invoiceIds.push(invoiceId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,7 +299,10 @@ function formatNumberInTemplate(format, year, seq) {
|
|||||||
// The previous SELECT-MAX-then-INSERT path raced under concurrent
|
// The previous SELECT-MAX-then-INSERT path raced under concurrent
|
||||||
// admin creates and could emit `Q-2026-AB12C3` after 5 retries.
|
// admin creates and could emit `Q-2026-AB12C3` after 5 retries.
|
||||||
async function nextQuoteNumber(trx) {
|
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 year = new Date().getFullYear();
|
||||||
const seq = await claimNextSequence('quote', year, trx);
|
const seq = await claimNextSequence('quote', year, trx);
|
||||||
return formatNumberInTemplate(format, year, seq);
|
return formatNumberInTemplate(format, year, seq);
|
||||||
@@ -521,6 +524,15 @@ async function createQuote(payload, adminId) {
|
|||||||
// Resolve bank account for the chosen currency.
|
// Resolve bank account for the chosen currency.
|
||||||
const bank = await businessProfileService.resolveBankAccountForCurrency(currency, payload.businessBankAccountId);
|
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) => {
|
return await db.transaction(async (trx) => {
|
||||||
// SQLite's 1-connection default deadlocks when claimNextSequence
|
// SQLite's 1-connection default deadlocks when claimNextSequence
|
||||||
// opens its own micro-transaction inside this outer one — thread
|
// opens its own micro-transaction inside this outer one — thread
|
||||||
@@ -574,21 +586,21 @@ async function createQuote(payload, adminId) {
|
|||||||
updated_at: new Date(),
|
updated_at: new Date(),
|
||||||
};
|
};
|
||||||
// Migration 121 — optional link to a Project Overview project.
|
// 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;
|
row.project_id = payload.projectId || null;
|
||||||
}
|
}
|
||||||
// Migration 130 — snapshot the chosen output VAT code (immutable; the export
|
// Migration 130 — snapshot the chosen output VAT code (immutable; the export
|
||||||
// emits exactly this rather than re-deriving from the mutable rate→code map).
|
// 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;
|
row.vat_code = payload.vatCode ? String(payload.vatCode).slice(0, 16) : null;
|
||||||
}
|
}
|
||||||
// Migration 146 — event type (event_types.slug_prefix). Drives the type of
|
// Migration 146 — event type (event_types.slug_prefix). Drives the type of
|
||||||
// the event the quote converts into, instead of the old hardcoded 'wedding'.
|
// 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;
|
row.event_type = payload.eventType ? String(payload.eventType).slice(0, 64) : null;
|
||||||
}
|
}
|
||||||
// Migration 147 — the booking workflow this quote runs on acceptance.
|
// 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;
|
row.booking_workflow_id = payload.bookingWorkflowId || null;
|
||||||
}
|
}
|
||||||
const inserted = await trx('quotes').insert(row).returning('id');
|
const inserted = await trx('quotes').insert(row).returning('id');
|
||||||
@@ -620,7 +632,9 @@ async function createQuote(payload, adminId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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 (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
logger.info('Quote created', { adminId, quoteId, quoteNumber });
|
logger.info('Quote created', { adminId, quoteId, quoteNumber });
|
||||||
@@ -1100,6 +1114,65 @@ async function emitQuoteEvent(quote, status) {
|
|||||||
} catch (_) { /* best-effort */ }
|
} 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 }) {
|
async function recordResponse({ token, action, ip, tosAccepted }) {
|
||||||
if (!['accept', 'decline'].includes(action)) {
|
if (!['accept', 'decline'].includes(action)) {
|
||||||
throw new AppError('Invalid action', 400);
|
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');
|
await logActivity(`quote_${newStatus}`, { quoteId: quote.id, token: tokenRow.token }, null, 'customer:public');
|
||||||
} catch (_) {}
|
} 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 };
|
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 });
|
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 };
|
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}`);
|
await logActivity('quote_declined_by_admin', { quoteId: id, reason: cleanReason }, null, `admin:${adminId}`);
|
||||||
} catch (_) {}
|
} 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 };
|
return { status: 'declined', declinedAt: now };
|
||||||
}
|
}
|
||||||
@@ -1415,12 +1495,12 @@ async function convertToInvoiceOnly(quoteId, adminId, options = {}) {
|
|||||||
|
|
||||||
const invoiceService = require('./invoiceService');
|
const invoiceService = require('./invoiceService');
|
||||||
|
|
||||||
return await db.transaction(async (trx) => {
|
const result = await db.transaction(async (trx) => {
|
||||||
const installments = Array.isArray(paymentTermSnapshot?.installments)
|
const installments = Array.isArray(paymentTermSnapshot?.installments)
|
||||||
? paymentTermSnapshot.installments
|
? paymentTermSnapshot.installments
|
||||||
: [{ percent: 100, trigger: 'after_delivery', offset_days: 0, label: 'Total' }];
|
: [{ percent: 100, trigger: 'after_delivery', offset_days: 0, label: 'Total' }];
|
||||||
|
|
||||||
await invoiceService.scheduleInvoicesForEvent({
|
const spawnResult = await invoiceService.scheduleInvoicesForEvent({
|
||||||
trx,
|
trx,
|
||||||
// eventId omitted → invoices have source_quote_id but no event_id.
|
// eventId omitted → invoices have source_quote_id but no event_id.
|
||||||
eventId: null,
|
eventId: null,
|
||||||
@@ -1459,6 +1539,10 @@ async function convertToInvoiceOnly(quoteId, adminId, options = {}) {
|
|||||||
// Migration 140 — every spawned invoice inherits the source
|
// Migration 140 — every spawned invoice inherits the source
|
||||||
// quote's deal_uuid so quote + N invoices group under one deal.
|
// quote's deal_uuid so quote + N invoices group under one deal.
|
||||||
dealUuid: quote.deal_uuid,
|
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
|
// Mark quote `converted` without a converted_event_id so the
|
||||||
@@ -1470,14 +1554,20 @@ async function convertToInvoiceOnly(quoteId, adminId, options = {}) {
|
|||||||
updated_at: new Date(),
|
updated_at: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
return { installmentsCreated: installments.length, invoiceIds: spawnResult?.invoiceIds || [] };
|
||||||
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 };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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 = {}) {
|
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);
|
throw new AppError(`Cannot convert a quote with status '${quote.status}'`, 409);
|
||||||
}
|
}
|
||||||
if (quote.converted_event_id) {
|
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
|
// Same guard as convertToInvoiceOnly — refuse if a contract is in
|
||||||
// flight unless the contract→event button re-entered this path.
|
// 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.
|
// Lazy import to avoid the circular dep.
|
||||||
const invoiceService = require('./invoiceService');
|
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:
|
// The events table schema has drifted across migrations:
|
||||||
// installs that ran the original 060 series have
|
// installs that ran the original 060 series have
|
||||||
// host_name/host_email; later ones renamed to customer_*; some
|
// 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
|
// else a configurable org default, else the resolved catch-all (an ACTIVE
|
||||||
// type — never a hardcoded slug the admin may have disabled).
|
// type — never a hardcoded slug the admin may have disabled).
|
||||||
const eventType = (quote.event_type && String(quote.event_type).trim())
|
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));
|
|| (await resolveDefaultEventType(trx));
|
||||||
|
|
||||||
// Each candidate column is paired with the value we'd write. We
|
// Each candidate column is paired with the value we'd write. We
|
||||||
@@ -1591,37 +1690,46 @@ async function convertToEvent(quoteId, adminId, options = {}) {
|
|||||||
? paymentTermSnapshot.installments
|
? paymentTermSnapshot.installments
|
||||||
: [{ percent: 100, trigger: 'after_delivery', offset_days: 0, label: 'Total' }];
|
: [{ percent: 100, trigger: 'after_delivery', offset_days: 0, label: 'Total' }];
|
||||||
|
|
||||||
await invoiceService.scheduleInvoicesForEvent({
|
// `skipInvoices` (workflow reserve_date): create the event as a pure date
|
||||||
trx,
|
// hold — no invoices scheduled at all. The other booking actions handle
|
||||||
eventId,
|
// money documents separately.
|
||||||
quoteId: quote.id,
|
const spawnResult = options.skipInvoices === true
|
||||||
customer,
|
? { invoiceIds: [] }
|
||||||
currency: quote.currency,
|
: await invoiceService.scheduleInvoicesForEvent({
|
||||||
language: quote.language,
|
trx,
|
||||||
lineItems,
|
eventId,
|
||||||
totals: {
|
quoteId: quote.id,
|
||||||
net: quote.net_amount_minor,
|
customer,
|
||||||
vatRate: quote.vat_rate,
|
currency: quote.currency,
|
||||||
vat: quote.vat_amount_minor,
|
language: quote.language,
|
||||||
shipping: quote.shipping_amount_minor,
|
lineItems,
|
||||||
total: quote.total_amount_minor,
|
totals: {
|
||||||
},
|
net: quote.net_amount_minor,
|
||||||
installments,
|
vatRate: quote.vat_rate,
|
||||||
eventDate: quote.event_date,
|
vat: quote.vat_amount_minor,
|
||||||
// Inline event snapshot — same rationale as convertToInvoiceOnly
|
shipping: quote.shipping_amount_minor,
|
||||||
// above (migration 123).
|
total: quote.total_amount_minor,
|
||||||
eventName: quote.event_name,
|
},
|
||||||
eventTimeStart: quote.event_time_start,
|
installments,
|
||||||
eventTimeEnd: quote.event_time_end,
|
eventDate: quote.event_date,
|
||||||
adminId,
|
// Inline event snapshot — same rationale as convertToInvoiceOnly
|
||||||
ccPdfEmail: quote.cc_pdf_email,
|
// above (migration 123).
|
||||||
// Net 14 / 30 / 60 / 90 carry through from the quote's
|
eventName: quote.event_name,
|
||||||
// payment-term template (same as convertToInvoiceOnly).
|
eventTimeStart: quote.event_time_start,
|
||||||
netDays: paymentTermSnapshot?.net_days,
|
eventTimeEnd: quote.event_time_end,
|
||||||
// Migration 140 — propagate the quote's deal_uuid down through
|
adminId,
|
||||||
// every spawned invoice (same as convertToInvoiceOnly above).
|
ccPdfEmail: quote.cc_pdf_email,
|
||||||
dealUuid: quote.deal_uuid,
|
// 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({
|
await trx('quotes').where({ id: quote.id }).update({
|
||||||
status: 'converted',
|
status: 'converted',
|
||||||
@@ -1629,13 +1737,18 @@ async function convertToEvent(quoteId, adminId, options = {}) {
|
|||||||
updated_at: new Date(),
|
updated_at: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
return { eventId, alreadyConverted: false, invoiceIds: spawnResult?.invoiceIds || [] };
|
||||||
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 };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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) {
|
async function duplicateQuote(id, adminId) {
|
||||||
@@ -1975,6 +2088,7 @@ module.exports = {
|
|||||||
recordResponse,
|
recordResponse,
|
||||||
adminAcceptQuote,
|
adminAcceptQuote,
|
||||||
adminDeclineQuote,
|
adminDeclineQuote,
|
||||||
|
finalizeQuoteResponses,
|
||||||
convertToEvent,
|
convertToEvent,
|
||||||
convertToInvoiceOnly,
|
convertToInvoiceOnly,
|
||||||
|
|
||||||
|
|||||||
@@ -16,16 +16,6 @@
|
|||||||
*/
|
*/
|
||||||
const registry = require('./registry');
|
const registry = require('./registry');
|
||||||
|
|
||||||
const DOCUMENT_ACTIONS = [
|
|
||||||
'prepare_quote',
|
|
||||||
'prepare_contract',
|
|
||||||
'prepare_event',
|
|
||||||
'prepare_gallery',
|
|
||||||
'prepare_invoice',
|
|
||||||
'send_document',
|
|
||||||
'reserve_date',
|
|
||||||
];
|
|
||||||
|
|
||||||
// --- Conditions ---
|
// --- Conditions ---
|
||||||
|
|
||||||
// True once the run's invoice entity is settled (paid_at set, status paid, or
|
// 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 };
|
: { skipped: true, reason: res.reason };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create/prepare-document actions — registered so flows referencing them are
|
// --- Booking document actions (draft-seam cutover) ---
|
||||||
// valid; service wiring is a follow-up. Records a skipped step (observable).
|
//
|
||||||
for (const key of DOCUMENT_ACTIONS) {
|
// The booking flows trigger on quote.accepted, so the run entity is the QUOTE.
|
||||||
registry.registerAction(key, async (ctx) => {
|
// prepare_* create DRAFT documents (idempotent, reusing the proven converters)
|
||||||
ctx.logger?.warn?.('[workflow] document action not yet wired', { action: key, runId: ctx.run.id });
|
// and stash the created ids in the run context; send_document then dispatches
|
||||||
return { skipped: true, reason: `action ${key} not yet implemented` };
|
// 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 = {};
|
||||||
|
|||||||
@@ -25,8 +25,11 @@ const { db } = require('../database/db');
|
|||||||
* JSON.parse on the way out. Falls back to the raw string on
|
* JSON.parse on the way out. Falls back to the raw string on
|
||||||
* malformed JSON so legacy text values still work.
|
* malformed JSON so legacy text values still work.
|
||||||
*/
|
*/
|
||||||
async function getAppSetting(key, defaultValue = null) {
|
async function getAppSetting(key, defaultValue = null, conn = db) {
|
||||||
const row = await db('app_settings').where({ setting_key: key }).first();
|
// 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;
|
if (!row || row.setting_value == null) return defaultValue;
|
||||||
try {
|
try {
|
||||||
return JSON.parse(row.setting_value);
|
return JSON.parse(row.setting_value);
|
||||||
|
|||||||
@@ -238,6 +238,7 @@
|
|||||||
"defaultPrompt": "Ein Workflow benötigt deine Bestätigung.",
|
"defaultPrompt": "Ein Workflow benötigt deine Bestätigung.",
|
||||||
"pendingTitle": "Offene Freigaben",
|
"pendingTitle": "Offene Freigaben",
|
||||||
"viewAll": "Alle Freigaben ansehen",
|
"viewAll": "Alle Freigaben ansehen",
|
||||||
|
"openEntity": "{{type}} #{{id}} öffnen",
|
||||||
"acted": "Erledigt"
|
"acted": "Erledigt"
|
||||||
},
|
},
|
||||||
"editor": {
|
"editor": {
|
||||||
|
|||||||
@@ -238,6 +238,7 @@
|
|||||||
"defaultPrompt": "A workflow needs your confirmation.",
|
"defaultPrompt": "A workflow needs your confirmation.",
|
||||||
"pendingTitle": "Pending approvals",
|
"pendingTitle": "Pending approvals",
|
||||||
"viewAll": "View all approvals",
|
"viewAll": "View all approvals",
|
||||||
|
"openEntity": "Open {{type}} #{{id}}",
|
||||||
"acted": "Done"
|
"acted": "Done"
|
||||||
},
|
},
|
||||||
"editor": {
|
"editor": {
|
||||||
|
|||||||
@@ -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)),
|
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<string, string> = {
|
||||||
|
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;
|
const isLoading = statsLoading || eventsLoading;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -277,14 +288,29 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{pendingApprovals.slice(0, 5).map((a) => {
|
{pendingApprovals.slice(0, 5).map((a) => {
|
||||||
const prompt = (a.payload as any)?.prompt as string | undefined;
|
const prompt = (a.payload as any)?.prompt as string | undefined;
|
||||||
|
const href = approvalEntityHref(a);
|
||||||
|
const info = (
|
||||||
|
<>
|
||||||
|
<h3 className="font-medium text-neutral-900 dark:text-neutral-100 truncate">{a.workflow_name}</h3>
|
||||||
|
<p className="text-sm text-neutral-600 dark:text-neutral-400 truncate">
|
||||||
|
{prompt || a.type}{a.entity_type ? ` · ${a.entity_type} #${a.entity_id}` : ''}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div key={a.id} className="flex items-center justify-between gap-3 p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
<div key={a.id} className="flex items-center justify-between gap-3 p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||||
<div className="min-w-0">
|
{href ? (
|
||||||
<h3 className="font-medium text-neutral-900 dark:text-neutral-100 truncate">{a.workflow_name}</h3>
|
<button
|
||||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 truncate">
|
type="button"
|
||||||
{prompt || a.type}{a.entity_type ? ` · ${a.entity_type} #${a.entity_id}` : ''}
|
onClick={() => navigate(href)}
|
||||||
</p>
|
className="min-w-0 text-left rounded -m-1 p-1 hover:bg-purple-100/60 dark:hover:bg-purple-900/40 transition-colors cursor-pointer"
|
||||||
</div>
|
title={t('workflows.approvals.openEntity', 'Open {{type}} #{{id}}', { type: a.entity_type, id: a.entity_id }) as string}
|
||||||
|
>
|
||||||
|
{info}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="min-w-0">{info}</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<Button variant="primary" size="sm" isLoading={approvalMutation.isPending}
|
<Button variant="primary" size="sm" isLoading={approvalMutation.isPending}
|
||||||
onClick={() => approvalMutation.mutate({ id: a.id, action: 'confirm' })}
|
onClick={() => approvalMutation.mutate({ id: a.id, action: 'confirm' })}
|
||||||
|
|||||||
@@ -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.');
|
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<string, string> = {
|
||||||
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -55,24 +66,41 @@ export const WorkflowApprovalsPage: React.FC = () => {
|
|||||||
<div className="p-10 text-center text-neutral-500 dark:text-neutral-400">{t('workflows.approvals.empty', 'Nothing waiting for you right now.')}</div>
|
<div className="p-10 text-center text-neutral-500 dark:text-neutral-400">{t('workflows.approvals.empty', 'Nothing waiting for you right now.')}</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
|
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||||
{approvals.map((a) => (
|
{approvals.map((a) => {
|
||||||
<li key={a.id} className="flex items-center gap-3 px-4 py-3">
|
const href = entityHref(a);
|
||||||
<div className="min-w-0 flex-1">
|
const meta = (
|
||||||
|
<>
|
||||||
<div className="text-sm text-neutral-900 dark:text-neutral-100">{promptOf(a)}</div>
|
<div className="text-sm text-neutral-900 dark:text-neutral-100">{promptOf(a)}</div>
|
||||||
<div className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">
|
<div className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">
|
||||||
{a.workflow_name}
|
{a.workflow_name}
|
||||||
{a.entity_type ? ` · ${a.entity_type} #${a.entity_id}` : ''}
|
{a.entity_type ? ` · ${a.entity_type} #${a.entity_id}` : ''}
|
||||||
{a.created_at ? ` · ${formatDateTime(a.created_at)}` : ''}
|
{a.created_at ? ` · ${formatDateTime(a.created_at)}` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
<Button variant="outline" size="sm" isLoading={actMutation.isPending} onClick={() => actMutation.mutate({ id: a.id, action: 'confirm' })} leftIcon={<Check className="w-4 h-4" />}>
|
);
|
||||||
{t('workflows.approvals.confirm', 'Confirm')}
|
return (
|
||||||
</Button>
|
<li key={a.id} className="flex items-center gap-3 px-4 py-3">
|
||||||
<Button variant="ghost" size="sm" onClick={() => actMutation.mutate({ id: a.id, action: 'deny' })} leftIcon={<X className="w-4 h-4" />}>
|
{href ? (
|
||||||
{t('workflows.approvals.deny', 'Deny')}
|
<button
|
||||||
</Button>
|
type="button"
|
||||||
</li>
|
onClick={() => navigate(href)}
|
||||||
))}
|
className="min-w-0 flex-1 text-left rounded -mx-1 px-1 py-0.5 hover:bg-neutral-50 dark:hover:bg-neutral-800/60 transition-colors cursor-pointer"
|
||||||
|
title={t('workflows.approvals.openEntity', 'Open {{type}} #{{id}}', { type: a.entity_type, id: a.entity_id }) as string}
|
||||||
|
>
|
||||||
|
{meta}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="min-w-0 flex-1">{meta}</div>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" size="sm" isLoading={actMutation.isPending} onClick={() => actMutation.mutate({ id: a.id, action: 'confirm' })} leftIcon={<Check className="w-4 h-4" />}>
|
||||||
|
{t('workflows.approvals.confirm', 'Confirm')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => actMutation.mutate({ id: a.id, action: 'deny' })} leftIcon={<X className="w-4 h-4" />}>
|
||||||
|
{t('workflows.approvals.deny', 'Deny')}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
Reference in New Issue
Block a user