diff --git a/backend/__tests__/integration/eventTypeRename.test.js b/backend/__tests__/integration/eventTypeRename.test.js new file mode 100644 index 00000000..caf41597 --- /dev/null +++ b/backend/__tests__/integration/eventTypeRename.test.js @@ -0,0 +1,64 @@ +/** + * Renaming an event type's slug_prefix must CASCADE to everything keyed on the + * old slug, so a rename behaves like a rename rather than silently detaching + * existing events/quotes and orphaning the per-type pre-event reminder template. + */ +const { bootCrmDb, seedMinimal } = require('./helpers/crmDb'); + +// bootCrmDb runs the full core-migration set in beforeAll. +jest.setTimeout(30000); + +describe('event type slug rename cascade', () => { + let db; + let cleanup; + let customerId; + let eventTypeService; + + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + ({ customerId } = await seedMinimal(db)); + eventTypeService = require('../../src/services/eventTypeService'); + }, 120000); + + afterAll(async () => { if (cleanup) await cleanup(); }); + + it('re-points events + quotes + the reminder template from old slug to new', async () => { + // A non-system event type with slug 'party'. + const [typeId] = await db('event_types').insert({ name: 'Party', slug_prefix: 'party', is_active: true }); + + // An authored per-type reminder template + an event + a quote, all on 'party'. + await db('email_templates').insert({ template_key: 'event_reminder_party', subject_en: 'Party reminder' }); + await db('events').insert({ + event_type: 'party', password_hash: 'x', expires_at: new Date(Date.now() + 9e9).toISOString(), + is_active: true, is_archived: false, slug: 'party-ev', share_link: 'party-ev', + event_name: 'A party', event_date: '2026-09-01', + }); + await db('quotes').insert({ + quote_number: 'Q-PARTY-1', customer_account_id: customerId, issue_date: '2026-01-01', event_type: 'party', + }); + + // Rename the slug. + await eventTypeService.updateEventType(typeId, { slug_prefix: 'concert' }); + + // Event + quote follow the rename. + expect((await db('events').where({ slug: 'party-ev' }).first()).event_type).toBe('concert'); + expect((await db('quotes').where({ quote_number: 'Q-PARTY-1' }).first()).event_type).toBe('concert'); + // The authored reminder template moved (subject/body preserved), old key gone. + expect(await db('email_templates').where({ template_key: 'event_reminder_party' }).first()).toBeUndefined(); + const moved = await db('email_templates').where({ template_key: 'event_reminder_concert' }).first(); + expect(moved).toBeTruthy(); + expect(moved.subject_en).toBe('Party reminder'); + }); + + it('does not clobber an existing template for the new slug', async () => { + const [typeId] = await db('event_types').insert({ name: 'Gala', slug_prefix: 'gala', is_active: true }); + await db('email_templates').insert({ template_key: 'event_reminder_gala', subject_en: 'old gala' }); + await db('email_templates').insert({ template_key: 'event_reminder_soiree', subject_en: 'existing soiree' }); + + await eventTypeService.updateEventType(typeId, { slug_prefix: 'soiree' }); + + // Target already existed → left intact; source not force-merged over it. + expect((await db('email_templates').where({ template_key: 'event_reminder_soiree' }).first()).subject_en) + .toBe('existing soiree'); + }); +}); diff --git a/backend/__tests__/integration/invoiceDunning.test.js b/backend/__tests__/integration/invoiceDunning.test.js new file mode 100644 index 00000000..8b4194a3 --- /dev/null +++ b/backend/__tests__/integration/invoiceDunning.test.js @@ -0,0 +1,115 @@ +/** + * Dunning / Mahngebühr logic — the tax-sensitive bits added in the dunning + * rework. Covers the fee math (flat / percent), the VAT toggle gating + * (incl. the "no-op when the org has no VAT rate" requirement), per-reminder + * accumulation (2nd = 1×, 3rd = 2×), invoice immutability (the fee never + * changes the issued invoice total), and the 3-reminder cap. + * + * The Mahnung PDF render is stubbed — PDF rendering (fonts) is flaky in CI and + * is verified manually; here we assert the data/immutability behaviour. + */ +const { bootCrmDb, seedMinimal } = require('./helpers/crmDb'); + +// bootCrmDb runs the full core-migration set in beforeAll; under full-suite +// parallel load on a small CI runner that can exceed the 5s default. Match the +// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill). +jest.setTimeout(30000); + +let db; +let cleanup; +let invoiceService; +let ids; + +async function setSetting(key, value) { + const { upsertAppSetting } = require('../../src/utils/appSettings'); + await upsertAppSetting(key, JSON.stringify(value), 'crm'); +} + +beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + ids = await seedMinimal(db); + try { await db('customer_accounts').where({ id: ids.customerId }).update({ feature_bills: true }); } catch (_) {} + invoiceService = require('../../src/services/invoiceService'); + // Stub the (flaky) PDF render so applyReminder exercises its data path. + // eslint-disable-next-line global-require + const pdfService = require('../../src/services/pdfService'); + pdfService.renderInvoiceToBuffer = async () => Buffer.from('%PDF-stub'); +}); + +afterAll(async () => { await cleanup(); }); + +describe('dunning fee resolvers', () => { + test('flat fee, no VAT', async () => { + await setSetting('crm_invoices_late_fee_enabled', true); + await setSetting('crm_invoices_late_fee_type', 'flat'); + await setSetting('crm_invoices_late_fee_minor', 2000); + await setSetting('crm_invoices_late_fee_vat_enabled', false); + const inv = { total_amount_minor: 100000 }; + expect(await invoiceService.resolveLateFeeNetMinor(inv)).toBe(2000); + expect(await invoiceService.resolveLateFeeVatRate()).toBe(0); + expect(await invoiceService.resolvePerReminderFeeMinor(inv)).toBe(2000); + }); + + test('percent fee = % of the invoice gross', async () => { + await setSetting('crm_invoices_late_fee_type', 'percent'); + await setSetting('crm_invoices_late_fee_percent', 5); + expect(await invoiceService.resolveLateFeeNetMinor({ total_amount_minor: 100000 })).toBe(5000); + }); + + test('VAT toggle applies the org rate, but is a NO-OP when the org has no VAT rate', async () => { + await setSetting('crm_invoices_late_fee_type', 'flat'); + await setSetting('crm_invoices_late_fee_minor', 2000); + await setSetting('crm_invoices_late_fee_vat_enabled', true); + + await db('business_profile').where({ id: 1 }).update({ vat_rate_default: 8.1 }); + expect(await invoiceService.resolveLateFeeVatRate()).toBeCloseTo(8.1); + expect(await invoiceService.resolvePerReminderFeeMinor({ total_amount_minor: 0 })) + .toBe(2000 + Math.round(2000 * 8.1 / 100)); // net + VAT + + // Org doesn't charge VAT → toggle adds nothing (Mara's requirement). + await db('business_profile').where({ id: 1 }).update({ vat_rate_default: 0 }); + expect(await invoiceService.resolveLateFeeVatRate()).toBe(0); + expect(await invoiceService.resolvePerReminderFeeMinor({ total_amount_minor: 0 })).toBe(2000); + }); +}); + +describe('applyReminder — dunning-document model', () => { + let invoiceId; + let originalTotal; + + beforeAll(async () => { + await setSetting('crm_invoices_late_fee_enabled', true); + await setSetting('crm_invoices_late_fee_type', 'flat'); + await setSetting('crm_invoices_late_fee_minor', 2000); + await setSetting('crm_invoices_late_fee_vat_enabled', false); + const res = await invoiceService.createInvoice({ + customerAccountId: ids.customerId, + currency: 'CHF', + vatRate: 0, + lineItems: [{ description: 'Service', quantity: 1, unit_price_minor: 100000 }], + }, ids.adminId); + invoiceId = res.invoiceIds[0]; + originalTotal = Number((await db('invoices').where({ id: invoiceId }).first()).total_amount_minor); + }); + + test('level 2 tracks one fee and leaves the invoice total immutable', async () => { + const data = await invoiceService.getInvoiceById(invoiceId); + await invoiceService.applyReminder(data.invoice, data.lineItems, 2, ids.adminId); + const inv = await db('invoices').where({ id: invoiceId }).first(); + expect(inv.reminder_level).toBe(2); + expect(Number(inv.late_fee_amount_minor)).toBe(2000); + expect(Number(inv.total_amount_minor)).toBe(originalTotal); // never mutated + }); + + test('level 3 accumulates the fee to 2×, total still immutable', async () => { + const data = await invoiceService.getInvoiceById(invoiceId); + await invoiceService.applyReminder(data.invoice, data.lineItems, 3, ids.adminId); + const inv = await db('invoices').where({ id: invoiceId }).first(); + expect(Number(inv.late_fee_amount_minor)).toBe(4000); + expect(Number(inv.total_amount_minor)).toBe(originalTotal); + }); + + test('sendReminder refuses to exceed level 3', async () => { + await expect(invoiceService.sendReminder(invoiceId, 4, ids.adminId)).rejects.toThrow(); + }); +}); diff --git a/backend/__tests__/integration/workflowEngine.test.js b/backend/__tests__/integration/workflowEngine.test.js new file mode 100644 index 00000000..5981efc5 --- /dev/null +++ b/backend/__tests__/integration/workflowEngine.test.js @@ -0,0 +1,628 @@ +/** + * Workflow engine — graph execution integration tests. + * + * Exercises the engine against a real (temp SQLite) DB with migration 142 + * applied: branching, bounded loops, wait pauses + scheduler-style resume, + * gate pauses + confirm/deny resume, dedup idempotency, and step recording. + */ +const { bootCrmDb } = require('./helpers/crmDb'); + +// bootCrmDb runs the full core-migration set in beforeAll; under full-suite +// parallel load on a small CI runner that can exceed the 5s default. Match the +// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill). +jest.setTimeout(30000); + +let db; +let cleanup; +let engine; + +async function makeWorkflow({ nodes, edges, trigger = 'test.event', enabled = true }) { + const ins = await db('workflows').insert({ name: 'wf', trigger_type: trigger, version: 1, enabled }); + const workflowId = ins[0]; + for (const n of nodes) { + await db('workflow_nodes').insert({ + workflow_id: workflowId, version: 1, node_key: n.key, type: n.type, + config: JSON.stringify(n.config || {}), + }); + } + for (const e of edges) { + await db('workflow_edges').insert({ + workflow_id: workflowId, version: 1, from_node: e.from, from_handle: e.handle || null, to_node: e.to, + loop_back: e.loopBack || false, + }); + } + return workflowId; +} + +beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + // Engine requires the singleton db — require AFTER bootCrmDb wired the test path. + engine = require('../../src/services/workflows'); + // Enable the workflows flag so emitWorkflowEvent doesn't fail closed. + await db('feature_flags').insert({ key: 'workflows', value: true }); +}); + +afterAll(async () => { await cleanup(); }); + +describe('workflow engine', () => { + test('condition + bounded loop + wait pauses, resumes to completion', async () => { + // trigger → set paid=false → condition(paid?) --no--> loop(max2) + // loop --loop--> reminder(noop) → wait → (back to condition) + // loop --exit--> lateFee(noop) → end + // condition --yes--> lateFee (paid path, not taken here) + const wfId = await makeWorkflow({ + nodes: [ + { key: 'n1', type: 'trigger' }, + { key: 'n2', type: 'action', config: { action: 'set_context', set: { paid: false } } }, + { key: 'n3', type: 'condition', config: { condition: 'expr', field: 'paid', op: 'truthy' } }, + { key: 'n4', type: 'loop', config: { maxIterations: 2 } }, + { key: 'n5', type: 'action', config: { action: 'noop' } }, + { key: 'n6', type: 'wait', config: { delayMinutes: 0 } }, + { key: 'n7', type: 'action', config: { action: 'noop' } }, + ], + edges: [ + { from: 'n1', to: 'n2' }, + { from: 'n2', to: 'n3' }, + { from: 'n3', handle: 'no', to: 'n4' }, + { from: 'n3', handle: 'yes', to: 'n7' }, + { from: 'n4', handle: 'loop', to: 'n5' }, + { from: 'n4', handle: 'exit', to: 'n7' }, + { from: 'n5', to: 'n6' }, + { from: 'n6', to: 'n3', loopBack: true }, + ], + }); + + const runIds = await engine.emitWorkflowEvent('test.event', { entityType: 'invoice', entityId: 1 }); + expect(runIds.length).toBe(1); + const runId = runIds[0]; + + let run = await db('workflow_runs').where({ id: runId }).first(); + expect(run.status).toBe('waiting'); // paused at first wait (loop iter 1) + expect(run.current_node).toBe('n6'); + + await engine.resumeRun(runId); + run = await db('workflow_runs').where({ id: runId }).first(); + expect(run.status).toBe('waiting'); // paused again (loop iter 2) + + await engine.resumeRun(runId); + run = await db('workflow_runs').where({ id: runId }).first(); + expect(run.status).toBe('done'); // loop exhausted → exit → end + + const ctx = JSON.parse(run.context); + expect(ctx.vars.__loop_n4).toBe(3); // counter incremented past the cap + void wfId; + + const steps = await db('workflow_run_steps').where({ run_id: runId }); + expect(steps.length).toBeGreaterThan(0); + }); + + test('emit is idempotent on dedup_key', async () => { + await makeWorkflow({ + trigger: 'dedup.event', + nodes: [{ key: 'n1', type: 'trigger' }, { key: 'n2', type: 'action', config: { action: 'noop' } }], + edges: [{ from: 'n1', to: 'n2' }], + }); + const first = await engine.emitWorkflowEvent('dedup.event', { entityType: 'x', entityId: 9 }); + const second = await engine.emitWorkflowEvent('dedup.event', { entityType: 'x', entityId: 9 }); + expect(first.length).toBe(1); + expect(second.length).toBe(0); // same entity → no duplicate run + }); + + test('gate pauses and resumes via the confirm edge', async () => { + const wfId = await makeWorkflow({ + trigger: 'gate.event', + nodes: [ + { key: 'g1', type: 'trigger' }, + { key: 'g2', type: 'gate', config: { type: 'payment_confirm' } }, + { key: 'g3', type: 'action', config: { action: 'noop' } }, + { key: 'g4', type: 'action', config: { action: 'noop' } }, + ], + edges: [ + { from: 'g1', to: 'g2' }, + { from: 'g2', handle: 'confirm', to: 'g3' }, + { from: 'g2', handle: 'deny', to: 'g4' }, + ], + }); + // create + start a run directly + await db('workflow_runs').insert({ + workflow_id: wfId, version: 1, trigger_event: 'gate.event', status: 'pending', + context: JSON.stringify({ vars: {} }), dedup_key: 'gate-test', + }); + const run0 = await db('workflow_runs').where({ dedup_key: 'gate-test' }).first(); + await engine.startRun(run0.id); + + let run = await db('workflow_runs').where({ id: run0.id }).first(); + expect(run.status).toBe('waiting'); + expect(run.current_node).toBe('g2'); + + await engine.resumeRun(run0.id, { decisionHandle: 'confirm' }); + run = await db('workflow_runs').where({ id: run0.id }).first(); + expect(run.status).toBe('done'); + }); + + test('runDueWaits resumes only elapsed wait nodes', async () => { + await makeWorkflow({ + trigger: 'wait.event', + nodes: [ + { key: 'w1', type: 'trigger' }, + { key: 'w2', type: 'wait', config: { delayMinutes: 60 } }, + { key: 'w3', type: 'action', config: { action: 'noop' } }, + ], + edges: [{ from: 'w1', to: 'w2' }, { from: 'w2', to: 'w3' }], + }); + const runIds = await engine.emitWorkflowEvent('wait.event', { entityType: 'e', entityId: 7 }); + const runId = runIds[0]; + let run = await db('workflow_runs').where({ id: runId }).first(); + expect(run.status).toBe('waiting'); + + expect(await engine.runDueWaits()).toBe(0); // wake_at ~60min out → not due + + await db('workflow_runs').where({ id: runId }).update({ wake_at: new Date(Date.now() - 1000).toISOString() }); + const resumed = await engine.runDueWaits(); + expect(resumed).toBeGreaterThanOrEqual(1); + run = await db('workflow_runs').where({ id: runId }).first(); + expect(run.status).toBe('done'); + }); + + test('send_email queues a customer mail with business-hours routing', async () => { + await makeWorkflow({ + trigger: 'mail.event', + nodes: [ + { key: 'm1', type: 'trigger' }, + { key: 'm2', type: 'action', config: { action: 'send_email', recipientClass: 'customer', emailType: 'workflow_test' } }, + ], + edges: [{ from: 'm1', to: 'm2' }], + }); + const runIds = await engine.emitWorkflowEvent('mail.event', { + entityType: 'invoice', entityId: 3, payload: { customerEmail: 'cust@example.com' }, + }); + const run = await db('workflow_runs').where({ id: runIds[0] }).first(); + expect(run.status).toBe('done'); + const queued = await db('email_queue').where({ recipient_email: 'cust@example.com' }).first(); + expect(queued).toBeTruthy(); + const step = await db('workflow_run_steps').where({ run_id: runIds[0], node_key: 'm2' }).first(); + expect(JSON.parse(step.result).respectBusinessHours).toBe(true); + }); + + test('invoice_paid condition reads the entity', async () => { + const registry = require('../../src/services/workflows/registry'); + const cond = registry.getCondition('invoice_paid'); + const makeCtx = (row) => ({ run: { entity_id: 1 }, db: () => ({ where: () => ({ first: async () => row }) }) }); + expect(await cond(makeCtx({ paid_at: '2026-01-01', status: 'sent' }))).toBe(true); + expect(await cond(makeCtx({ paid_at: null, status: 'paid' }))).toBe(true); + expect(await cond(makeCtx({ paid_at: null, status: 'sent', paid_amount_minor: 0, total_amount_minor: 1000 }))).toBe(false); + }); + + test('gate creates a pending approval + admin email, token confirm resumes the run', async () => { + await makeWorkflow({ + trigger: 'approval.event', + nodes: [ + { key: 'a1', type: 'trigger' }, + { key: 'a2', type: 'gate', config: { type: 'payment_confirm', prompt: 'No payment yet?' } }, + { key: 'a3', type: 'action', config: { action: 'noop' } }, // confirm path + { key: 'a4', type: 'action', config: { action: 'noop' } }, // deny path + ], + edges: [ + { from: 'a1', to: 'a2' }, + { from: 'a2', handle: 'confirm', to: 'a3' }, + { from: 'a2', handle: 'deny', to: 'a4' }, + ], + }); + const runIds = await engine.emitWorkflowEvent('approval.event', { + entityType: 'invoice', entityId: 42, payload: { adminEmail: 'admin@example.com' }, + }); + const runId = runIds[0]; + + let run = await db('workflow_runs').where({ id: runId }).first(); + expect(run.status).toBe('waiting'); + expect(run.current_node).toBe('a2'); + + const approval = await db('workflow_approvals').where({ run_id: runId }).first(); + expect(approval).toBeTruthy(); + expect(approval.status).toBe('pending'); + + const adminMail = await db('email_queue').where({ recipient_email: 'admin@example.com' }).first(); + expect(adminMail).toBeTruthy(); + + // Extract the raw token from the emailed confirm link and act on it. + const data = JSON.parse(adminMail.email_data); + const rawToken = data.confirm_url.split('/').slice(-2)[0]; + const res = await engine.actByToken(rawToken, 'confirm'); + expect(res.ok).toBe(true); + expect(res.status).toBe('confirmed'); + + run = await db('workflow_runs').where({ id: runId }).first(); + expect(run.status).toBe('done'); + + // A second click is idempotent (already recorded). + const again = await engine.actByToken(rawToken, 'confirm'); + expect(again.already).toBe(true); + }); + + test('seeds the invoice-dunning built-in as the delegation graph (v6, disabled for first beta)', async () => { + const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot'); + const noopLogger = { info() {}, warn() {} }; + await seedBuiltinWorkflowsAtBoot(db, noopLogger); + + const wf = await db('workflows').where({ builtin_key: DUNNING_KEY }).first(); + expect(wf).toBeTruthy(); + expect(!!wf.is_builtin).toBe(true); + expect(!!wf.enabled).toBe(false); // first beta: ships disabled; legacy ladder runs until enabled + expect(JSON.parse(wf.trigger_config).seedVersion).toBe(6); + + const nodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: wf.version }); + expect(nodes.filter((n) => n.type === 'trigger')).toHaveLength(1); + expect(nodes.some((n) => n.type === 'gate')).toBe(false); // payment-check email IS the gate + expect(nodes.some((n) => JSON.parse(n.config || '{}').action === 'queue_payment_check')).toBe(true); + expect(nodes.some((n) => JSON.parse(n.config || '{}').action === 'escalate_to_collections')).toBe(true); + + await seedBuiltinWorkflowsAtBoot(db, noopLogger); // idempotent at current seed version + const all = await db('workflows').where({ builtin_key: DUNNING_KEY }); + expect(all.length).toBe(1); + }); + + test('re-seeds a stale built-in on version bump, but never an admin-owned one', async () => { + const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot'); + const noopLogger = { info() {}, warn() {} }; + + // Simulate an older, never-touched seed (v1, with a legacy gate node). + const wf = await db('workflows').where({ builtin_key: DUNNING_KEY }).first(); + await db('workflows').where({ id: wf.id }).update({ enabled: true, admin_toggled_at: null, trigger_config: JSON.stringify({ seedVersion: 1 }) }); + await db('workflow_nodes').insert({ workflow_id: wf.id, version: wf.version, node_key: 'legacyGate', type: 'gate', config: '{}', pos_x: 0, pos_y: 0 }); + + await seedBuiltinWorkflowsAtBoot(db, noopLogger); + const reseeded = await db('workflows').where({ id: wf.id }).first(); + expect(reseeded.version).toBe(wf.version + 1); // bumped + expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(6); + expect(!!reseeded.enabled).toBe(false); // seed default re-applied (not admin-owned → flips enabled→disabled) + const newNodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: reseeded.version }); + expect(newNodes.some((n) => n.type === 'gate')).toBe(false); // legacy graph replaced + + // Admin-owned (admin_toggled_at set) + stale → must NOT be touched. + await db('workflows').where({ id: wf.id }).update({ enabled: true, admin_toggled_at: new Date().toISOString(), trigger_config: JSON.stringify({ seedVersion: 1 }) }); + const before = await db('workflows').where({ id: wf.id }).first(); + await seedBuiltinWorkflowsAtBoot(db, noopLogger); + const after = await db('workflows').where({ id: wf.id }).first(); + expect(after.version).toBe(before.version); // unchanged + expect(!!after.enabled).toBe(true); // admin's choice preserved + }); + + test('seeds the gallery, pre-event + booking built-ins (all disabled for first beta)', async () => { + const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot'); + await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} }); + + // First beta: cutover flows ship DISABLED (legacy paths run until enabled); + // they delegate to the proven send functions once turned on. + const expiring = await db('workflows').where({ builtin_key: 'gallery_expiring' }).first(); + expect(expiring).toBeTruthy(); + expect(!!expiring.enabled).toBe(false); + expect(expiring.trigger_type).toBe('gallery.expiring'); + const expiringNodes = await db('workflow_nodes').where({ workflow_id: expiring.id, version: expiring.version }); + expect(expiringNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_gallery_expiring')).toBe(true); + + const expired = await db('workflows').where({ builtin_key: 'gallery_expired' }).first(); + expect(expired).toBeTruthy(); + expect(!!expired.enabled).toBe(false); + expect(expired.trigger_type).toBe('gallery.expired'); + const expiredNodes = await db('workflow_nodes').where({ workflow_id: expired.id, version: expired.version }); + expect(expiredNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_gallery_expired')).toBe(true); + + // Invoice-only booking variant (quote → invoice, no gallery). + const invoiceOnly = await db('workflows').where({ builtin_key: 'booking_invoice_only' }).first(); + expect(invoiceOnly).toBeTruthy(); + expect(!!invoiceOnly.enabled).toBe(false); + expect(invoiceOnly.trigger_type).toBe('quote.accepted'); + const ioNodes = await db('workflow_nodes').where({ workflow_id: invoiceOnly.id, version: invoiceOnly.version }); + expect(ioNodes.some((n) => n.type === 'wait')).toBe(false); // no event wait — sends on approval + expect(ioNodes.some((n) => JSON.parse(n.config || '{}').action === 'prepare_event')).toBe(false); // no gallery + + const bookingFull = await db('workflows').where({ builtin_key: 'booking_full' }).first(); + expect(bookingFull).toBeTruthy(); + expect(!!bookingFull.enabled).toBe(false); // illustrative/stub — stays disabled + expect(bookingFull.trigger_type).toBe('quote.accepted'); + const fullNodes = await db('workflow_nodes').where({ workflow_id: bookingFull.id, version: bookingFull.version }); + expect(fullNodes.some((n) => JSON.parse(n.config || '{}').action === 'prepare_contract')).toBe(true); + // Admin review gate guards BOTH document sends (adjust line items, then OK). + const fullGateKeys = fullNodes.filter((n) => n.type === 'gate').map((n) => n.node_key); + expect(fullGateKeys).toEqual(expect.arrayContaining(['reviewContract', 'reviewInvoice'])); + const fullEdges = await db('workflow_edges').where({ workflow_id: bookingFull.id, version: bookingFull.version }); + // reviewContract --confirm--> sendContract. The invoice is prepared + approved + // EARLY; reviewInvoice --confirm--> waitEvent, and the wait --> sendInvoice, so + // dispatch is held until the event date after the admin's early OK. + expect(fullEdges.some((e) => e.from_node === 'reviewContract' && e.from_handle === 'confirm' && e.to_node === 'sendContract')).toBe(true); + expect(fullEdges.some((e) => e.from_node === 'reviewInvoice' && e.from_handle === 'confirm' && e.to_node === 'waitEvent')).toBe(true); + expect(fullEdges.some((e) => e.from_node === 'waitEvent' && e.to_node === 'sendInvoice')).toBe(true); + + const bookingSimple = await db('workflows').where({ builtin_key: 'booking_simple' }).first(); + expect(bookingSimple).toBeTruthy(); + expect(bookingSimple.trigger_type).toBe('quote.accepted'); + const simpleEdges = await db('workflow_edges').where({ workflow_id: bookingSimple.id, version: bookingSimple.version }); + expect(simpleEdges.some((e) => e.from_node === 'reviewInvoice' && e.from_handle === 'confirm' && e.to_node === 'waitEvent')).toBe(true); + expect(simpleEdges.some((e) => e.from_node === 'waitEvent' && e.to_node === 'sendInvoice')).toBe(true); + + const preEvent = await db('workflows').where({ builtin_key: 'pre_event_email' }).first(); + expect(preEvent).toBeTruthy(); + expect(!!preEvent.enabled).toBe(false); // first beta: ships disabled + expect(preEvent.trigger_type).toBe('event.date_approaching'); + expect(JSON.parse(preEvent.trigger_config).daysBefore).toBe(2); // default when global setting unset + const preNodes = await db('workflow_nodes').where({ workflow_id: preEvent.id, version: preEvent.version }); + expect(preNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_pre_event')).toBe(true); + }); + + test('emitDueEventReminders starts a run for an event inside the lead window', async () => { + const wfId = await makeWorkflow({ + trigger: 'event.date_approaching', + enabled: true, + nodes: [{ key: 'pe1', type: 'trigger' }, { key: 'pe2', type: 'action', config: { action: 'noop' } }], + edges: [{ from: 'pe1', to: 'pe2' }], + }); + // Park the workflow's trigger window at 5 days so our event (2 days out) is in range. + await db('workflows').where({ id: wfId }).update({ trigger_config: JSON.stringify({ daysBefore: 5 }) }); + + const inWindow = new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 10); + const tooFar = new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10); + const farFuture = new Date(Date.now() + 365 * 86400000).toISOString(); + const evt = { event_type: 'wedding', password_hash: 'x', expires_at: farFuture, is_active: true, is_archived: false, customer_email: 'c@x.test' }; + await db('events').insert({ ...evt, slug: 'pe-soon', share_link: 'pe-soon', event_name: 'Soon', event_date: inWindow }); + await db('events').insert({ ...evt, slug: 'pe-far', share_link: 'pe-far', event_name: 'Far', event_date: tooFar }); + + const emitted = await engine.emitDueEventReminders(); + expect(emitted).toBeGreaterThanOrEqual(1); + + const runs = await db('workflow_runs').where({ workflow_id: wfId, entity_type: 'event' }); + expect(runs.length).toBe(1); // only the in-window event, not the far one + + // Idempotent: a second pass dedups (no duplicate run for the same event). + await engine.emitDueEventReminders(); + const runs2 = await db('workflow_runs').where({ workflow_id: wfId, entity_type: 'event' }); + expect(runs2.length).toBe(1); + }); + + test('notify_pre_event / sendReminderForEvent sends to an event with a direct email (no CRM account)', async () => { + // Regression: the reminder query used events.customer_account_id, which does + // not exist — so an event with only customer_email/host_email got no mail. + const farFuture = new Date(Date.now() + 365 * 86400000).toISOString(); + await db('events').insert({ + event_type: 'wedding', password_hash: 'x', expires_at: farFuture, + is_active: true, is_archived: false, + slug: 'rem-direct', share_link: 'rem-direct', event_name: 'Direct', + event_date: new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 10), + customer_email: 'direct@x.test', // event-level email, NOT a customer_account + }); + const ev = await db('events').where({ slug: 'rem-direct' }).first(); + + const res = await require('../../src/services/eventReminderService').sendReminderForEvent(ev.id); + expect(res.sent).toBe(1); + const mail = await db('email_queue').where({ event_id: ev.id }).first(); + expect(mail).toBeTruthy(); + expect(mail.recipient_email).toBe('direct@x.test'); + // Idempotent: sent_at stamped → a second call is a no-op. + const again = await require('../../src/services/eventReminderService').sendReminderForEvent(ev.id); + expect(again.sent).toBe(0); + expect(again.reason).toBe('already_sent'); + }); + + test('reminder template resolves per event type within the chosen group, else group default', async () => { + const { _internal } = require('../../src/services/eventReminderService'); + // Per-type template exists within a custom group → used. + await db('email_templates').insert({ template_key: 'promo_wedding' }); + expect(await _internal.resolveTemplateKey('wedding', 'promo')).toBe('promo_wedding'); + // A type with no authored template (in any group) → the group's default. + expect(await _internal.resolveTemplateKey('zzznotype', 'promo')).toBe('promo_default'); + // Blank group → the default event_reminder group. + expect(await _internal.resolveTemplateKey('zzznotype')).toBe('event_reminder_default'); + // Trailing underscore on the group is tolerated. + expect(await _internal.resolveTemplateKey('zzznotype', 'promo_')).toBe('promo_default'); + }); + + test('webhook action enqueues a delivery for a configured subscription (full pipeline)', async () => { + const webhook = engine.registry.getAction('webhook'); + expect(typeof webhook).toBe('function'); // registered — no longer a silent no-op + const ctx = (config, vars = {}) => ({ + run: { id: 1, workflow_id: 1, version: 1, trigger_event: 'invoice.sent', entity_type: 'invoice', entity_id: 5 }, + node: { config }, vars, db, logger: { warn() {} }, + }); + // No webhook selected → observable skip, not a crash. + expect(await webhook(ctx({}))).toMatchObject({ skipped: true }); + + // A configured, active webhook subscription. + const [adminId] = await db('admin_users').insert({ username: 'wfhook', email: 'wf@x.test', password_hash: 'x' }); + const [whId] = await db('webhooks').insert({ + name: 'Flow hook', url: 'https://example.com/hook', secret: 'whsec_test', + events: JSON.stringify([]), active: true, created_by: adminId, + }); + + // Dry run does not enqueue. + expect(await webhook(ctx({ webhookId: whId }, { __dryRun: true }))).toMatchObject({ dryRun: true, would: 'webhook' }); + expect(await db('webhook_deliveries').where({ webhook_id: whId }).count('id as c').first()).toMatchObject({ c: 0 }); + + // Real run → a pending delivery is enqueued for the worker (which does the + // signing + SSRF re-validation + retries). + const res = await webhook(ctx({ webhookId: whId })); + expect(res.webhook_enqueued).toBe(whId); + const del = await db('webhook_deliveries').where({ webhook_id: whId }).first(); + expect(del).toBeTruthy(); + expect(del.status).toBe('pending'); + expect(del.event_type).toBe('workflow.invoice.sent'); + + // Inactive / missing subscription → skip. + await db('webhooks').where({ id: whId }).update({ active: false }); + expect((await webhook(ctx({ webhookId: whId }))).skipped).toBe(true); + }); + + test('isBuiltinFlowActive reflects the built-in ENABLED state (enabled-based mutex)', async () => { + const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot'); + await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} }); + // All built-ins ship disabled → inactive until the admin enables one. + expect(await engine.isBuiltinFlowActive('gallery_expiring')).toBe(false); + expect(await engine.isBuiltinFlowActive('does_not_exist')).toBe(false); + // Enable one → now active. + await db('workflows').where({ builtin_key: 'gallery_expiring' }).update({ enabled: true }); + expect(await engine.isBuiltinFlowActive('gallery_expiring')).toBe(true); + await db('workflows').where({ builtin_key: 'gallery_expiring' }).update({ enabled: false }); // restore + }); + + test('legacy event-reminder pass stands down ONLY when the pre_event_email flow is enabled', async () => { + const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot'); + await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} }); // pre_event_email seeded DISABLED + // crm_event_reminders_enabled must be on to reach the mutex guard. + await db('app_settings') + .insert({ setting_key: 'crm_event_reminders_enabled', setting_value: JSON.stringify(true), setting_type: 'boolean' }) + .onConflict('setting_key').merge(); + const eventReminderService = require('../../src/services/eventReminderService'); + + // Flow disabled → guard does NOT fire (legacy pass owns reminders). + expect(await engine.isBuiltinFlowActive('pre_event_email')).toBe(false); + + // Flow enabled → the pass stands down before doing any work (byWorkflow). + await db('workflows').where({ builtin_key: 'pre_event_email' }).update({ enabled: true }); + const after = await eventReminderService.runEventReminderPass(); + expect(after.byWorkflow).toBe(true); + expect(after.sent).toBe(0); + await db('workflows').where({ builtin_key: 'pre_event_email' }).update({ enabled: false }); // restore + }); + + test('targetWorkflowId runs only the selected flow, not every matching one', async () => { + // Two enabled flows on the same trigger — the quote picks one. + const chosen = await makeWorkflow({ + trigger: 'pick.event', enabled: true, + nodes: [{ key: 'c1', type: 'trigger' }, { key: 'c2', type: 'action', config: { action: 'noop' } }], + edges: [{ from: 'c1', to: 'c2' }], + }); + const other = await makeWorkflow({ + trigger: 'pick.event', enabled: true, + nodes: [{ key: 'o1', type: 'trigger' }, { key: 'o2', type: 'action', config: { action: 'noop' } }], + edges: [{ from: 'o1', to: 'o2' }], + }); + + const runIds = await engine.emitWorkflowEvent('pick.event', { entityType: 'quote', entityId: 99, targetWorkflowId: chosen }); + expect(runIds.length).toBe(1); + const chosenRuns = await db('workflow_runs').where({ workflow_id: chosen, entity_id: 99 }); + const otherRuns = await db('workflow_runs').where({ workflow_id: other, entity_id: 99 }); + expect(chosenRuns.length).toBe(1); // only the selected flow ran + expect(otherRuns.length).toBe(0); // the other matching flow did NOT + }); + + test('gate decision with no matching edge FAILS the run (not a silent done)', async () => { + // Gate has a confirm edge but the deny edge was lost (e.g. a bad import). + const wfId = await makeWorkflow({ + trigger: 'noedge.event', enabled: true, + nodes: [ + { key: 'g0', type: 'trigger' }, + { key: 'g1', type: 'gate', config: {} }, + { key: 'g2', type: 'action', config: { action: 'noop' } }, + ], + edges: [ + { from: 'g0', to: 'g1' }, + { from: 'g1', handle: 'confirm', to: 'g2' }, // no deny edge + ], + }); + const [runId] = await engine.emitWorkflowEvent('noedge.event', { entityType: 'x', entityId: 1 }); + const approval = await db('workflow_approvals').where({ run_id: runId, status: 'pending' }).first(); + await engine.actById(approval.id, 'deny'); // deny has no edge + const run = await db('workflow_runs').where({ id: runId }).first(); + expect(run.status).toBe('failed'); // loud failure, not a green 'done' + expect(run.error).toMatch(/deny.*no matching edge/i); + }); + + test('admin confirms a gate early; the following wait holds dispatch until its date', async () => { + // The booking pattern: prepare → REVIEW GATE → WAIT(event date) → send. The + // admin can approve at the gate whenever; the run then parks at the wait and + // the scheduler dispatches when the date arrives. + const wfId = await makeWorkflow({ + trigger: 'gatewait.event', + nodes: [ + { key: 'g0', type: 'trigger' }, + { key: 'g1', type: 'gate', config: { prompt: 'Approve invoice?' } }, + { key: 'g2', type: 'wait', config: { delayDays: 5 } }, + { key: 'g3', type: 'action', config: { action: 'noop' } }, + ], + edges: [ + { from: 'g0', to: 'g1' }, + { from: 'g1', handle: 'confirm', to: 'g2' }, + { from: 'g2', to: 'g3' }, + ], + }); + const [runId] = await engine.emitWorkflowEvent('gatewait.event', { entityType: 'invoice', entityId: 7 }); + let run = await db('workflow_runs').where({ id: runId }).first(); + expect(run.status).toBe('waiting'); + expect(run.current_node).toBe('g1'); // parked at the review gate + + // Admin confirms EARLY (before the wait date). + const approval = await db('workflow_approvals').where({ run_id: runId, status: 'pending' }).first(); + await engine.actById(approval.id, 'confirm'); + run = await db('workflow_runs').where({ id: runId }).first(); + expect(run.status).toBe('waiting'); + expect(run.current_node).toBe('g2'); // now holding at the wait, not yet dispatched + + // Date arrives → scheduler dispatches. + await db('workflow_runs').where({ id: runId }).update({ wake_at: new Date(Date.now() - 1000).toISOString() }); + await engine.runDueWaits(); + run = await db('workflow_runs').where({ id: runId }).first(); + expect(run.status).toBe('done'); + }); + + test('recoverStaleRuns resumes a run orphaned mid-flow (crash recovery)', async () => { + const wfId = await makeWorkflow({ + trigger: 'recover.event', + nodes: [{ key: 'r1', type: 'trigger' }, { key: 'r2', type: 'action', config: { action: 'noop' } }], + edges: [{ from: 'r1', to: 'r2' }], + }); + // Simulate a run left 'running' at r2 with a stale heartbeat (crash mid-flow). + await db('workflow_runs').insert({ + workflow_id: wfId, version: 1, trigger_event: 'recover.event', status: 'running', current_node: 'r2', + context: JSON.stringify({ vars: {} }), dedup_key: 'recover-1', + updated_at: new Date(Date.now() - 3600000).toISOString(), + }); + const run0 = await db('workflow_runs').where({ dedup_key: 'recover-1' }).first(); + const n = await engine.recoverStaleRuns({ staleMs: 1000 }); + expect(n).toBeGreaterThanOrEqual(1); + const run = await db('workflow_runs').where({ id: run0.id }).first(); + expect(run.status).toBe('done'); + }); + + test('recoverStaleRuns abandons a crash-looping run after the attempts cap', async () => { + const wfId = await makeWorkflow({ + trigger: 'crashloop.event', + nodes: [{ key: 'c1', type: 'trigger' }, { key: 'c2', type: 'action', config: { action: 'noop' } }], + edges: [{ from: 'c1', to: 'c2' }], + }); + await db('workflow_runs').insert({ + workflow_id: wfId, version: 1, trigger_event: 'crashloop.event', status: 'running', current_node: 'c2', + context: JSON.stringify({ vars: {} }), dedup_key: 'crash-1', attempts: 5, + updated_at: new Date(Date.now() - 3600000).toISOString(), + }); + const run0 = await db('workflow_runs').where({ dedup_key: 'crash-1' }).first(); + await engine.recoverStaleRuns({ staleMs: 1000 }); + const run = await db('workflow_runs').where({ id: run0.id }).first(); + expect(run.status).toBe('failed'); + }); + + test('testRun dry-run walks the whole flow (waits skipped, gate auto-confirmed, actions mocked)', async () => { + const wfId = await makeWorkflow({ + trigger: 'testfire.event', + nodes: [ + { key: 't', type: 'trigger' }, + { key: 'w', type: 'wait', config: { delayDays: 14 } }, + { key: 'g', type: 'gate', config: { type: 'payment_confirm' } }, + { key: 'a', type: 'action', config: { action: 'send_email', recipientClass: 'customer' } }, + { key: 'end', type: 'action', config: { action: 'noop' } }, + ], + edges: [ + { from: 't', to: 'w' }, + { from: 'w', to: 'g' }, + { from: 'g', handle: 'confirm', to: 'a' }, + { from: 'g', handle: 'deny', to: 'end' }, + { from: 'a', to: 'end' }, + ], + }); + const runId = await engine.testRun(wfId, { dryRun: true }); + const run = await db('workflow_runs').where({ id: runId }).first(); + expect(run.status).toBe('done'); // walked to completion — no parking at the wait/gate + + const steps = await db('workflow_run_steps').where({ run_id: runId }); + expect(steps.find((s) => s.node_key === 'w').status).toBe('skipped'); // wait passed through + const emailStep = steps.find((s) => s.node_key === 'a'); + expect(JSON.parse(emailStep.result).dryRun).toBe(true); // send_email mocked, no real mail + }); +}); diff --git a/backend/__tests__/integration/workflowRoutes.test.js b/backend/__tests__/integration/workflowRoutes.test.js new file mode 100644 index 00000000..8b283db9 --- /dev/null +++ b/backend/__tests__/integration/workflowRoutes.test.js @@ -0,0 +1,126 @@ +/** + * Admin workflow API — route tests (CRUD, versioning, RBAC gate, approvals). + */ +const request = require('supertest'); +const { + bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp, +} = require('./helpers/crmDb'); + +// bootCrmDb runs the full core-migration set in beforeAll; under full-suite +// parallel load on a small CI runner that can exceed the 5s default. Match the +// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill). +jest.setTimeout(30000); + +let db; +let cleanup; +let app; +let token; +let noPermToken; + +const sampleGraph = { + name: 'Test flow', + trigger_type: 'invoice.sent', + enabled: false, + nodes: [ + { node_key: 'n1', type: 'trigger' }, + { node_key: 'n2', type: 'action', config: { action: 'noop' } }, + ], + edges: [{ from_node: 'n1', to_node: 'n2' }], +}; + +beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + const { adminId } = await seedMinimal(db); + await assignAdminRole(db, adminId, 'super_admin'); + token = mintAdminToken(adminId); + + const ins = await db('admin_users').insert({ + username: 'norole', email: 'nr@example.com', password_hash: 'x', + must_change_password: false, created_at: new Date(), + }).returning('id'); + noPermToken = mintAdminToken(ins[0]?.id ?? ins[0]); + + await db('feature_flags').insert({ key: 'workflows', value: true }); + app = buildRouteApp('/api/admin/workflows', require('../../src/routes/adminWorkflows')); +}); + +afterAll(async () => { await cleanup(); }); + +const auth = (t) => ({ Authorization: `Bearer ${t}` }); + +describe('admin workflows API', () => { + let createdId; + + test('create → 201 with id', async () => { + const res = await request(app).post('/api/admin/workflows').set(auth(token)).send(sampleGraph); + expect(res.status).toBe(201); + expect(res.body.id).toBeGreaterThan(0); + createdId = res.body.id; + }); + + test('rejects a graph without exactly one trigger', async () => { + const res = await request(app).post('/api/admin/workflows').set(auth(token)) + .send({ ...sampleGraph, nodes: [{ node_key: 'x', type: 'action' }], edges: [] }); + expect(res.status).toBe(400); + }); + + test('rejects an unknown node type', async () => { + const res = await request(app).post('/api/admin/workflows').set(auth(token)) + .send({ ...sampleGraph, nodes: [{ node_key: 't', type: 'trigger' }, { node_key: 'x', type: 'actoin' }], edges: [] }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/unknown node type/i); + }); + + 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' } }], + 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); + }); + + test('get one returns the graph', async () => { + const res = await request(app).get(`/api/admin/workflows/${createdId}`).set(auth(token)); + expect(res.status).toBe(200); + expect(res.body.nodes).toHaveLength(2); + expect(res.body.edges).toHaveLength(1); + expect(res.body.version).toBe(1); + }); + + test('list includes it', async () => { + const res = await request(app).get('/api/admin/workflows').set(auth(token)); + expect(res.status).toBe(200); + expect(res.body.some((w) => w.id === createdId)).toBe(true); + }); + + test('update bumps the version', async () => { + const res = await request(app).put(`/api/admin/workflows/${createdId}`).set(auth(token)) + .send({ ...sampleGraph, name: 'Renamed' }); + expect(res.status).toBe(200); + expect(res.body.version).toBe(2); + const get = await request(app).get(`/api/admin/workflows/${createdId}`).set(auth(token)); + expect(get.body.name).toBe('Renamed'); + expect(get.body.version).toBe(2); + }); + + test('enable toggle', async () => { + const res = await request(app).patch(`/api/admin/workflows/${createdId}/enabled`).set(auth(token)).send({ enabled: true }); + expect(res.status).toBe(200); + expect(res.body.enabled).toBe(true); + }); + + test('approvals inbox returns an array', async () => { + const res = await request(app).get('/api/admin/workflows/approvals').set(auth(token)); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + test('a role without workflows.manage is forbidden from writing', async () => { + const res = await request(app).post('/api/admin/workflows').set(auth(noPermToken)).send(sampleGraph); + expect(res.status).toBe(403); + }); +}); diff --git a/backend/migrations/core/142_create_workflow_tables.js b/backend/migrations/core/142_create_workflow_tables.js new file mode 100644 index 00000000..da08da78 --- /dev/null +++ b/backend/migrations/core/142_create_workflow_tables.js @@ -0,0 +1,213 @@ +/** + * Migration 142: Workflow / automation engine schema + permissions. + * + * An admin-configurable visual flow engine (trigger → conditions → ordered + * steps with branching, loops, waits and approval gates). Strictly opt-in via + * the `workflows` feature flag (default off; no run is created/resumed while + * off). See docs / project_workflow_engine_requirements. + * + * Graph model (canvas, not a list): + * - workflows : one row per flow (name, enabled, current `version`, + * trigger_type + trigger_config). Built-ins (e.g. the + * dunning ladder) carry is_builtin + builtin_key. + * - workflow_nodes : nodes of a flow VERSION (node_key, type, config, x/y). + * - workflow_edges : edges of a flow VERSION (from_node[+handle] → to_node). + * Versioned so in-flight runs keep executing the version they started on + * (editing bumps workflows.version and writes a fresh node/edge set). + * - workflow_runs : one execution (pinned version, entity, status, + * current_node, context JSON, wake_at for delays, + * dedup_key to prevent double-fire on re-tick). + * - workflow_run_steps: per-node audit trail (observability + System Health). + * - workflow_approvals: human gates — token_hash for the email confirm/deny + * link (hashed at rest) + the webview inbox. + * + * Loose-FK integers (no DB-level FK) by design, matching whatsapp_queue / + * inbound_documents / expenses — the service cascades child deletes in a + * transaction. Idempotent: every createTable is hasTable-guarded; the + * permission seed mirrors migration 123. + */ +const NEW_PERMISSIONS = [ + { + name: 'workflows.view', + display_name: 'View Workflows', + category: 'workflows', + description: 'View automation workflows, their runs and pending approvals', + }, + { + name: 'workflows.manage', + display_name: 'Manage Workflows', + category: 'workflows', + description: 'Create, edit, enable/disable workflows and act on approval gates', + }, +]; + +exports.up = async function (knex) { + if (!(await knex.schema.hasTable('workflows'))) { + await knex.schema.createTable('workflows', (table) => { + table.increments('id').primary(); + table.string('name', 255).notNullable(); + table.text('description'); + table.boolean('enabled').notNullable().defaultTo(false); + // Current/latest graph version. Editing bumps this; runs pin the value + // they started on so an edit never rewrites a flow mid-run. + table.integer('version').notNullable().defaultTo(1); + table.string('trigger_type', 64).notNullable(); + table.json('trigger_config'); + // Seeded built-ins (e.g. the converted reminder ladder) are flagged so a + // boot self-heal can find/upsert them by a stable key. + table.boolean('is_builtin').notNullable().defaultTo(false); + table.string('builtin_key', 64); + table.integer('created_by').unsigned(); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + table.index(['enabled', 'trigger_type'], 'workflows_trigger_index'); + table.index(['builtin_key']); + }); + } + + if (!(await knex.schema.hasTable('workflow_nodes'))) { + await knex.schema.createTable('workflow_nodes', (table) => { + table.increments('id').primary(); + table.integer('workflow_id').unsigned().notNullable(); + table.integer('version').notNullable().defaultTo(1); + // Stable id within the graph (edges + runs.current_node reference it). + table.string('node_key', 64).notNullable(); + // trigger | condition | branch | loop | wait | action | gate | webhook + table.string('type', 32).notNullable(); + table.json('config'); + table.integer('pos_x').notNullable().defaultTo(0); + table.integer('pos_y').notNullable().defaultTo(0); + table.unique(['workflow_id', 'version', 'node_key'], 'workflow_nodes_key_unique'); + table.index(['workflow_id', 'version'], 'workflow_nodes_graph_index'); + }); + } + + if (!(await knex.schema.hasTable('workflow_edges'))) { + await knex.schema.createTable('workflow_edges', (table) => { + table.increments('id').primary(); + table.integer('workflow_id').unsigned().notNullable(); + table.integer('version').notNullable().defaultTo(1); + table.string('from_node', 64).notNullable(); + // Output handle for multi-path nodes (yes/no, confirm/deny, ≥max/continue). + table.string('from_handle', 32); + table.string('to_node', 64).notNullable(); + table.string('label', 64); + // True for the loop-back edge so the canvas can render it distinctly. + table.boolean('loop_back').notNullable().defaultTo(false); + table.index(['workflow_id', 'version'], 'workflow_edges_graph_index'); + }); + } + + if (!(await knex.schema.hasTable('workflow_runs'))) { + await knex.schema.createTable('workflow_runs', (table) => { + table.increments('id').primary(); + table.integer('workflow_id').unsigned().notNullable(); + // Pinned graph version this run executes. + table.integer('version').notNullable(); + table.string('trigger_event', 64).notNullable(); + table.string('entity_type', 64); + table.integer('entity_id').unsigned(); + // pending | running | waiting | done | failed | cancelled + table.string('status', 20).notNullable().defaultTo('pending'); + table.string('current_node', 64); + table.json('context'); + // Idempotency: prevents a re-emitted/re-ticked trigger from double-firing. + table.string('dedup_key', 191).unique(); + // When a waiting run (delay or gate timeout) should be resumed by the + // scheduler. NULL while running/done. + table.timestamp('wake_at'); + table.timestamp('started_at').defaultTo(knex.fn.now()); + table.timestamp('finished_at'); + table.text('error'); + // Scheduler poll path: waiting runs whose wake_at has passed. + table.index(['status', 'wake_at'], 'workflow_runs_wake_index'); + table.index(['entity_type', 'entity_id'], 'workflow_runs_entity_index'); + table.index(['workflow_id']); + }); + } + + if (!(await knex.schema.hasTable('workflow_run_steps'))) { + await knex.schema.createTable('workflow_run_steps', (table) => { + table.increments('id').primary(); + table.integer('run_id').unsigned().notNullable(); + table.string('node_key', 64).notNullable(); + table.string('node_type', 32); + // done | failed | skipped | waiting + table.string('status', 20).notNullable().defaultTo('pending'); + table.json('result'); + table.text('error'); + table.timestamp('started_at').defaultTo(knex.fn.now()); + table.timestamp('finished_at'); + table.index(['run_id'], 'workflow_run_steps_run_index'); + }); + } + + if (!(await knex.schema.hasTable('workflow_approvals'))) { + await knex.schema.createTable('workflow_approvals', (table) => { + table.increments('id').primary(); + table.integer('run_id').unsigned().notNullable(); + table.string('node_key', 64).notNullable(); + table.string('type', 32).notNullable().defaultTo('payment_confirm'); + // pending | confirmed | denied | expired + table.string('status', 20).notNullable().defaultTo('pending'); + // SHA-256 hex of the single-use email confirm/deny token (hash-on-store). + table.string('token_hash', 128).notNullable(); + table.json('payload'); + table.timestamp('expires_at'); + table.integer('acted_by').unsigned(); + table.string('acted_via', 16); + table.timestamp('acted_at'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.unique(['token_hash'], 'workflow_approvals_token_unique'); + table.index(['status'], 'workflow_approvals_status_index'); + table.index(['run_id']); + }); + } + + // --- Permissions (idempotent, mirrors migration 123) --- + if (await knex.schema.hasTable('permissions')) { + const names = NEW_PERMISSIONS.map((p) => p.name); + const existing = await knex('permissions').whereIn('name', names).select('name'); + const existingSet = new Set(existing.map((r) => r.name)); + const toInsert = NEW_PERMISSIONS.filter((p) => !existingSet.has(p.name)); + if (toInsert.length > 0) await knex('permissions').insert(toInsert); + + if ((await knex.schema.hasTable('roles')) && (await knex.schema.hasTable('role_permissions'))) { + const roles = await knex('roles').whereIn('name', ['super_admin', 'admin']).select('id'); + const perms = await knex('permissions').whereIn('name', names).select('id'); + if (roles.length && perms.length) { + const existingGrants = await knex('role_permissions') + .whereIn('role_id', roles.map((r) => r.id)) + .whereIn('permission_id', perms.map((p) => p.id)) + .select('role_id', 'permission_id'); + const grantSet = new Set(existingGrants.map((g) => `${g.role_id}:${g.permission_id}`)); + const toGrant = []; + for (const r of roles) { + for (const p of perms) { + if (!grantSet.has(`${r.id}:${p.id}`)) { + toGrant.push({ role_id: r.id, permission_id: p.id }); + } + } + } + if (toGrant.length > 0) await knex('role_permissions').insert(toGrant); + } + } + } +}; + +exports.down = async function (knex) { + if (await knex.schema.hasTable('permissions')) { + const names = NEW_PERMISSIONS.map((p) => p.name); + const perms = await knex('permissions').whereIn('name', names).select('id'); + if (perms.length && (await knex.schema.hasTable('role_permissions'))) { + await knex('role_permissions').whereIn('permission_id', perms.map((p) => p.id)).del(); + } + await knex('permissions').whereIn('name', names).del(); + } + await knex.schema.dropTableIfExists('workflow_approvals'); + await knex.schema.dropTableIfExists('workflow_run_steps'); + await knex.schema.dropTableIfExists('workflow_runs'); + await knex.schema.dropTableIfExists('workflow_edges'); + await knex.schema.dropTableIfExists('workflow_nodes'); + await knex.schema.dropTableIfExists('workflows'); +}; diff --git a/backend/migrations/core/143_seed_late_fee_type.js b/backend/migrations/core/143_seed_late_fee_type.js new file mode 100644 index 00000000..37995c89 --- /dev/null +++ b/backend/migrations/core/143_seed_late_fee_type.js @@ -0,0 +1,38 @@ +/** + * Migration 143: late-fee (Mahngebühr) type — flat amount OR percentage. + * + * Extends the existing flat `crm_invoices_late_fee_minor` with a type switch so + * the dunning fee can be a percentage of the invoice gross instead of a fixed + * amount. The fee is charged from the 2nd reminder onwards (the 1st is + * fee-free), accumulating per fee-bearing reminder (2nd = 1×, 3rd = 2×). + * + * Seeds conservative defaults that PRESERVE current behaviour: type='flat' + * (so the existing flat fee keeps applying) and percent=0. Idempotent — + * only inserts keys that don't already exist, never clobbers an admin value. + * + * ⚠️ A late fee is only legally enforceable if the concrete amount is stated in + * the AGB (Liechtenstein/Swiss law) — the admin UI surfaces this; verify with a + * Treuhänder. See docs/crm-disclaimers / [[feedback_legal_financial_examples_only]]. + */ +exports.up = async function (knex) { + if (!(await knex.schema.hasTable('app_settings'))) return; + const seeds = [ + { setting_key: 'crm_invoices_late_fee_type', setting_value: JSON.stringify('flat'), setting_type: 'crm' }, + { setting_key: 'crm_invoices_late_fee_percent', setting_value: JSON.stringify(0), setting_type: 'crm' }, + // VAT on the late fee is jurisdiction-dependent (CH: yes; DE/AT: no), so it's + // a toggle. Default OFF (preserve current no-VAT behaviour). No-op anyway + // when the org doesn't charge VAT (business_profile.vat_rate_default = 0). + { setting_key: 'crm_invoices_late_fee_vat_enabled', setting_value: JSON.stringify(false), setting_type: 'crm' }, + ]; + for (const s of seeds) { + const exists = await knex('app_settings').where({ setting_key: s.setting_key }).first(); + if (!exists) await knex('app_settings').insert({ ...s, updated_at: new Date() }); + } +}; + +exports.down = async function (knex) { + if (!(await knex.schema.hasTable('app_settings'))) return; + await knex('app_settings') + .whereIn('setting_key', ['crm_invoices_late_fee_type', 'crm_invoices_late_fee_percent', 'crm_invoices_late_fee_vat_enabled']) + .del(); +}; diff --git a/backend/migrations/core/144_add_late_fee_vat_minor.js b/backend/migrations/core/144_add_late_fee_vat_minor.js new file mode 100644 index 00000000..d7db7a1f --- /dev/null +++ b/backend/migrations/core/144_add_late_fee_vat_minor.js @@ -0,0 +1,24 @@ +/** + * Migration 144: track the VAT portion of the Mahngebühr separately. + * + * The dunning rework keeps the fee on the invoice ROW as dunning state (gross + * in late_fee_amount_minor) but renders it on a separate Mahnung document, NOT + * on the immutable invoice. `late_fee_vat_minor` records the VAT component + * (0 when VAT-exempt — DE/AT, or the org has no VAT) so the Mahnung can show + * the breakdown and the tax report can later book the Mahngebühr VAT (CH). + */ +exports.up = async function (knex) { + if (!(await knex.schema.hasTable('invoices'))) return; + if (!(await knex.schema.hasColumn('invoices', 'late_fee_vat_minor'))) { + await knex.schema.alterTable('invoices', (t) => { + t.bigInteger('late_fee_vat_minor').notNullable().defaultTo(0); + }); + } +}; + +exports.down = async function (knex) { + if (!(await knex.schema.hasTable('invoices'))) return; + if (await knex.schema.hasColumn('invoices', 'late_fee_vat_minor')) { + await knex.schema.alterTable('invoices', (t) => t.dropColumn('late_fee_vat_minor')); + } +}; diff --git a/backend/migrations/core/145_workflow_run_recovery.js b/backend/migrations/core/145_workflow_run_recovery.js new file mode 100644 index 00000000..d44adec0 --- /dev/null +++ b/backend/migrations/core/145_workflow_run_recovery.js @@ -0,0 +1,33 @@ +/** + * Migration 145: crash-recovery fields for workflow runs. + * + * A run left in 'running'/'pending' by a crash has nothing to resume it (the + * scheduler only wakes 'waiting' runs). Add a heartbeat (`updated_at`, stamped + * on every step) so a recovery sweep can detect stale runs, plus an `attempts` + * counter so a node that reliably crashes the process can't be recovered + * forever (crash-loop backstop → marked failed after a cap). + */ +exports.up = async function (knex) { + if (!(await knex.schema.hasTable('workflow_runs'))) return; + const hasUpdated = await knex.schema.hasColumn('workflow_runs', 'updated_at'); + const hasAttempts = await knex.schema.hasColumn('workflow_runs', 'attempts'); + await knex.schema.alterTable('workflow_runs', (t) => { + if (!hasUpdated) t.timestamp('updated_at').defaultTo(knex.fn.now()); + if (!hasAttempts) t.integer('attempts').notNullable().defaultTo(0); + }); + // Recovery sweep queries by (status, updated_at). + if (!hasUpdated) { + try { await knex.schema.alterTable('workflow_runs', (t) => t.index(['status', 'updated_at'], 'workflow_runs_recovery_index')); } catch (_) {} + } +}; + +exports.down = async function (knex) { + if (!(await knex.schema.hasTable('workflow_runs'))) return; + try { await knex.schema.alterTable('workflow_runs', (t) => t.dropIndex(['status', 'updated_at'], 'workflow_runs_recovery_index')); } catch (_) {} + if (await knex.schema.hasColumn('workflow_runs', 'updated_at')) { + await knex.schema.alterTable('workflow_runs', (t) => t.dropColumn('updated_at')); + } + if (await knex.schema.hasColumn('workflow_runs', 'attempts')) { + await knex.schema.alterTable('workflow_runs', (t) => t.dropColumn('attempts')); + } +}; diff --git a/backend/migrations/core/146_add_quote_event_type.js b/backend/migrations/core/146_add_quote_event_type.js new file mode 100644 index 00000000..bb4b7637 --- /dev/null +++ b/backend/migrations/core/146_add_quote_event_type.js @@ -0,0 +1,25 @@ +/** + * Migration 146: carry an event type on the quote. + * + * Quotes already snapshot event_name + event_date, but not the TYPE. Without it + * the quote→event conversion (convertToEvent) had to hardcode 'wedding'. This + * column lets the admin pick the type on the quote (from the event_types + * catalog, stored as its slug_prefix — same shape as events.event_type), so the + * conversion / booking flow's prepare_event can carry it through. Nullable: old + * quotes and the "didn't pick one" case fall back to a configurable default. + */ +exports.up = async function (knex) { + if (!(await knex.schema.hasTable('quotes'))) return; + if (!(await knex.schema.hasColumn('quotes', 'event_type'))) { + await knex.schema.alterTable('quotes', (t) => { + t.string('event_type', 64); + }); + } +}; + +exports.down = async function (knex) { + if (!(await knex.schema.hasTable('quotes'))) return; + if (await knex.schema.hasColumn('quotes', 'event_type')) { + await knex.schema.alterTable('quotes', (t) => t.dropColumn('event_type')); + } +}; diff --git a/backend/migrations/core/147_add_quote_booking_workflow.js b/backend/migrations/core/147_add_quote_booking_workflow.js new file mode 100644 index 00000000..f61ab48a --- /dev/null +++ b/backend/migrations/core/147_add_quote_booking_workflow.js @@ -0,0 +1,25 @@ +/** + * Migration 147: let a quote pick the booking workflow it runs on acceptance. + * + * Today quote.accepted fans out to every enabled flow with that trigger. This + * column lets the admin choose ONE workflow per quote (e.g. "with contract" vs + * "invoice only, no gallery"); emitQuoteEvent passes it as targetWorkflowId so + * only the chosen flow runs. Plain nullable integer (not a hard FK) — the emit + * re-checks the workflow exists + is enabled + matches the trigger at fire time, + * so a deleted/disabled selection just runs nothing. + */ +exports.up = async function (knex) { + if (!(await knex.schema.hasTable('quotes'))) return; + if (!(await knex.schema.hasColumn('quotes', 'booking_workflow_id'))) { + await knex.schema.alterTable('quotes', (t) => { + t.integer('booking_workflow_id'); + }); + } +}; + +exports.down = async function (knex) { + if (!(await knex.schema.hasTable('quotes'))) return; + if (await knex.schema.hasColumn('quotes', 'booking_workflow_id')) { + await knex.schema.alterTable('quotes', (t) => t.dropColumn('booking_workflow_id')); + } +}; diff --git a/backend/migrations/core/148_add_workflow_admin_toggled_at.js b/backend/migrations/core/148_add_workflow_admin_toggled_at.js new file mode 100644 index 00000000..1806229a --- /dev/null +++ b/backend/migrations/core/148_add_workflow_admin_toggled_at.js @@ -0,0 +1,24 @@ +/** + * Migration 148: mark when an admin has taken ownership of a (built-in) workflow. + * + * The boot seeder re-seeds a built-in on a SEED_VERSION bump and applies the new + * default `enabled` state. Without a sentinel that would re-flip a flow the + * admin had deliberately enabled/disabled. `admin_toggled_at` is stamped on any + * admin enable/disable or edit; the seeder then leaves that flow alone. Nullable + * → existing rows are treated as never-touched (seed defaults apply once). + */ +exports.up = async function (knex) { + if (!(await knex.schema.hasTable('workflows'))) return; + if (!(await knex.schema.hasColumn('workflows', 'admin_toggled_at'))) { + await knex.schema.alterTable('workflows', (t) => { + t.timestamp('admin_toggled_at'); + }); + } +}; + +exports.down = async function (knex) { + if (!(await knex.schema.hasTable('workflows'))) return; + if (await knex.schema.hasColumn('workflows', 'admin_toggled_at')) { + await knex.schema.alterTable('workflows', (t) => t.dropColumn('admin_toggled_at')); + } +}; diff --git a/backend/server.js b/backend/server.js index 7709e394..6b71ea3f 100644 --- a/backend/server.js +++ b/backend/server.js @@ -707,6 +707,7 @@ app.use('/api/admin/contracts', require('./src/routes/adminContracts')); app.use('/api/admin/projects', require('./src/routes/adminProjects')); app.use('/api/admin/calendar', require('./src/routes/adminCalendar')); app.use('/api/admin/deals', require('./src/routes/adminDeals')); +app.use('/api/admin/workflows', require('./src/routes/adminWorkflows')); app.use('/api/admin/tax-report', require('./src/routes/adminTaxReport')); app.use('/api/admin/expenses', require('./src/routes/adminExpenses')); app.use('/api/admin/ledger', require('./src/routes/adminLedger')); @@ -718,6 +719,7 @@ app.use('/api/admin/dev', require('./src/routes/adminDev')); app.use('/api/public/quotes', require('./src/routes/publicQuotes')); app.use('/api/public/contracts', require('./src/routes/publicContracts')); app.use('/api/public/payment-check', require('./src/routes/publicPaymentCheck')); +app.use('/api/public/workflow-approvals', require('./src/routes/publicWorkflowApprovals')); app.use('/api/admin/event-types', require('./src/routes/adminEventTypes')); app.use('/api/admin/api-tokens', require('./src/routes/adminApiTokens')); app.use('/api/admin/webhooks', require('./src/routes/adminWebhooks')); @@ -895,6 +897,15 @@ async function startServer() { logger.warn('restore-settings self-heal failed at boot:', err.message); } + // Seed built-in workflows (the editable invoice-dunning flow). Disabled by + // default — live reminder behaviour is unchanged. See _workflowSeedBoot.js. + try { + const { seedBuiltinWorkflowsAtBoot } = require('./src/services/_workflowSeedBoot'); + await seedBuiltinWorkflowsAtBoot(db, logger); + } catch (err) { + logger.warn('built-in workflow seed failed at boot:', err.message); + } + // Install-from-backup trigger. If `RESTORE_ON_INSTALL` (or // `.txt`) exists in the /backup mount AND the DB is empty, run // the restore HERE before any admin UI surfaces. Lets admins diff --git a/backend/src/routes/adminFeatureFlags.js b/backend/src/routes/adminFeatureFlags.js index 1b3badd0..65114458 100644 --- a/backend/src/routes/adminFeatureFlags.js +++ b/backend/src/routes/adminFeatureFlags.js @@ -88,6 +88,11 @@ const KNOWN_FLAGS = [ // per-event-type presets and global watermark defaults tab. Strictly opt-in; // gates all slideshow admin UI (per-event card, type preset, settings tab). 'slideshow', + // Workflow / automation engine — admin-configurable visual flows (triggers, + // conditions, branches, loops, approval gates). Strictly opt-in; master + // kill-switch for the Workflows admin area AND the engine's runtime side + // effects (no run is created/resumed while off). + 'workflows', ]; // Spec defaults for any flag missing from the DB (e.g. a row added by a @@ -117,6 +122,7 @@ const DEFAULT_FLAGS = { projects: false, whatsapp: false, slideshow: false, + workflows: false, }; async function readAllFlags() { diff --git a/backend/src/routes/adminQuotes.js b/backend/src/routes/adminQuotes.js index a130f4c1..afaeff29 100644 --- a/backend/src/routes/adminQuotes.js +++ b/backend/src/routes/adminQuotes.js @@ -86,6 +86,8 @@ function transformQuote(q) { validUntil: q.valid_until, eventName: q.event_name, eventDate: q.event_date, + eventType: q.event_type ?? null, + bookingWorkflowId: q.booking_workflow_id ?? null, eventTimeStart: q.event_time_start, eventTimeEnd: q.event_time_end, expectedDurationHours: q.expected_duration_hours == null ? null : Number(q.expected_duration_hours), @@ -212,7 +214,8 @@ function mapPayloadToService(body) { customerAccountId: 'customerAccountId', language: 'language', currency: 'currency', issueDate: 'issueDate', validUntil: 'validUntil', - eventName: 'eventName', eventDate: 'eventDate', + eventName: 'eventName', eventDate: 'eventDate', eventType: 'eventType', + bookingWorkflowId: 'bookingWorkflowId', eventTimeStart: 'eventTimeStart', eventTimeEnd: 'eventTimeEnd', expectedDurationHours: 'expectedDurationHours', paymentTermTemplateId: 'paymentTermTemplateId', diff --git a/backend/src/routes/adminWorkflows.js b/backend/src/routes/adminWorkflows.js new file mode 100644 index 00000000..0b17babf --- /dev/null +++ b/backend/src/routes/adminWorkflows.js @@ -0,0 +1,284 @@ +/** + * Admin workflow management API. + * + * GET /api/admin/workflows list + * GET /api/admin/workflows/approvals pending-approval inbox + * POST /api/admin/workflows/approvals/:id/:act confirm|deny (webview) + * GET /api/admin/workflows/runs/:runId/steps run step audit + * GET /api/admin/workflows/:id/runs run history + * GET /api/admin/workflows/:id one workflow + its graph + * POST /api/admin/workflows create + * PUT /api/admin/workflows/:id update (bumps version) + * PATCH /api/admin/workflows/:id/enabled enable/disable + * DELETE /api/admin/workflows/:id delete (built-ins refused) + * + * Versioning: editing writes a fresh node/edge set under version+1 and bumps + * workflows.version; in-flight runs keep executing the version they pinned. + * All endpoints gated by the `workflows` feature flag + RBAC (view/manage). + */ +const express = require('express'); + +const router = express.Router(); +const { db } = require('../database/db'); +const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); +const { requireFeatureFlag } = require('../middleware/requireFeatureFlag'); +const workflows = require('../services/workflows'); +const { DOCUMENT_ACTIONS } = require('../services/workflows/actions'); +const { hasColumnCached } = require('../utils/schemaCache'); + +router.use(adminAuth, requireFeatureFlag('workflows')); + +// Graph payload caps — a workflows.manage user shouldn't be able to DoS the DB +// with an enormous graph. Generous vs any real flow. +const MAX_NODES = 200; +const MAX_EDGES = 500; +const MAX_NODE_CONFIG_BYTES = 16 * 1024; +const VALID_NODE_TYPES = new Set(['trigger', 'action', 'condition', 'branch', 'loop', 'wait', 'gate', 'webhook']); +// Actions registered but not yet wired (return {skipped:true}); a flow that +// uses any of these can't be meaningfully enabled. +const UNIMPLEMENTED_ACTIONS = new Set(DOCUMENT_ACTIONS); + +function parseJson(v, fallback) { + if (v == null) return fallback; + if (typeof v === 'object') return v; + try { return JSON.parse(v); } catch (e) { return fallback; } +} + +function validateGraph(body) { + const nodes = Array.isArray(body.nodes) ? body.nodes : []; + const edges = Array.isArray(body.edges) ? body.edges : []; + if (nodes.length > MAX_NODES) return `Too many nodes (max ${MAX_NODES})`; + if (edges.length > MAX_EDGES) return `Too many edges (max ${MAX_EDGES})`; + const triggers = nodes.filter((n) => n.type === 'trigger'); + if (triggers.length !== 1) return 'A workflow must have exactly one trigger node'; + if (nodes.some((n) => !n.node_key || !n.type)) return 'Every node needs a node_key and type'; + const badType = nodes.find((n) => !VALID_NODE_TYPES.has(n.type)); + if (badType) return `Unknown node type '${badType.type}'`; + const oversized = nodes.find((n) => JSON.stringify(n.config || {}).length > MAX_NODE_CONFIG_BYTES); + if (oversized) return `Node '${oversized.node_key}' config is too large (max ${MAX_NODE_CONFIG_BYTES} bytes)`; + const keys = new Set(nodes.map((n) => n.node_key)); + if (keys.size !== nodes.length) return 'Duplicate node_key in graph'; + for (const e of edges) { + if (!keys.has(e.from_node) || !keys.has(e.to_node)) return 'Edge references an unknown node'; + } + return null; +} + +// The unimplemented (stub) actions a graph references — used to refuse enabling +// a flow that would silently no-op (e.g. the booking built-ins' prepare_*/send). +function unimplementedActionsIn(nodes = []) { + const found = new Set(); + for (const n of nodes) { + const action = n && n.config && n.config.action; + if (action && UNIMPLEMENTED_ACTIONS.has(action)) found.add(action); + } + return [...found]; +} + +async function writeGraph(trx, workflowId, version, nodes = [], edges = []) { + for (const n of nodes) { + await trx('workflow_nodes').insert({ + workflow_id: workflowId, version, node_key: n.node_key, type: n.type, + config: JSON.stringify(n.config || {}), pos_x: n.pos_x || 0, pos_y: n.pos_y || 0, + }); + } + for (const e of edges) { + await trx('workflow_edges').insert({ + workflow_id: workflowId, version, from_node: e.from_node, from_handle: e.from_handle || null, + to_node: e.to_node, label: e.label || null, loop_back: !!e.loop_back, + }); + } +} + +// --- Approvals inbox (registered before /:id so 'approvals' isn't an id) --- +router.get('/approvals', requirePermission('workflows.view'), async (req, res, next) => { + try { + const items = await workflows.listPending(); + res.json(items.map((a) => ({ ...a, payload: parseJson(a.payload, {}) }))); + } catch (e) { next(e); } +}); + +router.post('/approvals/:id/:action', requirePermission('workflows.manage'), async (req, res, next) => { + try { + const { action } = req.params; + if (!['confirm', 'deny'].includes(action)) return res.status(400).json({ error: 'Invalid action' }); + const result = await workflows.actById(Number(req.params.id), action, req.admin?.id); + if (!result.ok && result.reason === 'not_found') return res.status(404).json({ error: 'Approval not found' }); + if (!result.ok && result.reason === 'expired') return res.status(410).json({ error: 'Approval expired' }); + res.json(result); + } catch (e) { next(e); } +}); + +// --- Run history --- +router.get('/runs/:runId/steps', requirePermission('workflows.view'), async (req, res, next) => { + try { + const steps = await db('workflow_run_steps').where({ run_id: Number(req.params.runId) }).orderBy('id', 'asc'); + res.json(steps.map((s) => ({ ...s, result: parseJson(s.result, null) }))); + } catch (e) { next(e); } +}); + +router.get('/:id/runs', requirePermission('workflows.view'), async (req, res, next) => { + try { + const runs = await db('workflow_runs').where({ workflow_id: Number(req.params.id) }).orderBy('id', 'desc').limit(200); + res.json(runs.map((r) => ({ ...r, context: parseJson(r.context, {}) }))); + } catch (e) { next(e); } +}); + +// Test-fire: run the workflow on demand (default dry-run — side effects mocked, +// waits skipped, gates auto-confirm) and return the step-by-step log. +router.post('/:id/test-run', requirePermission('workflows.manage'), async (req, res, next) => { + try { + const { entityType, entityId, payload, dryRun } = req.body || {}; + const runId = await workflows.testRun(Number(req.params.id), { + entityType: entityType || null, + entityId: entityId != null && entityId !== '' ? Number(entityId) : null, + payload: payload && typeof payload === 'object' ? payload : {}, + dryRun: dryRun !== false, // default true (safe) + }); + const run = await db('workflow_runs').where({ id: runId }).first(); + const steps = await db('workflow_run_steps').where({ run_id: runId }).orderBy('id', 'asc'); + res.json({ + runId, + dryRun: dryRun !== false, + status: run?.status, + steps: steps.map((s) => ({ ...s, result: parseJson(s.result, null) })), + }); + } catch (e) { next(e); } +}); + +// --- List / get --- +router.get('/', requirePermission('workflows.view'), async (req, res, next) => { + try { + const rows = await db('workflows').orderBy('id', 'desc'); + res.json(rows.map((w) => ({ ...w, trigger_config: parseJson(w.trigger_config, null) }))); + } catch (e) { next(e); } +}); + +router.get('/:id', requirePermission('workflows.view'), async (req, res, next) => { + try { + const wf = await db('workflows').where({ id: Number(req.params.id) }).first(); + if (!wf) return res.status(404).json({ error: 'Workflow not found' }); + const nodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: wf.version }); + const edges = await db('workflow_edges').where({ workflow_id: wf.id, version: wf.version }); + res.json({ + ...wf, + trigger_config: parseJson(wf.trigger_config, null), + nodes: nodes.map((n) => ({ ...n, config: parseJson(n.config, {}) })), + edges, + }); + } catch (e) { next(e); } +}); + +// --- Create / update / toggle / delete --- +router.post('/', requirePermission('workflows.manage'), async (req, res, next) => { + try { + const b = req.body || {}; + if (!b.name || !b.trigger_type) return res.status(400).json({ error: 'name and trigger_type are required' }); + const err = validateGraph(b); + if (err) return res.status(400).json({ error: err }); + if (b.enabled) { + const stubs = unimplementedActionsIn(b.nodes); + 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(', ')}.` }); + } + const id = await db.transaction(async (trx) => { + const ins = await trx('workflows').insert({ + name: b.name, description: b.description || null, enabled: !!b.enabled, version: 1, + trigger_type: b.trigger_type, trigger_config: b.trigger_config ? JSON.stringify(b.trigger_config) : null, + created_by: req.admin?.id || null, + }).returning('id'); + // Postgres returns [] without an explicit returning clause, so ins[0] + // would be undefined → the child node inserts would violate NOT NULL. + // Normalise the {id} (pg) vs bare id (sqlite) shapes. + const newId = ins[0]?.id ?? ins[0]; + await writeGraph(trx, newId, 1, b.nodes, b.edges); + return newId; + }); + res.status(201).json({ id }); + } catch (e) { next(e); } +}); + +router.put('/:id', requirePermission('workflows.manage'), async (req, res, next) => { + try { + const id = Number(req.params.id); + const wf = await db('workflows').where({ id }).first(); + if (!wf) return res.status(404).json({ error: 'Workflow not found' }); + const b = req.body || {}; + const err = validateGraph(b); + if (err) return res.status(400).json({ error: err }); + const willEnable = b.enabled != null ? !!b.enabled : (wf.enabled === true || wf.enabled === 1); + if (willEnable) { + const stubs = unimplementedActionsIn(b.nodes); + 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(', ')}.` }); + } + const newVersion = wf.version + 1; + const hasAdminToggled = await hasColumnCached('workflows', 'admin_toggled_at'); + await db.transaction(async (trx) => { + const update = { + name: b.name ?? wf.name, + description: b.description ?? wf.description, + enabled: b.enabled != null ? !!b.enabled : wf.enabled, + trigger_type: b.trigger_type ?? wf.trigger_type, + trigger_config: b.trigger_config !== undefined + ? (b.trigger_config ? JSON.stringify(b.trigger_config) : null) + : wf.trigger_config, + version: newVersion, + updated_at: trx.fn.now(), + }; + // An admin edit claims ownership of a built-in so the boot seeder stops + // re-seeding / re-enabling it (see _workflowSeedBoot). + if (hasAdminToggled) update.admin_toggled_at = trx.fn.now(); + await trx('workflows').where({ id }).update(update); + await writeGraph(trx, id, newVersion, b.nodes, b.edges); + }); + res.json({ id, version: newVersion }); + } catch (e) { next(e); } +}); + +router.patch('/:id/enabled', requirePermission('workflows.manage'), async (req, res, next) => { + try { + const id = Number(req.params.id); + const enabled = !!(req.body && req.body.enabled); + const wf = await db('workflows').where({ id }).first(); + if (!wf) return res.status(404).json({ error: 'Workflow not found' }); + // Refuse to enable a flow that would silently no-op — i.e. one whose graph + // references actions that aren't implemented yet (the booking built-ins' + // prepare_*/send_document stubs). Concern #5 from review. + if (enabled) { + const rows = await db('workflow_nodes').where({ workflow_id: id, version: wf.version }); + const stubs = unimplementedActionsIn(rows.map((n) => ({ config: parseJson(n.config, {}) }))); + 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(', ')}.` }); + } + } + const patch = { enabled, updated_at: db.fn.now() }; + // Mark admin ownership so the boot seeder won't re-flip this built-in's + // enabled state on the next SEED_VERSION bump (review nit #1). + if (await hasColumnCached('workflows', 'admin_toggled_at')) patch.admin_toggled_at = db.fn.now(); + await db('workflows').where({ id }).update(patch); + res.json({ id, enabled }); + } catch (e) { next(e); } +}); + +router.delete('/:id', requirePermission('workflows.manage'), async (req, res, next) => { + try { + const id = Number(req.params.id); + const wf = await db('workflows').where({ id }).first(); + if (!wf) return res.status(404).json({ error: 'Workflow not found' }); + if (wf.is_builtin) return res.status(409).json({ error: 'Built-in workflows cannot be deleted' }); + await db.transaction(async (trx) => { + const runIds = (await trx('workflow_runs').where({ workflow_id: id }).select('id')).map((r) => r.id); + if (runIds.length) { + await trx('workflow_run_steps').whereIn('run_id', runIds).del(); + await trx('workflow_approvals').whereIn('run_id', runIds).del(); + } + await trx('workflow_runs').where({ workflow_id: id }).del(); + await trx('workflow_edges').where({ workflow_id: id }).del(); + await trx('workflow_nodes').where({ workflow_id: id }).del(); + await trx('workflows').where({ id }).del(); + }); + res.json({ deleted: true }); + } catch (e) { next(e); } +}); + +module.exports = router; diff --git a/backend/src/routes/publicWorkflowApprovals.js b/backend/src/routes/publicWorkflowApprovals.js new file mode 100644 index 00000000..559619de --- /dev/null +++ b/backend/src/routes/publicWorkflowApprovals.js @@ -0,0 +1,102 @@ +/** + * Public workflow-approval endpoint — the confirm/deny links emailed to the + * admin when a workflow gate is reached. Token is the single-use raw value + * (hashed at rest); acting resumes the run down the matching edge. + * + * Prefetch safety: GET is NEVER state-changing. Email clients + security + * scanners (Outlook Safe Links, Gmail, Proofpoint, AV link-checkers) GET email + * URLs before the human clicks — a GET that acted would silently advance a + * payment-confirm gate. So GET renders an interstitial with buttons that POST + * the decision; only POST calls actByToken. The token still gates everything + * (256-bit, single-use), and prefetchers don't POST. + */ +const express = require('express'); + +const router = express.Router(); +const { actByToken, peekApproval } = require('../services/workflows'); + +function page(title, body) { + return `` + + `` + + `${title}` + + `` + + `

${title}

${body}

`; +} + +// Escape any prompt text we echo into the interstitial HTML. +function esc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ( + { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] + )); +} + +function decisionPage(token, emphasis, prompt) { + const btn = (href, label, primary) => `
` + + `
`; + const body = (prompt ? `${esc(prompt)}` : '') + + `
` + + btn(`confirm`, 'Confirm payment received', emphasis === 'confirm') + + btn(`deny`, 'No payment received', emphasis === 'deny') + + `
` + + `

Choosing is a single, final action.

`; + return page('Confirm your response', body); +} + +// GET — render the interstitial. READ-ONLY: never mutates / resumes. +router.get('/:token/:action', async (req, res) => { + const { token, action } = req.params; + if (!['confirm', 'deny'].includes(action)) { + return res.status(400).send(page('Invalid link', 'This confirmation link is not valid.')); + } + try { + const info = await peekApproval(token); + if (!info.found) { + return res.status(404).send(page('Link not found', 'This confirmation link is invalid or has been revoked.')); + } + if (info.status !== 'pending') { + return res.send(page('Already recorded', `This request was already ${esc(info.status)}.`)); + } + if (info.expired) { + return res.status(410).send(page('Link expired', 'This confirmation link has expired. Use the workflow inbox in the admin panel instead.')); + } + // Relative form actions resolve against the current path's directory; the + // emphasis just highlights the button matching the link they clicked. + return res.send(decisionPage(token, action, info.prompt)); + } catch (e) { + return res.status(500).send(page('Something went wrong', 'Please try again or use the admin panel.')); + } +}); + +// POST — the actual decision. Only a human (or an explicit form submit) reaches +// here; prefetchers issue GET, not POST. +router.post('/:token/:action', async (req, res) => { + const { token, action } = req.params; + if (!['confirm', 'deny'].includes(action)) { + return res.status(400).send(page('Invalid link', 'This confirmation link is not valid.')); + } + try { + const result = await actByToken(token, action); + if (!result.ok && result.reason === 'not_found') { + return res.status(404).send(page('Link not found', 'This confirmation link is invalid or has been revoked.')); + } + if (!result.ok && result.reason === 'expired') { + return res.status(410).send(page('Link expired', 'This confirmation link has expired. Use the workflow inbox in the admin panel instead.')); + } + if (result.already) { + return res.send(page('Already recorded', `This request was already ${esc(result.status)}.`)); + } + return res.send(page( + 'Thank you', + action === 'confirm' + ? 'Confirmed — the workflow will continue.' + : 'Recorded — the workflow has been told there is no payment / to stop.', + )); + } catch (e) { + return res.status(500).send(page('Something went wrong', 'We could not record your response. Please try again or use the admin panel.')); + } +}); + +module.exports = router; diff --git a/backend/src/services/_workflowSeedBoot.js b/backend/src/services/_workflowSeedBoot.js new file mode 100644 index 00000000..1f0f1d98 --- /dev/null +++ b/backend/src/services/_workflowSeedBoot.js @@ -0,0 +1,431 @@ +/** + * Boot-time seed for built-in workflows. + * + * Seeds the reminder/booking ladders as EDITABLE built-in flows, so the canvas + * has real content and admins can see (and tweak) their processes as blocks. + * + * IMPORTANT — every built-in is seeded DISABLED. Live behaviour is UNCHANGED + * until an admin enables a flow: the hardcoded reminder ladder still runs, and + * the booking document actions (prepare_quote/contract/event/invoice) are still + * stubs that record an observable `skipped` step rather than firing. The + * cutover (drive each process through the engine + stop the hardcoded path) is a + * deliberate follow-up so we never double-act. Enabling a flow before its + * cutover is safe — at worst it records skipped steps — but the dunning flow in + * particular auto-suppresses the hardcoded ladder while enabled so the two never + * double-send. + * + * Idempotent: keyed on builtin_key. Once seeded, admin edits are preserved (we + * never overwrite an enabled built-in, and re-seed a disabled one only when its + * SEED_VERSION moves on). Self-heal pattern per [[feedback_self_heal_pattern]]. + */ +const { getAppSetting } = require('../utils/appSettings'); + +const DUNNING_KEY = 'invoice_dunning'; + +function buildDunningGraph({ firstDays, gapDays, maxReminders }) { + // Delegation model: the payment-check email IS the admin gate (it drives the + // existing confirm + reminder_level + Mahngebühr state machine), so the flow + // just decides WHEN to fire it. After due date + grace, loop up to + // maxReminders times: if still unpaid, queue a payment-check, wait the gap, + // repeat; stop early once paid. After the loop exhausts → collections handoff. + const nodes = [ + { node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 }, + { node_key: 'waitDue', type: 'wait', config: { untilVar: 'dueDate' }, pos_x: 240, pos_y: 110 }, + { node_key: 'waitGrace', type: 'wait', config: { delayDays: firstDays }, pos_x: 240, pos_y: 220 }, + { node_key: 'loop', type: 'loop', config: { maxIterations: maxReminders }, pos_x: 240, pos_y: 330 }, + { node_key: 'checkPaid', type: 'condition', config: { condition: 'invoice_paid' }, pos_x: 240, pos_y: 440 }, + { node_key: 'paymentCheck', type: 'action', config: { action: 'queue_payment_check' }, pos_x: 240, pos_y: 550 }, + { node_key: 'waitGap', type: 'wait', config: { delayDays: gapDays }, pos_x: 240, pos_y: 660 }, + { node_key: 'donePaid', type: 'action', config: { action: 'noop' }, pos_x: 520, pos_y: 440 }, + { node_key: 'collections', type: 'action', config: { action: 'escalate_to_collections' }, pos_x: 520, pos_y: 250 }, + { node_key: 'doneEnd', type: 'action', config: { action: 'noop' }, pos_x: 760, pos_y: 250 }, + ]; + const edges = [ + { from_node: 't', to_node: 'waitDue' }, + { from_node: 'waitDue', to_node: 'waitGrace' }, + { from_node: 'waitGrace', to_node: 'loop' }, + { from_node: 'loop', from_handle: 'loop', to_node: 'checkPaid' }, + { from_node: 'loop', from_handle: 'exit', to_node: 'collections' }, + { from_node: 'collections', to_node: 'doneEnd' }, + { from_node: 'checkPaid', from_handle: 'yes', to_node: 'donePaid' }, + { from_node: 'checkPaid', from_handle: 'no', to_node: 'paymentCheck' }, + { from_node: 'paymentCheck', to_node: 'waitGap' }, + { from_node: 'waitGap', to_node: 'loop', loop_back: true }, + ]; + return { nodes, edges }; +} + +// Booking — quote accepted → prepare contract → ADMIN REVIEW GATE → send +// contract → admin gate "signed?" → create the event/gallery → wait to the +// event date → prepare invoice → ADMIN REVIEW GATE → send invoice. +// +// A document is never sent without an explicit admin OK: prepare_* creates a +// DRAFT, the admin adjusts line items / terms in the CRM, then confirms the +// review gate, and only then does send_document fire. The "signed?" gate models +// the external signing step (no e-sign webhook yet). The document actions are +// stubs until the booking cutover, so an enabled run records observable skipped +// steps rather than acting. +function buildBookingFullGraph() { + const nodes = [ + { node_key: 't', type: 'trigger', config: {}, pos_x: 320, pos_y: 0 }, + { node_key: 'prepContract', type: 'action', config: { action: 'prepare_contract' }, pos_x: 320, pos_y: 110 }, + { node_key: 'reviewContract', type: 'gate', config: { label: 'Review contract before sending' }, pos_x: 320, pos_y: 220 }, + { node_key: 'sendContract', type: 'action', config: { action: 'send_document', document: 'contract', recipient: 'customer' }, pos_x: 320, pos_y: 330 }, + { node_key: 'gateSigned', type: 'gate', config: { label: 'Contract signed?' }, pos_x: 320, pos_y: 440 }, + { node_key: 'prepEvent', type: 'action', config: { action: 'prepare_event' }, pos_x: 320, pos_y: 550 }, + { node_key: 'prepInvoice', type: 'action', config: { action: 'prepare_invoice' }, pos_x: 320, pos_y: 660 }, + { node_key: 'reviewInvoice', type: 'gate', config: { label: 'Review invoice (early — dispatch waits for the event)' }, pos_x: 320, pos_y: 770 }, + { node_key: 'waitEvent', type: 'wait', config: { untilVar: 'eventDate' }, pos_x: 320, pos_y: 880 }, + { node_key: 'sendInvoice', type: 'action', config: { action: 'send_document', document: 'invoice', recipient: 'customer' }, pos_x: 320, pos_y: 990 }, + { node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 320, pos_y: 1100 }, + { node_key: 'cancelContract', type: 'action', config: { action: 'noop' }, pos_x: 620, pos_y: 220 }, + { node_key: 'declined', type: 'action', config: { action: 'noop' }, pos_x: 620, pos_y: 440 }, + { node_key: 'cancelInvoice', type: 'action', config: { action: 'noop' }, pos_x: 620, pos_y: 770 }, + ]; + const edges = [ + { from_node: 't', to_node: 'prepContract' }, + { from_node: 'prepContract', to_node: 'reviewContract' }, + { from_node: 'reviewContract', from_handle: 'confirm', to_node: 'sendContract' }, + { from_node: 'reviewContract', from_handle: 'deny', to_node: 'cancelContract' }, + { from_node: 'sendContract', to_node: 'gateSigned' }, + { from_node: 'gateSigned', from_handle: 'confirm', to_node: 'prepEvent' }, + { from_node: 'gateSigned', from_handle: 'deny', to_node: 'declined' }, + // Prepare + approve the invoice EARLY (admin can adjust line items now); + // then the wait holds dispatch until the event date, and it sends itself. + { from_node: 'prepEvent', to_node: 'prepInvoice' }, + { from_node: 'prepInvoice', to_node: 'reviewInvoice' }, + { from_node: 'reviewInvoice', from_handle: 'confirm', to_node: 'waitEvent' }, + { from_node: 'reviewInvoice', from_handle: 'deny', to_node: 'cancelInvoice' }, + { from_node: 'waitEvent', to_node: 'sendInvoice' }, + { from_node: 'sendInvoice', to_node: 'done' }, + ]; + return { nodes, edges }; +} + +// Booking — quote accepted → create the event/gallery → wait to the event date +// → prepare invoice → ADMIN REVIEW GATE → send invoice. The no-contract path +// (e.g. small shoots). Same review-before-send rule and stub caveat as the full +// booking flow. +function buildBookingSimpleGraph() { + const nodes = [ + { node_key: 't', type: 'trigger', config: {}, pos_x: 320, pos_y: 0 }, + { node_key: 'prepEvent', type: 'action', config: { action: 'prepare_event' }, pos_x: 320, pos_y: 110 }, + { node_key: 'prepInvoice', type: 'action', config: { action: 'prepare_invoice' }, pos_x: 320, pos_y: 220 }, + { node_key: 'reviewInvoice', type: 'gate', config: { label: 'Review invoice (early — dispatch waits for the event)' }, pos_x: 320, pos_y: 330 }, + { node_key: 'waitEvent', type: 'wait', config: { untilVar: 'eventDate' }, pos_x: 320, pos_y: 440 }, + { node_key: 'sendInvoice', type: 'action', config: { action: 'send_document', document: 'invoice', recipient: 'customer' }, pos_x: 320, pos_y: 550 }, + { node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 320, pos_y: 660 }, + { node_key: 'cancelInvoice', type: 'action', config: { action: 'noop' }, pos_x: 620, pos_y: 330 }, + ]; + const edges = [ + { from_node: 't', to_node: 'prepEvent' }, + // Prepare + approve the invoice early; the wait holds dispatch to the event date. + { from_node: 'prepEvent', to_node: 'prepInvoice' }, + { from_node: 'prepInvoice', to_node: 'reviewInvoice' }, + { from_node: 'reviewInvoice', from_handle: 'confirm', to_node: 'waitEvent' }, + { from_node: 'reviewInvoice', from_handle: 'deny', to_node: 'cancelInvoice' }, + { from_node: 'waitEvent', to_node: 'sendInvoice' }, + { from_node: 'sendInvoice', to_node: 'done' }, + ]; + return { nodes, edges }; +} + +// Booking — quote accepted → prepare invoice → admin review gate → send. No +// event/gallery and no wait: the invoice goes out as soon as the admin approves +// it. For shoots billed without a delivered online gallery. Same stub caveat as +// the other booking flows (prepare_invoice/send_document not yet wired). +function buildBookingInvoiceOnlyGraph() { + const nodes = [ + { node_key: 't', type: 'trigger', config: {}, pos_x: 320, pos_y: 0 }, + { node_key: 'prepInvoice', type: 'action', config: { action: 'prepare_invoice' }, pos_x: 320, pos_y: 110 }, + { node_key: 'reviewInvoice', type: 'gate', config: { label: 'Review invoice before sending' }, pos_x: 320, pos_y: 220 }, + { node_key: 'sendInvoice', type: 'action', config: { action: 'send_document', document: 'invoice', recipient: 'customer' }, pos_x: 320, pos_y: 330 }, + { node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 320, pos_y: 440 }, + { node_key: 'cancelInvoice', type: 'action', config: { action: 'noop' }, pos_x: 620, pos_y: 220 }, + ]; + const edges = [ + { from_node: 't', to_node: 'prepInvoice' }, + { from_node: 'prepInvoice', to_node: 'reviewInvoice' }, + { from_node: 'reviewInvoice', from_handle: 'confirm', to_node: 'sendInvoice' }, + { from_node: 'reviewInvoice', from_handle: 'deny', to_node: 'cancelInvoice' }, + { from_node: 'sendInvoice', to_node: 'done' }, + ]; + return { nodes, edges }; +} + +// Pre-event reminder — fired by the scheduler at event_date − daysBefore (see +// emitDueEventReminders). The notify_pre_event action DELEGATES to +// eventReminderService.sendReminderForEvent, so the email is byte-identical to +// the legacy pass (per-type template, per-event override, sent_at idempotency). +// This is the live replacement for that pass (mutual-exclusion guard there). +function buildPreEventEmailGraph() { + const nodes = [ + { node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 }, + { node_key: 'notify', type: 'action', config: { action: 'notify_pre_event', templateGroup: 'event_reminder' }, pos_x: 240, pos_y: 110 }, + { node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 240, pos_y: 220 }, + ]; + const edges = [ + { from_node: 't', to_node: 'notify' }, + { from_node: 'notify', to_node: 'done' }, + ]; + return { nodes, edges }; +} + +// Gallery expiring — fired by the expiration checker `daysBefore` expiry. The +// notify_gallery_expiring action delegates to the checker's queueExpirationWarning +// so the warning email is identical. Live replacement for the legacy warning +// email (mutual-exclusion guard in the checker). +function buildGalleryExpiringGraph() { + const nodes = [ + { node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 }, + { node_key: 'notify', type: 'action', config: { action: 'notify_gallery_expiring' }, pos_x: 240, pos_y: 110 }, + { node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 240, pos_y: 220 }, + ]; + const edges = [ + { from_node: 't', to_node: 'notify' }, + { from_node: 'notify', to_node: 'done' }, + ]; + return { nodes, edges }; +} + +// Gallery expired — fired when a gallery passes its expiry. The +// notify_gallery_expired action delegates to the checker's sendGalleryExpiredEmails. +// Live replacement for the legacy expired email (mutual-exclusion guard in the checker). +function buildGalleryExpiredGraph() { + const nodes = [ + { node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 }, + { node_key: 'notify', type: 'action', config: { action: 'notify_gallery_expired' }, pos_x: 240, pos_y: 110 }, + { node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 240, pos_y: 220 }, + ]; + const edges = [ + { from_node: 't', to_node: 'notify' }, + { from_node: 'notify', to_node: 'done' }, + ]; + return { nodes, edges }; +} + +// Built-in registry. `version` is the SEED_VERSION — bump when a graph changes +// (or to re-assert the default `enabled` state) so a never-admin-touched copy is +// re-seeded on boot. `enabled` is the seed default. +// +// FIRST-BETA POSTURE (review feedback): all built-ins ship DISABLED. The legacy +// hardcoded paths keep running by default (the mutual-exclusion guards are +// enabled-based, so they only stand down once the admin ENABLES the matching +// built-in — a deliberate, per-install cutover). Enabling reverts to legacy. +// Once the prefetch-safe approval interstitial has soaked in beta, flip the +// three notification built-ins back to enabled-by-default in a follow-up. +// invoice_dunning v6 = ship disabled (was v5 enabled-by-default). +const BUILTINS = [ + { + key: DUNNING_KEY, + version: 6, + enabled: false, + name: 'Invoice dunning (built-in)', + trigger_type: 'invoice.sent', + trigger_config: {}, + description: + 'Drives overdue dunning through the engine: wait to the due date, then up to ' + + 'three payment-check cycles. Each cycle fires the existing admin confirm-payment ' + + 'email (the gate), which applies reminders + Mahngebühr via the proven payment-check ' + + 'flow; after the cycles exhaust it hands the case to collections. DISABLED by default — ' + + 'the hardcoded reminder ladder keeps running until you enable this; enabling cuts over to ' + + 'the engine (the ladder then stands down so the two never double-send), disabling reverts.', + build: async () => { + const firstDays = Number(await getAppSetting('crm_invoices_reminder_first_days')) || 14; + const secondDays = Number(await getAppSetting('crm_invoices_reminder_second_days')) || 30; + const gapDays = Math.max(1, secondDays - firstDays); + return buildDunningGraph({ firstDays, gapDays, maxReminders: 3 }); + }, + }, + { + key: 'gallery_expiring', + version: 2, + enabled: false, + name: 'Gallery expiring (built-in)', + trigger_type: 'gallery.expiring', + trigger_config: {}, + description: + 'When a gallery is approaching its expiry date, email the customer the expiration warning. ' + + 'DISABLED by default; the hourly expiration checker keeps sending the warning until you ' + + 'enable this, which delegates to the identical email and stands the legacy send down. ' + + 'Edit or extend it here (e.g. add a final-download nudge).', + build: async () => buildGalleryExpiringGraph(), + }, + { + key: 'gallery_expired', + version: 2, + enabled: false, + name: 'Gallery expired (built-in)', + trigger_type: 'gallery.expired', + trigger_config: {}, + description: + 'When a gallery passes its expiry, email the customer (and admin) that it has expired. ' + + 'DISABLED by default; the expiration checker keeps sending it until you enable this, which ' + + 'delegates to the identical email and stands the legacy send down. The gallery is still ' + + 'archived automatically regardless of this flow.', + build: async () => buildGalleryExpiredGraph(), + }, + { + key: 'pre_event_email', + version: 4, + enabled: false, + name: 'Pre-event reminder (built-in)', + trigger_type: 'event.date_approaching', + // daysBefore seeds the scheduler emitter from the current global setting so + // enabling preserves timing; per-event offset overrides still win. + trigger_config: async () => { + const d = Number(await getAppSetting('crm_event_reminders_days_before')); + return { daysBefore: Number.isFinite(d) && d >= 0 ? d : 2 }; + }, + description: + 'A few days before the event date, send the customer the pre-event reminder. DISABLED by ' + + 'default; the legacy reminder pass keeps running until you enable this, which delegates to ' + + 'the proven reminder logic (per-type template, per-event override, send-once) and stands ' + + 'the legacy pass down. Lead time = daysBefore in the trigger config (seeded from your old ' + + 'global setting); per-event overrides on the event page still apply.', + build: async () => buildPreEventEmailGraph(), + }, + { + key: 'booking_full', + version: 3, + enabled: false, + name: 'Booking — quote → contract → event → invoice (built-in)', + trigger_type: 'quote.accepted', + trigger_config: {}, + description: + 'On quote acceptance: prepare the contract, let the admin review it (adjust line items / ' + + 'terms) and confirm before it is sent, wait for the admin to confirm it is signed, then ' + + 'create the event/gallery and prepare the invoice EARLY so the admin can adjust it. The ' + + 'admin approves the invoice at the review gate whenever they like; dispatch then waits ' + + 'until the event date and sends itself. No document is sent without an explicit admin OK. ' + + 'Disabled by default — the document actions are stubs until the booking cutover, so an ' + + 'enabled run just records observable skipped steps. A starting point to edit.', + build: async () => buildBookingFullGraph(), + }, + { + key: 'booking_simple', + version: 3, + enabled: false, + name: 'Booking — quote → event → invoice (built-in)', + trigger_type: 'quote.accepted', + trigger_config: {}, + description: + 'The no-contract booking path: on quote acceptance create the event/gallery and prepare the ' + + 'invoice early. The admin approves it at the review gate ahead of time; dispatch then waits ' + + 'until the event date and sends itself. Same review-before-send rule and stub caveat as the ' + + 'full booking flow; disabled by default.', + build: async () => buildBookingSimpleGraph(), + }, + { + key: 'booking_invoice_only', + version: 1, + enabled: false, + name: 'Booking — quote → invoice, no gallery (built-in)', + trigger_type: 'quote.accepted', + trigger_config: {}, + description: + 'For shoots billed without an online gallery: on quote acceptance prepare the invoice, the ' + + 'admin reviews + approves it, and it is sent right away (no event/gallery, no wait). Pick ' + + 'this flow per quote via the booking-workflow selector. Same review-before-send rule and ' + + 'stub caveat as the other booking flows; disabled by default.', + build: async () => buildBookingInvoiceOnlyGraph(), + }, +]; + +let booted = false; + +function parseSeedConfig(raw) { + if (raw == null) return {}; + if (typeof raw === 'object') return raw; + try { return JSON.parse(raw) || {}; } catch (e) { return {}; } +} + +async function writeGraph(trx, workflowId, version, nodes, edges) { + for (const n of nodes) { + await trx('workflow_nodes').insert({ + workflow_id: workflowId, version, node_key: n.node_key, type: n.type, + config: JSON.stringify(n.config || {}), pos_x: n.pos_x || 0, pos_y: n.pos_y || 0, + }); + } + for (const e of edges) { + await trx('workflow_edges').insert({ + workflow_id: workflowId, version, from_node: e.from_node, from_handle: e.from_handle || null, + to_node: e.to_node, label: e.label || null, loop_back: !!e.loop_back, + }); + } +} + +async function seedOneBuiltin(db, logger, def) { + const { nodes, edges } = await def.build(); + const baseCfg = typeof def.trigger_config === 'function' + ? (await def.trigger_config()) || {} + : (def.trigger_config || {}); + const triggerConfig = { ...baseCfg, seedVersion: def.version }; + const defEnabled = def.enabled === true; + + const existing = await db('workflows').where({ builtin_key: def.key }).first(); + + if (existing) { + // Never touch a built-in the admin has taken ownership of (enabled/disabled + // or edited it) — admin_toggled_at is the sentinel (migration 148). For a + // never-touched copy, re-seed on a SEED_VERSION bump and (re-)apply the seed + // default `enabled`, so a shipped default flip (e.g. enabled→disabled for + // first beta) propagates to installs the admin hasn't customised. + const storedVersion = Number(parseSeedConfig(existing.trigger_config).seedVersion) || 0; + const adminOwned = !!existing.admin_toggled_at; + if (adminOwned || storedVersion >= def.version) return; + + const newVersion = (existing.version || 1) + 1; + await db.transaction(async (trx) => { + await trx('workflows').where({ id: existing.id }).update({ + name: def.name, + description: def.description, + trigger_type: def.trigger_type, + trigger_config: JSON.stringify(triggerConfig), + enabled: defEnabled, + version: newVersion, + updated_at: trx.fn.now(), + }); + await writeGraph(trx, existing.id, newVersion, nodes, edges); + }); + logger?.info?.(`Re-seeded built-in workflow: ${def.key} (v${def.version}, enabled=${defEnabled})`); + return; + } + + await db.transaction(async (trx) => { + const ins = await trx('workflows').insert({ + name: def.name, + description: def.description, + enabled: defEnabled, + version: 1, + trigger_type: def.trigger_type, + trigger_config: JSON.stringify(triggerConfig), + is_builtin: true, + builtin_key: def.key, + }).returning('id'); + // Postgres returns [] without `.returning`, so ins[0] would be undefined and + // the child node inserts would roll back on NOT NULL. Normalise the {id} + // (pg) vs bare-id (sqlite) shapes. + const workflowId = ins[0]?.id ?? ins[0]; + await writeGraph(trx, workflowId, 1, nodes, edges); + }); + logger?.info?.(`Seeded built-in workflow: ${def.key} (enabled=${defEnabled})`); +} + +async function seedBuiltinWorkflowsAtBoot(db, logger) { + try { + if (!(await db.schema.hasTable('workflows'))) return; + for (const def of BUILTINS) { + try { + await seedOneBuiltin(db, logger, def); + } catch (err) { + logger?.warn?.(`Built-in workflow seed failed for ${def.key}:`, err.message); + } + } + booted = true; + } catch (err) { + logger?.warn?.('Built-in workflow seed failed at boot:', err.message); + } +} + +module.exports = { seedBuiltinWorkflowsAtBoot, buildDunningGraph, DUNNING_KEY, BUILTINS }; diff --git a/backend/src/services/contractService.js b/backend/src/services/contractService.js index 54af7e41..31a959ea 100644 --- a/backend/src/services/contractService.js +++ b/backend/src/services/contractService.js @@ -87,6 +87,36 @@ function customerPublicActor() { return { type: 'customer', name: 'Customer (public link)' }; } +/** + * Fire a contract lifecycle event for the workflow engine. Best-effort: + * resolves the customer email (so send_email actions have a recipient) and + * never throws into the caller. No-op when the workflows flag is off (emit + * fails closed). Mirrors quoteService.emitQuoteEvent. + */ +async function emitContractEvent(contract, status) { + try { + let customerEmail = null; + if (contract.customer_account_id) { + const c = await db('customer_accounts').where({ id: contract.customer_account_id }).first(); + customerEmail = c?.email || null; + } + await require('./workflows').emitWorkflowEvent(`contract.${status}`, { + entityType: 'contract', + entityId: contract.id, + payload: { + contractId: contract.id, + contractNumber: contract.contract_number, + customerAccountId: contract.customer_account_id || null, + customerEmail, + eventName: contract.event_name || null, + title: contract.title || null, + }, + }); + } catch (err) { + logger.warn('Failed to emit contract workflow event', { contractId: contract.id, status, error: err.message }); + } +} + /** * Privacy gate for the customer/admin IP captured at signing time. * The `crm_contracts_store_ip` setting (default true) controls @@ -1065,6 +1095,8 @@ async function sendContract(id, adminId) { await logActivity('contract_sent', { contractId: id, token }, null, await adminActor(adminId)); } catch (_) { /* logging is best-effort */ } + await emitContractEvent(contract, 'sent'); + logger.info('Contract sent', { adminId, contractId: id }); return { token, pdfPath }; } @@ -1460,6 +1492,10 @@ async function recordAdminCountersignature(contractId, { name, ip, signatureData await logActivity(`contract_${newStatus}`, { contractId: contract.id }, null, await adminActor(adminId)); } catch (_) { /* logging is best-effort */ } + // The binding moment — fire contract.signed once the contract is fully signed + // (matches the editor's trigger). Best-effort / fail-closed. + if (newStatus === 'fully_signed') await emitContractEvent(contract, 'signed'); + return { status: newStatus, signedAt: now }; } @@ -1565,6 +1601,8 @@ async function attachSignedPdfUpload(contractId, filePath, uploaderRole) { uploaderRole === 'admin' ? { type: 'admin', name: 'Admin (PDF upload)' } : customerPublicActor()); } catch (_) { /* logging is best-effort */ } + await emitContractEvent(contract, 'signed'); + return { status: 'fully_signed', signedPdfPath: filePath }; } diff --git a/backend/src/services/crmEmailTemplates.js b/backend/src/services/crmEmailTemplates.js index 2114fdb2..84572e24 100644 --- a/backend/src/services/crmEmailTemplates.js +++ b/backend/src/services/crmEmailTemplates.js @@ -358,6 +358,70 @@ Rechnung {{invoice_number}} für {{customer_name}}{{#if event_name}} ({{event_na Erfasst am: {{paid_at}} Automatische Benachrichtigung — keine Aktion erforderlich.`, +}, + }, + invoice_collections_handoff: { + category: 'billing', feature_flag: 'bills', + variables: ['invoice_number', 'customer_name', 'customer_email', 'customer_address', 'event_name', 'original_amount', 'late_fee_amount', 'paid_amount', 'outstanding_amount', 'due_date', 'reminder_level'], + en: { + subject: 'Collections handoff: invoice {{invoice_number}} still unpaid after dunning', + body_html: `

Ready to hand to collections

+

Invoice {{invoice_number}}{{#if event_name}} ({{event_name}}){{/if}} is still unpaid after {{reminder_level}} reminders. The invoice PDF is attached for forwarding.

+ + + {{#if customer_email}}{{/if}} + {{#if customer_address}}{{/if}} + + + {{#if late_fee_amount}}{{/if}} + + +
Customer{{customer_name}}
Email{{customer_email}}
Address{{customer_address}}
Due date{{due_date}}
Original amount{{original_amount}}
Late fees{{late_fee_amount}}
Paid{{paid_amount}}
Outstanding{{outstanding_amount}}
+

Forward to your collections agency / for Betreibung. Automatic notification.

`, + body_text: `Ready to hand to collections + +Invoice {{invoice_number}}{{#if event_name}} ({{event_name}}){{/if}} is still unpaid after {{reminder_level}} reminders. The invoice PDF is attached. + + Customer: {{customer_name}}{{#if customer_email}} + Email: {{customer_email}}{{/if}}{{#if customer_address}} + Address: {{customer_address}}{{/if}} + Due date: {{due_date}} + Original amount: {{original_amount}}{{#if late_fee_amount}} + Late fees: {{late_fee_amount}}{{/if}} + Paid: {{paid_amount}} + Outstanding: {{outstanding_amount}} + +Forward to your collections agency / for Betreibung.`, +}, + de: { + subject: 'Inkasso-Übergabe: Rechnung {{invoice_number}} trotz Mahnungen offen', + body_html: `

Bereit zur Inkasso-Übergabe

+

Rechnung {{invoice_number}}{{#if event_name}} ({{event_name}}){{/if}} ist nach {{reminder_level}} Mahnungen weiterhin offen. Das Rechnungs-PDF ist zur Weiterleitung angehängt.

+ + + {{#if customer_email}}{{/if}} + {{#if customer_address}}{{/if}} + + + {{#if late_fee_amount}}{{/if}} + + +
Kunde{{customer_name}}
E-Mail{{customer_email}}
Adresse{{customer_address}}
Fälligkeit{{due_date}}
Rechnungsbetrag{{original_amount}}
Mahngebühren{{late_fee_amount}}
Bezahlt{{paid_amount}}
Offen{{outstanding_amount}}
+

Zur Weiterleitung an das Inkasso / für die Betreibung. Automatische Benachrichtigung.

`, + body_text: `Bereit zur Inkasso-Übergabe + +Rechnung {{invoice_number}}{{#if event_name}} ({{event_name}}){{/if}} ist nach {{reminder_level}} Mahnungen weiterhin offen. Das Rechnungs-PDF ist angehängt. + + Kunde: {{customer_name}}{{#if customer_email}} + E-Mail: {{customer_email}}{{/if}}{{#if customer_address}} + Adresse: {{customer_address}}{{/if}} + Fälligkeit: {{due_date}} + Rechnungsbetrag: {{original_amount}}{{#if late_fee_amount}} + Mahngebühren: {{late_fee_amount}}{{/if}} + Bezahlt: {{paid_amount}} + Offen: {{outstanding_amount}} + +Zur Weiterleitung an das Inkasso / für die Betreibung.`, }, }, }; diff --git a/backend/src/services/customerAccountsService.js b/backend/src/services/customerAccountsService.js index 041790e0..8b8061d1 100644 --- a/backend/src/services/customerAccountsService.js +++ b/backend/src/services/customerAccountsService.js @@ -22,6 +22,23 @@ const { ConflictError, NotFoundError, ValidationError } = require('../utils/erro const INVITATION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days, matches admin invites +/** + * Fire customer.created for the workflow engine, from every creation path + * (direct add + invitation accept). Best-effort / fail-closed; never throws + * into the caller. + */ +async function emitCustomerCreated(id, email) { + try { + await require('./workflows').emitWorkflowEvent('customer.created', { + entityType: 'customer', + entityId: id, + payload: { customerAccountId: id, customerEmail: email || null }, + }); + } catch (err) { + logger.warn('Failed to emit customer.created workflow event', { customerId: id, error: err.message }); + } +} + /** * Whitelist of customer profile fields the admin is allowed to pre-fill on * an invitation (and that the customer can then edit on accept). Centralised @@ -252,6 +269,7 @@ async function createDirect({ email, prefill, createdByAdminId }) { ); logger.info('Passive customer created', { id, email: normalisedEmail, createdByAdminId }); + await emitCustomerCreated(id, normalisedEmail); return { id }; } @@ -420,6 +438,7 @@ async function acceptInvitation({ token, name, password, profile }) { ); logger.info('Customer invitation accepted', { customerId, email: invitation.email }); + await emitCustomerCreated(customerId, invitation.email); return { customerId, email: invitation.email }; } diff --git a/backend/src/services/eventReminderService.js b/backend/src/services/eventReminderService.js index 74e4601b..c22c3f62 100644 --- a/backend/src/services/eventReminderService.js +++ b/backend/src/services/eventReminderService.js @@ -61,6 +61,7 @@ const logger = require('../utils/logger'); const { ensureEventReminderTemplatesSeeded } = require('./eventReminderTemplates'); const DEFAULT_DAYS_BEFORE = 2; +const DEFAULT_TEMPLATE_GROUP = 'event_reminder'; const TEMPLATE_KEY_DEFAULT = 'event_reminder_default'; const TEMPLATE_KEY_PREFIX = 'event_reminder_'; @@ -71,31 +72,36 @@ const TEMPLATE_KEY_PREFIX = 'event_reminder_'; let schemaWarnLogged = false; /** - * Lookup the most specific available template for an event_type slug. - * Returns the template_key string. The email_processor handles missing - * template rows by failing the send; we don't fetch the row body here - * because emailProcessor.queueEmail does that lookup itself. + * Resolve the reminder template within a GROUP (template-key prefix). The group + * is chosen on the flow block (defaults to `event_reminder`); within it the pick + * is automatic and per-event-type: + * `_` if a template exists → else `_default` + * So an exact wedding/birthday/… template wins; otherwise the group's catch-all. + * emailProcessor handles a missing template row itself, so we only return a key. */ -async function resolveTemplateKey(eventType) { +async function resolveTemplateKey(eventType, group = DEFAULT_TEMPLATE_GROUP) { + const g = String(group || DEFAULT_TEMPLATE_GROUP).replace(/_+$/, ''); // tolerate a trailing "_" if (eventType) { - const perType = `${TEMPLATE_KEY_PREFIX}${eventType}`; + const perType = `${g}_${eventType}`; const exists = await db('email_templates') .where({ template_key: perType }) .first('id'); if (exists) return perType; } - return TEMPLATE_KEY_DEFAULT; + return `${g}_default`; } /** * Build the variables payload the template engine substitutes. Keep * the keys in sync with the seeded template's `variables` JSON. */ -function composePayload({ event, customer, daysBefore, businessName }) { - const customerName = customer.company_name - || [customer.first_name, customer.last_name].filter(Boolean).join(' ') - || customer.display_name - || customer.email +function composePayload({ event, recipientEmail, daysBefore, businessName }) { + // Recipient identity comes from the EVENT row (events.customer_name / + // host_name), not a customer_accounts join — events store the recipient + // inline (customer_email / host_email), there is no events.customer_account_id. + const customerName = event.customer_name + || event.host_name + || recipientEmail || ''; // Event date formatted DD.MM.YYYY here for simplicity; the rendered // email may further re-locale via the template engine when locale- @@ -128,6 +134,17 @@ async function runEventReminderPass() { return { scanned: 0, sent: 0, skipped: 0, disabled: true }; } + // Mutual exclusion with the workflow engine: the legacy pass stands down only + // when the pre_event_email built-in is ENABLED (then the engine sends via the + // notify_pre_event action). If the flow is disabled, this legacy pass keeps + // running — so the built-ins can ship disabled without going dark, and + // disabling a built-in cleanly reverts to the legacy path. Fails closed. + try { + if (await require('./workflows').isBuiltinFlowActive('pre_event_email')) { + return { scanned: 0, sent: 0, skipped: 0, byWorkflow: true }; + } + } catch (_) { /* workflow subsystem down → keep the legacy pass running */ } + // Column-existence guards — pre-migration installs return early // instead of throwing. const hasCols = await hasColumnCached('events', 'event_reminder_sent_at'); @@ -156,40 +173,29 @@ async function runEventReminderPass() { const profile = await db('business_profile').where({ id: 1 }).first('company_name'); const businessName = profile?.company_name || ''; - // Candidate set: events with a customer, event_date in the future, - // not yet sent, not disabled per-event. We don't filter on - // event_date - days_before <= NOW() in SQL because per-event - // override `event_reminder_offset_days` may shift the trigger - // window — easier to filter in JS. + // Candidate set: active events with a date in the future, not yet sent, not + // disabled per-event. Recipient comes from the event row itself (customer_email + // / host_email) — events have no customer_account_id. `events.*` so the + // customer_email column (newer; absent on very old installs) is read safely. const now = new Date(); const rows = await db('events') - .leftJoin('customer_accounts', 'customer_accounts.id', 'events.customer_account_id') - .whereNotNull('events.customer_account_id') .whereNotNull('events.event_date') .where('events.is_active', true) .where('events.is_archived', false) .where('events.event_reminder_disabled', false) .whereNull('events.event_reminder_sent_at') .where('events.event_date', '>=', now.toISOString().slice(0, 10)) - .select( - 'events.id', 'events.event_name', 'events.event_type', 'events.event_date', - 'events.event_reminder_offset_days', - 'events.event_reminder_body_override', - 'events.customer_account_id', - 'customer_accounts.email as customer_email', - 'customer_accounts.first_name as customer_first_name', - 'customer_accounts.last_name as customer_last_name', - 'customer_accounts.display_name as customer_display_name', - 'customer_accounts.company_name as customer_company_name', - ); + .select('events.*'); let sent = 0; let skipped = 0; for (const row of rows) { try { - if (!row.customer_email) { skipped += 1; continue; } - const offsetDays = Number.isFinite(Number(row.event_reminder_offset_days)) - ? Number(row.event_reminder_offset_days) + const recipientEmail = row.customer_email || row.host_email; + if (!recipientEmail) { skipped += 1; continue; } + const rawOffset = row.event_reminder_offset_days; + const offsetDays = (rawOffset != null && rawOffset !== '' && Number.isFinite(Number(rawOffset))) + ? Number(rawOffset) : daysBeforeDefault; // Trigger window: NOW >= event_date - offset_days. const ed = row.event_date instanceof Date ? row.event_date : new Date(row.event_date); @@ -197,15 +203,8 @@ async function runEventReminderPass() { if (now < triggerAt) { skipped += 1; continue; } const templateKey = await resolveTemplateKey(row.event_type); - const customer = { - email: row.customer_email, - first_name: row.customer_first_name, - last_name: row.customer_last_name, - display_name: row.customer_display_name, - company_name: row.customer_company_name, - }; const payload = composePayload({ - event: row, customer, daysBefore: offsetDays, businessName, + event: row, recipientEmail, daysBefore: offsetDays, businessName, }); // Per-event body override: when present, append as a synthetic // `body_override` field. The template engine should branch on it @@ -217,7 +216,7 @@ async function runEventReminderPass() { payload.body_override = row.event_reminder_body_override; } - await emailProcessor.queueEmail(row.id, customer.email, templateKey, payload); + await emailProcessor.queueEmail(row.id, recipientEmail, templateKey, payload); // Stamp sent_at immediately so a same-pass-re-entrancy (or a // crash between queueEmail and the update) doesn't double-send @@ -254,8 +253,63 @@ async function runEventReminderPass() { return { scanned: rows.length, sent, skipped }; } +/** + * Send the pre-event reminder for ONE event — the per-event body of + * runEventReminderPass, reused by the workflow `notify_pre_event` action so the + * engine path is byte-identical to the legacy pass (same template resolution, + * per-event body override, recipient rule and `event_reminder_sent_at` idempotency). + * + * Returns { sent, skipped, reason? }. Never throws on a business skip (no email, + * disabled, already sent, no template-eligible recipient); only DB/queue errors + * propagate so the caller can surface them. + */ +async function sendReminderForEvent(eventId, { templateGroup = null } = {}) { + const hasCols = await hasColumnCached('events', 'event_reminder_sent_at'); + if (!hasCols) return { sent: 0, skipped: 1, reason: 'schema_not_migrated' }; + + // Self-heal templates (idempotent, process-cached) — same as the pass. + try { await ensureEventReminderTemplatesSeeded(db, logger); } catch (err) { + logger.error('Event reminder template self-heal failed', { message: err.message }); + } + + // Recipient comes from the event row (customer_email / host_email) — events + // have no customer_account_id. `events.*` reads customer_email safely even on + // installs predating that column. + const row = await db('events').where('id', eventId).select('events.*').first(); + + if (!row) return { sent: 0, skipped: 1, reason: 'not_found' }; + if (row.event_reminder_disabled) return { sent: 0, skipped: 1, reason: 'disabled' }; + if (row.event_reminder_sent_at) return { sent: 0, skipped: 1, reason: 'already_sent' }; + if (row.is_active === false || row.is_active === 0 || row.is_archived === true || row.is_archived === 1) { + return { sent: 0, skipped: 1, reason: 'inactive' }; + } + const recipientEmail = row.customer_email || row.host_email; + if (!recipientEmail) return { sent: 0, skipped: 1, reason: 'no_recipient' }; + + const globalDaysBefore = Number(await getAppSetting('crm_event_reminders_days_before')); + const daysBeforeDefault = Number.isFinite(globalDaysBefore) && globalDaysBefore >= 0 + ? globalDaysBefore : DEFAULT_DAYS_BEFORE; + const rawOffset = row.event_reminder_offset_days; + const offsetDays = (rawOffset != null && rawOffset !== '' && Number.isFinite(Number(rawOffset))) + ? Number(rawOffset) : daysBeforeDefault; + + const profile = await db('business_profile').where({ id: 1 }).first('company_name'); + const businessName = profile?.company_name || ''; + + // The flow block chooses the template GROUP (blank → the default group); the + // exact template is still auto-picked by event type within that group. + const templateKey = await resolveTemplateKey(row.event_type, templateGroup || DEFAULT_TEMPLATE_GROUP); + const payload = composePayload({ event: row, recipientEmail, daysBefore: offsetDays, businessName }); + if (row.event_reminder_body_override) payload.body_override = row.event_reminder_body_override; + + await emailProcessor.queueEmail(row.id, recipientEmail, templateKey, payload); + await db('events').where({ id: row.id }).update({ event_reminder_sent_at: new Date() }); + return { sent: 1, skipped: 0, offsetDays }; +} + module.exports = { runEventReminderPass, + sendReminderForEvent, // exported for tests _internal: { resolveTemplateKey, diff --git a/backend/src/services/eventService.js b/backend/src/services/eventService.js index 3f7faea7..bb732247 100644 --- a/backend/src/services/eventService.js +++ b/backend/src/services/eventService.js @@ -10,6 +10,7 @@ const crypto = require('crypto'); const path = require('path'); const fs = require('fs').promises; const { db } = require('../database/db'); +const logger = require('../utils/logger'); const { formatBoolean } = require('../utils/dbCompat'); const { hasColumnCached } = require('../utils/schemaCache'); const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation'); @@ -283,6 +284,28 @@ const createEvent = async (eventData) => { const insertResult = await db('events').insert(insertData).returning('id'); const eventId = insertResult[0]?.id || insertResult[0]; + // Fire gallery.published — a gallery goes live the moment it's created (active + // + share link). Best-effort; emit is fail-closed when the workflows flag is + // off and never throws into the create path. + try { + await require('./workflows').emitWorkflowEvent('gallery.published', { + entityType: 'event', + entityId: eventId, + payload: { + eventId, + slug, + eventName: event_name, + eventDate: event_date, + customerEmail: customer_email || null, + adminEmail: admin_email || null, + galleryLink: shareUrl, + expiresAt: expires_at, + }, + }); + } catch (err) { + logger.warn('Failed to emit gallery.published workflow event', { eventId, error: err.message }); + } + return { id: eventId, slug, diff --git a/backend/src/services/eventTypeService.js b/backend/src/services/eventTypeService.js index 1dbd8308..fb6d701f 100644 --- a/backend/src/services/eventTypeService.js +++ b/backend/src/services/eventTypeService.js @@ -7,6 +7,8 @@ const { db } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); +const { hasColumnCached } = require('../utils/schemaCache'); +const logger = require('../utils/logger'); /** * Get all event types @@ -201,7 +203,47 @@ const updateEventType = async (id, updates) => { updateData.updated_at = new Date(); - await db('event_types').where('id', id).update(updateData); + // A slug_prefix rename must CASCADE, or it silently orphans everything keyed on + // the old slug: existing events/quotes (their event_type), and the per-type + // pre-event reminder template (event_reminder_). Re-point them so a + // rename behaves like a rename, not a detach. Atomic. + const oldSlug = eventType.slug_prefix; + const newSlug = updateData.slug_prefix; + const slugChanged = newSlug !== undefined && newSlug !== oldSlug; + + if (!slugChanged) { + await db('event_types').where('id', id).update(updateData); + return getEventTypeById(id); + } + + // Resolve schema lookups BEFORE opening the transaction — hasColumnCached + // reads via the global db, and a global read inside a SQLite transaction + // (single connection) deadlocks. + const quotesHasEventType = await hasColumnCached('quotes', 'event_type'); + + await db.transaction(async (trx) => { + await trx('event_types').where('id', id).update(updateData); + // Re-point existing documents from the old slug to the new one. + const evCount = await trx('events').where('event_type', oldSlug).update({ event_type: newSlug }); + let qCount = 0; + if (quotesHasEventType) { + qCount = await trx('quotes').where('event_type', oldSlug).update({ event_type: newSlug }); + } + // Carry the authored per-type reminder template along (subject/body follow the + // rename). Guard: never clobber an existing target template for the new slug. + const oldKey = `event_reminder_${oldSlug}`; + const newKey = `event_reminder_${newSlug}`; + let tplMoved = false; + const src = await trx('email_templates').where({ template_key: oldKey }).first('id'); + const dst = await trx('email_templates').where({ template_key: newKey }).first('id'); + if (src && !dst) { + await trx('email_templates').where({ template_key: oldKey }).update({ template_key: newKey }); + tplMoved = true; + } + logger.info('Event type slug renamed — cascaded references', { + id, oldSlug, newSlug, events: evCount, quotes: qCount, reminderTemplateMoved: tplMoved, + }); + }); return getEventTypeById(id); }; diff --git a/backend/src/services/expirationChecker.js b/backend/src/services/expirationChecker.js index 1666e1d3..d9876383 100644 --- a/backend/src/services/expirationChecker.js +++ b/backend/src/services/expirationChecker.js @@ -11,7 +11,7 @@ function startExpirationChecker() { cron.schedule('0 * * * *', async () => { await checkExpirations(); }); - + logger.info('Expiration checker started'); } @@ -19,7 +19,22 @@ async function checkExpirations() { try { const now = new Date(); const warningDate = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days from now - + + // Mutual exclusion with the workflow engine: when the matching built-in flow + // is enabled, the engine sends the email (via notify_gallery_* actions). We + // still EMIT the trigger every pass (for the built-in AND any custom flows), + // but skip the LEGACY email so the two never double-send. State transitions + // (is_active=false, archive) always run regardless — they're the expiry + // mechanic, not the notification. + // Enabled-based mutual exclusion: the legacy email stands down only when the + // matching built-in is ENABLED (then its action sends the identical mail). A + // disabled built-in leaves the legacy send running — so the flows can ship + // disabled without galleries going un-notified, and disabling a flow reverts + // to legacy. The trigger is still emitted regardless (for any custom flows). + const { isBuiltinFlowActive } = require('./workflows'); + const warningFlowOwns = await isBuiltinFlowActive('gallery_expiring'); + const expiredFlowOwns = await isBuiltinFlowActive('gallery_expired'); + // Check for events needing warning emails // Skip events with null expires_at (they never expire) const eventsNeedingWarning = await db('events') @@ -28,19 +43,14 @@ async function checkExpirations() { .whereNotNull('expires_at') .where('expires_at', '<=', warningDate) .where('expires_at', '>', now); - + for (const event of eventsNeedingWarning) { - // Check if warning email already sent - const existingWarning = await db('email_queue') - .where('event_id', event.id) - .where('email_type', 'expiration_warning') - .first(); - - if (!existingWarning) { - await queueExpirationWarning(event); + await emitGalleryExpiring(event); // always — for the built-in + any custom flows + if (!warningFlowOwns) { + await queueExpirationWarning(event); // legacy email (self-dedupes) } } - + // Check for expired events // Skip events with null expires_at (they never expire) const expiredEvents = await db('events') @@ -48,17 +58,57 @@ async function checkExpirations() { .where('is_archived', formatBoolean(false)) .whereNotNull('expires_at') .where('expires_at', '<=', now); - + for (const event of expiredEvents) { - await handleExpiredEvent(event); + await handleExpiredEvent(event, { sendLegacyEmails: !expiredFlowOwns }); } - + } catch (error) { logger.error('Error checking expirations:', error); } } +/** + * Emit gallery.expiring for the workflow engine. Best-effort / fail-closed; + * deduped per (workflow, event) by emitWorkflowEvent so the hourly sweep fires + * a flow at most once per gallery. + */ +async function emitGalleryExpiring(event) { + try { + const daysRemaining = Math.ceil((new Date(event.expires_at) - new Date()) / (1000 * 60 * 60 * 24)); + const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token }); + await require('./workflows').emitWorkflowEvent('gallery.expiring', { + entityType: 'event', + entityId: event.id, + payload: { + eventId: event.id, + slug: event.slug, + eventName: event.event_name, + eventDate: event.event_date, + expiresAt: event.expires_at, + daysRemaining, + customerEmail: event.customer_email || event.host_email || null, + adminEmail: event.admin_email || null, + galleryLink: shareUrl, + }, + }); + } catch (err) { + logger.warn('Failed to emit gallery.expiring workflow event', { eventId: event.id, error: err.message }); + } +} + +/** + * Queue the customer expiration-warning email. Self-dedupes on the + * (event_id, 'expiration_warning') email_queue row so both the legacy hourly + * loop and the workflow `notify_gallery_expiring` action are safe to call it. + */ async function queueExpirationWarning(event) { + const existingWarning = await db('email_queue') + .where('event_id', event.id) + .where('email_type', 'expiration_warning') + .first(); + if (existingWarning) return; + const daysRemaining = Math.ceil((new Date(event.expires_at) - new Date()) / (1000 * 60 * 60 * 24)); const recipientEmail = event.customer_email || event.host_email; @@ -91,7 +141,49 @@ async function queueExpirationWarning(event) { logger.info(`Queued expiration warning for event ${event.slug}`); } -async function handleExpiredEvent(event) { +/** + * Queue the gallery_expired emails (customer + optional admin). Self-dedupes on + * the (event_id, 'gallery_expired') email_queue row, so both the legacy expiry + * handler and the workflow `notify_gallery_expired` action are safe to call it. + */ +async function sendGalleryExpiredEmails(event) { + const existing = await db('email_queue') + .where('event_id', event.id) + .where('email_type', 'gallery_expired') + .first(); + if (existing) return; + + // The shipped templates (EN/DE in legacy 028, NL/PT/RU in core 075) reference + // {{host_name}}, {{event_date}}, {{expiry_date}} and {{support_email}} — fill + // them all here. + const recipientEmail = event.customer_email || event.host_email; + const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null); + const supportEmail = await getSupportEmail(); + + const customerVars = { + customer_name: recipientName, + customer_email: recipientEmail, + host_name: recipientName, + event_name: event.event_name, + event_date: event.event_date, + expiry_date: event.expires_at, + admin_email: event.admin_email, + support_email: supportEmail + }; + + if (recipientEmail) { + await queueEmail(event.id, recipientEmail, 'gallery_expired', customerVars); + } + // Also notify admin (when configured). + if (event.admin_email && event.admin_email !== recipientEmail) { + await queueEmail(event.id, event.admin_email, 'gallery_expired', { + ...customerVars, + host_name: 'Admin' + }); + } +} + +async function handleExpiredEvent(event, { sendLegacyEmails = true } = {}) { try { // Mark as inactive await db('events').where('id', event.id).update({ is_active: formatBoolean(false) }); @@ -120,45 +212,45 @@ async function handleExpiredEvent(event) { }); } catch (e) { /* non-fatal */ } - // Queue expiration emails. The shipped templates (EN/DE in legacy 028, - // NL/PT/RU in core 075) reference {{host_name}}, {{event_date}}, - // {{expiry_date}} and {{support_email}}. Without these, customers used - // to literally see "Hello {{host_name}}, your gallery expired on - // {{expiry_date}}…" — fill them all here. - const recipientEmail = event.customer_email || event.host_email; - const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null); - const supportEmail = await getSupportEmail(); - - const customerVars = { - customer_name: recipientName, - customer_email: recipientEmail, - host_name: recipientName, - event_name: event.event_name, - event_date: event.event_date, - expiry_date: event.expires_at, - admin_email: event.admin_email, - support_email: supportEmail - }; - - if (recipientEmail) { - await queueEmail(event.id, recipientEmail, 'gallery_expired', customerVars); - } - - // Also notify admin (when configured). - if (event.admin_email && event.admin_email !== recipientEmail) { - await queueEmail(event.id, event.admin_email, 'gallery_expired', { - ...customerVars, - host_name: 'Admin' + // Emit gallery.expired for the workflow engine (sibling to the event.expired + // webhook). Always emitted; deduped per (workflow, event). + try { + await require('./workflows').emitWorkflowEvent('gallery.expired', { + entityType: 'event', + entityId: event.id, + payload: { + eventId: event.id, + slug: event.slug, + eventName: event.event_name, + eventDate: event.event_date, + expiresAt: event.expires_at, + customerEmail: event.customer_email || event.host_email || null, + adminEmail: event.admin_email || null, + }, }); + } catch (err) { + logger.warn('Failed to emit gallery.expired workflow event', { eventId: event.id, error: err.message }); } - - // Start archiving process + + // Legacy notification — skipped when the gallery_expired built-in flow drives + // it (the flow's notify_gallery_expired action sends the same emails). + if (sendLegacyEmails) { + await sendGalleryExpiredEmails(event); + } + + // Start archiving process (always — the expiry mechanic, not the email). await archiveEvent(event); - + logger.info(`Handled expiration for event ${event.slug}`); } catch (error) { logger.error(`Error handling expired event ${event.slug}:`, error); } } -module.exports = { startExpirationChecker }; +module.exports = { + startExpirationChecker, + // Reused by the workflow notify_gallery_* actions so the engine path sends the + // exact same emails as the legacy hourly checker. + queueExpirationWarning, + sendGalleryExpiredEmails, +}; diff --git a/backend/src/services/invoiceSchedulerService.js b/backend/src/services/invoiceSchedulerService.js index 181ab99e..a6ec2644 100644 --- a/backend/src/services/invoiceSchedulerService.js +++ b/backend/src/services/invoiceSchedulerService.js @@ -42,6 +42,23 @@ async function runTick() { } catch (err) { logger.error('Event reminder pass failed', { err: err.message }); } + try { + // Resume workflow runs whose wait has elapsed. No-op (fails closed) when + // the `workflows` feature flag is off. Independent try/catch so a workflow + // failure never suppresses the invoice/reminder jobs above. + const wf = require('./workflows'); + const resumed = await wf.runDueWaits(); + if (resumed) logger.info('Workflow scheduler: resumed waiting runs', { resumed }); + // Fire pre-event reminders for events entering an enabled flow's lead window. + const preEvent = await wf.emitDueEventReminders(); + if (preEvent) logger.info('Workflow scheduler: emitted pre-event reminders', { preEvent }); + // Recover runs orphaned by a crash (stuck in running/pending). Runs on the + // boot tick too, so a restart catches anything stranded during downtime. + const recovered = await wf.recoverStaleRuns(); + if (recovered) logger.warn('Workflow scheduler: recovered orphaned runs', { recovered }); + } catch (err) { + logger.error('Workflow resume pass failed', { err: err.message }); + } } function startInvoiceScheduler() { diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js index b25400bd..497ce2a6 100644 --- a/backend/src/services/invoiceService.js +++ b/backend/src/services/invoiceService.js @@ -1785,11 +1785,10 @@ async function buildInvoiceRenderContext(invoice, lineItems) { vatAmountMinor: invoice.vat_amount_minor, shippingAmountMinor: invoice.shipping_amount_minor, totalAmountMinor: invoice.total_amount_minor, - // Mahngebühr surfaced to the totals box (renders a row - // between VAT and the grand-total divider) and folded - // into the displayed Grand Total when > 0. Reminder - // invoices after level 2 carry a non-zero value. - lateFeeAmountMinor: invoice.late_fee_amount_minor || 0, + // The Mahngebühr is shown on the separate Mahnung document, NEVER on + // the (immutable) invoice — so the invoice render always reports 0. The + // Mahnung render path (applyReminder) overrides this with the tracked fee. + lateFeeAmountMinor: 0, }, doc: { // Document type discriminator. `'invoice'` (default) renders @@ -1803,7 +1802,7 @@ async function buildInvoiceRenderContext(invoice, lineItems) { issueDate: invoice.issue_date, dueDate: invoice.due_date, totalAmountMinor: invoice.total_amount_minor, - lateFeeMinor: invoice.late_fee_amount_minor, + lateFeeMinor: 0, // Reminder level — drives Skonto suppression on second // reminders (no early-payment discount once the customer // is in dunning). @@ -2039,6 +2038,28 @@ async function sendInvoice(id, adminId) { }); try { await logActivity('invoice_sent', { invoiceId: id }, invoice.event_id || null, `admin:${adminId}`); } catch (_) {} + + // Fire the workflow engine's invoice.sent trigger (after the row is updated + + // the email queued). Idempotent per invoice id; no-op when the workflows flag + // is off. Never throws into the send path. + try { + await require('./workflows').emitWorkflowEvent('invoice.sent', { + entityType: 'invoice', + entityId: id, + payload: { + invoiceId: id, + invoiceNumber: invoice.invoice_number, + eventId: invoice.event_id || null, + customerAccountId: invoice.customer_account_id, + customerEmail: invoiceTo, + dueDate: invoice.due_date, + issueDate: invoice.issue_date, + totalMinor: invoice.total_amount_minor, + currency: invoice.currency, + }, + }); + } catch (_) {} + return { sent: true, pdfPath }; } @@ -2068,7 +2089,7 @@ async function markPaid(id, { amountMinor, paidAt, paymentMethod, reference, not ? Math.max(0, ensureInt(invoice.total_amount_minor) - amount) : null; - return await db.transaction(async (trx) => { + const markResult = await db.transaction(async (trx) => { await trx('invoice_payment_log').insert({ invoice_id: id, amount_minor: amount, @@ -2145,6 +2166,26 @@ async function markPaid(id, { amountMinor, paidAt, paymentMethod, reference, not return { paidTotalMinor: total, status: isFull ? 'paid' : invoice.status }; }); + + // Fire invoice.paid for the workflow engine ONLY on the transition into + // 'paid' (mirrors the admin-notification guard above). After the commit so a + // workflow side effect can never roll back the recorded payment. + if (markResult.status === 'paid' && invoice.status !== 'paid') { + try { + await require('./workflows').emitWorkflowEvent('invoice.paid', { + entityType: 'invoice', + entityId: id, + payload: { + invoiceId: id, + invoiceNumber: invoice.invoice_number, + eventId: invoice.event_id || null, + customerAccountId: invoice.customer_account_id, + paidTotalMinor: markResult.paidTotalMinor, + }, + }); + } catch (_) {} + } + return markResult; } /** @@ -2600,94 +2641,158 @@ async function sendReminder(id, levelOverride, adminId) { throw new AppError(`Cannot remind on status '${invoice.status}'`, 409); } const newLevel = levelOverride || (invoice.reminder_level + 1); - if (newLevel > 2) { + if (newLevel > 3) { throw new AppError('Reminder level exhausted', 409); } return await applyReminder(invoice, lineItems, newLevel, adminId); } +// Per-reminder Mahngebühr in minor units (0 when disabled). Flat amount OR a +// percentage of the invoice gross, per crm_invoices_late_fee_type. Charged from +// the 2nd reminder onwards. ⚠️ A late fee is only enforceable if the concrete +// amount is stated in the AGB — verify with a Treuhänder (the admin UI says so). +// Net per-reminder Mahngebühr (flat amount or % of invoice gross), 0 disabled. +async function resolveLateFeeNetMinor(invoice) { + if ((await getAppSetting('crm_invoices_late_fee_enabled')) === false) return 0; + const type = (await getAppSetting('crm_invoices_late_fee_type')) || 'flat'; + let fee; + if (type === 'percent') { + const pct = Number(await getAppSetting('crm_invoices_late_fee_percent')) || 0; + fee = Math.round(Number(invoice.total_amount_minor || 0) * pct / 100); + } else { + fee = ensureInt(await getAppSetting('crm_invoices_late_fee_minor')) || 2500; + } + return Math.max(0, fee); +} + +// VAT rate on the fee — jurisdiction-dependent (CH: yes; DE/AT: no), so +// toggle-gated AND org-VAT-gated: 0 when the org has no default VAT rate, so +// enabling the toggle on a non-VAT org adds nothing. +async function resolveLateFeeVatRate() { + if ((await getAppSetting('crm_invoices_late_fee_vat_enabled')) !== true) return 0; + const profile = await db('business_profile').where({ id: 1 }).first('vat_rate_default'); + return Number(profile?.vat_rate_default) || 0; +} + +// Gross per-reminder fee (net + VAT) — for the admin payment-check preview. +async function resolvePerReminderFeeMinor(invoice) { + const net = await resolveLateFeeNetMinor(invoice); + if (net <= 0) return 0; + const rate = await resolveLateFeeVatRate(); + return rate > 0 ? net + Math.round(net * rate / 100) : net; +} + async function applyReminder(invoice, lineItems, level, adminId) { const customer = await db('customer_accounts').where({ id: invoice.customer_account_id }).first(); - let lateFeeMinor = invoice.late_fee_amount_minor || 0; - if (level === 2) { - const enabled = await getAppSetting('crm_invoices_late_fee_enabled'); - if (enabled !== false) { - const fee = ensureInt(await getAppSetting('crm_invoices_late_fee_minor')) || 2500; - lateFeeMinor = fee; - } - } - const newTotal = invoice.total_amount_minor + lateFeeMinor; - await db('invoices').where({ id: invoice.id }).update({ + // Per fee-bearing reminder (levels 2..level): 2nd = 1×, 3rd = 2×, computed + // from `level` so re-applying the same level never stacks. The fee is dunning + // STATE on the row (gross + the VAT portion) — it is NOT shown on the + // immutable invoice; it appears on the separate Mahnung document below. + let lateFeeGross = invoice.late_fee_amount_minor || 0; + let lateFeeVat = invoice.late_fee_vat_minor || 0; + if (level >= 2) { + const net = await resolveLateFeeNetMinor(invoice); + const rate = await resolveLateFeeVatRate(); + const vatPer = rate > 0 ? Math.round(net * rate / 100) : 0; + lateFeeGross = (level - 1) * (net + vatPer); + lateFeeVat = (level - 1) * vatPer; + } + const newTotal = Number(invoice.total_amount_minor || 0) + lateFeeGross; + + const update = { status: 'overdue', reminder_level: level, last_reminder_sent_at: new Date(), - late_fee_amount_minor: lateFeeMinor, + late_fee_amount_minor: lateFeeGross, updated_at: new Date(), - }); + }; + if (await hasColumnCached('invoices', 'late_fee_vat_minor')) update.late_fee_vat_minor = lateFeeVat; + await db('invoices').where({ id: invoice.id }).update(update); - // Re-render PDF so the late fee shows up. + // Fire invoice.overdue at the status→overdue flip. Deduped per (workflow, + // invoice), so across the reminder ladder it triggers a flow at most once. + // Best-effort / fail-closed. + try { + await require('./workflows').emitWorkflowEvent('invoice.overdue', { + entityType: 'invoice', + entityId: invoice.id, + payload: { + invoiceId: invoice.id, + invoiceNumber: invoice.invoice_number, + eventId: invoice.event_id || null, + customerAccountId: invoice.customer_account_id, + customerEmail: customer?.email || null, + dueDate: invoice.due_date, + reminderLevel: level, + totalMinor: invoice.total_amount_minor, + currency: invoice.currency, + }, + }); + } catch (_) {} + + // Render the MAHNUNG (reminder letter). The original invoice PDF is left + // UNTOUCHED (immutable). The Mahnung reuses the invoice layout via a + // 'mahnung' kind: same line items + the Mahngebühr row + the new total, with + // a "Mahnung" title and no QR (it would encode the old amount). const fresh = await db('invoices').where({ id: invoice.id }).first(); const ctx = await buildInvoiceRenderContext(fresh, lineItems); + ctx.doc.kind = 'mahnung'; + ctx.doc.reminderLevel = level; + ctx.doc.lateFeeMinor = lateFeeGross; + ctx.totals.lateFeeAmountMinor = lateFeeGross; const buffer = await pdfService.renderInvoiceToBuffer(ctx); const fs = require('fs'); const path = require('path'); const year = new Date(fresh.issue_date).getFullYear(); - const root = path.join(process.cwd(), 'storage', 'business-docs', 'invoice', String(year)); + const root = path.join(process.cwd(), 'storage', 'business-docs', 'mahnung', String(year)); fs.mkdirSync(root, { recursive: true }); - const pdfPath = path.join(root, `${fresh.invoice_number}.pdf`); - fs.writeFileSync(pdfPath, buffer); + const mahnungPath = path.join(root, `${fresh.invoice_number}_mahnung_L${level}.pdf`); + fs.writeFileSync(mahnungPath, buffer); - await db('invoices').where({ id: invoice.id }).update({ pdf_path: pdfPath, updated_at: new Date() }); - - // days_overdue floors at 1 — a reminder that fires with "0 days - // overdue" reads as broken to the customer ("Why am I getting this - // already?"). The scheduler only triggers the row once - // due_date <= now - reminder_first_days, so the natural minimum is - // the configured threshold; for the manual "Send reminder now" - // path the admin's intent is "this customer is late", so 1 is the - // sensible lower bound even if the calendar arithmetic disagrees. + // days_overdue floors at 1 (a "0 days overdue" reminder reads as broken). const rawDaysOverdue = Math.floor((Date.now() - new Date(invoice.due_date).getTime()) / 86400000); const daysOverdue = Math.max(1, rawDaysOverdue); const templateKey = level === 1 ? 'invoice_reminder_first' : 'invoice_reminder_second'; + const locale = ctx.locale || invoice.language || 'de'; + const outstandingMinor = Math.max(0, newTotal - Number(invoice.paid_amount_minor || 0)); - // Outstanding = gross total + late fee − already paid. Reminder - // templates use this for the "outstanding is X" line so partial - // payments are reflected in the reminder amount. - const outstandingMinor = Math.max(0, - Number(invoice.total_amount_minor || 0) - + Number(lateFeeMinor || 0) - - Number(invoice.paid_amount_minor || 0)); + // Attach the (unchanged) original invoice PDF + the new Mahnung. + const attachments = []; + if (invoice.pdf_path && fs.existsSync(invoice.pdf_path)) { + attachments.push({ filename: `${invoice.invoice_number}.pdf`, contentPath: invoice.pdf_path, contentType: 'application/pdf' }); + } + attachments.push({ filename: `${fresh.invoice_number}_Mahnung.pdf`, contentPath: mahnungPath, contentType: 'application/pdf' }); const { to: reminderTo, cc: reminderCc } = resolveBillingRecipients(customer, invoice.cc_pdf_email); - await emailProcessor.queueEmail(invoice.event_id || null, reminderTo, templateKey, { - invoice_number: invoice.invoice_number, - customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0], - total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, ctx.locale), - new_total_amount: formatMajor(newTotal, invoice.currency, ctx.locale), - outstanding_amount: formatMajor(outstandingMinor, invoice.currency, ctx.locale), - paid_amount: formatMajor(invoice.paid_amount_minor, invoice.currency, ctx.locale), - late_fee_amount: formatMajor(lateFeeMinor, invoice.currency, ctx.locale), - // Format dates as DD.MM.YYYY for the customer-facing email - // (matches the quote_sent + invoice_sent templates). - due_date: formatShortDate(invoice.due_date), - days_overdue: daysOverdue, - cc: reminderCc, - attachments: [{ - filename: `${invoice.invoice_number}.pdf`, - contentPath: pdfPath, - contentType: 'application/pdf', - }], - // Dunning reminders are relationship mail — hold to business hours so - // the customer isn't pinged overnight (no-op unless hours configured). - }, { respectBusinessHours: true }); + try { + await emailProcessor.queueEmail(invoice.event_id || null, reminderTo, templateKey, { + invoice_number: invoice.invoice_number, + customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0], + total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, locale), + new_total_amount: formatMajor(newTotal, invoice.currency, locale), + outstanding_amount: formatMajor(outstandingMinor, invoice.currency, locale), + paid_amount: formatMajor(invoice.paid_amount_minor, invoice.currency, locale), + late_fee_amount: formatMajor(lateFeeGross, invoice.currency, locale), + due_date: formatShortDate(invoice.due_date), + days_overdue: daysOverdue, + cc: reminderCc, + attachments, + // Dunning reminders are relationship mail — hold to business hours. + }, { respectBusinessHours: true }); + } catch (err) { + // Don't leave the just-rendered Mahnung PDF orphaned on disk if queueing the + // email failed — it would only be reachable via the next reminder anyway. + try { fs.unlinkSync(mahnungPath); } catch (_) { /* best-effort cleanup */ } + throw err; + } try { - await logActivity('invoice_reminder_sent', { invoiceId: invoice.id, level, lateFeeMinor }, + await logActivity('invoice_reminder_sent', { invoiceId: invoice.id, level, lateFeeMinor: lateFeeGross }, invoice.event_id || null, `admin:${adminId || 'system'}`); } catch (_) {} - return { level, lateFeeMinor }; + return { level, lateFeeMinor: lateFeeGross }; } // --------------------------------------------------------------------- @@ -2867,10 +2972,9 @@ async function queuePaymentCheckEmail(invoiceId, { skipThrottle = false } = {}) // Determine whether the customer reminder will include a Mahngebühr // if the admin selects "Not paid" / "Partial" — surfaced to the // email so the admin sees the consequence before clicking. - const reminderLateFeeEnabled = (await getAppSetting('crm_invoices_late_fee_enabled')) !== false; - const reminderFeeMinor = ensureInt(await getAppSetting('crm_invoices_late_fee_minor')) || 2500; + const reminderFeeMinor = await resolvePerReminderFeeMinor(invoice); const nextLevel = (invoice.reminder_level || 0) + 1; - const willChargeFee = reminderLateFeeEnabled && nextLevel >= 2; + const willChargeFee = reminderFeeMinor > 0 && nextLevel >= 2; const baseUrl = process.env.FRONTEND_URL || (await getAppSetting('app_frontend_url')) @@ -3121,7 +3225,7 @@ async function recordPaymentCheckAction({ token, action, amountMinor, ip, adminI const refreshed = await db('invoices').where({ id: invoice.id }).first(); if (refreshed.status !== 'paid') { const nextLevel = (refreshed.reminder_level || 0) + 1; - if (nextLevel <= 2) { + if (nextLevel <= 3) { const lineItems = await db('invoice_line_items') .where({ invoice_id: invoice.id }).orderBy('position', 'asc'); await applyReminder(refreshed, lineItems, nextLevel, adminId); @@ -3132,7 +3236,7 @@ async function recordPaymentCheckAction({ token, action, amountMinor, ip, adminI // 'unpaid' const nextLevel = (invoice.reminder_level || 0) + 1; - if (nextLevel > 2) { + if (nextLevel > 3) { // Already at max reminder — admin has to take this offline. return { applied: 'unpaid', reminderSkipped: 'max_level_reached' }; } @@ -3350,7 +3454,17 @@ async function runScheduledTasks() { // Throttled to one email per 24h per invoice via // invoices.last_payment_check_at. const remindersEnabled = await getAppSetting('crm_invoices_reminders_enabled'); - if (remindersEnabled !== false) { + // Mutual exclusion with the workflow engine: the hardcoded ladder stands down + // only when the invoice_dunning built-in is ENABLED (then the engine fires the + // payment-check emails). A disabled built-in leaves this ladder running — so + // the flow can ship disabled without dunning going dark, and disabling the + // flow reverts to the ladder. Fails closed → ladder stays on if the subsystem + // is down. + let engineDrivesDunning = false; + try { + engineDrivesDunning = await require('./workflows').isBuiltinFlowActive('invoice_dunning'); + } catch (_) { /* workflows tables absent / flag system down → ladder stays on */ } + if (remindersEnabled !== false && !engineDrivesDunning) { const firstDays = ensureInt(await getAppSetting('crm_invoices_reminder_first_days')) || 14; const secondDays = ensureInt(await getAppSetting('crm_invoices_reminder_second_days')) || 30; @@ -3441,6 +3555,10 @@ module.exports = { validateInstallmentPlanInput, sendInvoice, sendReminder, + applyReminder, + resolveLateFeeNetMinor, + resolveLateFeeVatRate, + resolvePerReminderFeeMinor, markPaid, cancelInvoice, releaseForDelivery, diff --git a/backend/src/services/pdf-i18n.js b/backend/src/services/pdf-i18n.js index 22ce8e78..064c0002 100644 --- a/backend/src/services/pdf-i18n.js +++ b/backend/src/services/pdf-i18n.js @@ -25,6 +25,7 @@ const LABELS = { // under the title that the customer/auditor needs to trace the // §14c-defensible reversal. storno_title: 'Cancellation invoice', + mahnung_title: 'Payment reminder', reference_cancels: 'Cancels', date: 'Date', quote_number: 'Quote', @@ -154,6 +155,7 @@ const LABELS = { quote_number_label: 'Angebotsnummer', invoice_number_label: 'Rechnungsnummer', storno_title: 'Stornorechnung', + mahnung_title: 'Mahnung', reference_cancels: 'Storno zu', date: 'Datum', quote_number: 'Angebot', diff --git a/backend/src/services/pdfService.js b/backend/src/services/pdfService.js index 0e4cf8d6..c12d607b 100644 --- a/backend/src/services/pdfService.js +++ b/backend/src/services/pdfService.js @@ -1477,6 +1477,10 @@ function renderDocument(type, context) { // family — Storni share the invoice renderer surface, only // the cosmetic + accounting-sign branches differ. const isStorno = type === 'invoice' && ctx.doc.kind === 'storno'; + // Mahnung (reminder letter) reuses the invoice surface: same line items + + // a Mahngebühr row + the new grand total, but a "Mahnung" title and NO + // QR (the QR would encode the original amount, not the new total). + const isMahnung = type === 'invoice' && ctx.doc.kind === 'mahnung'; // ---- document number (above) + date (below), both right-aligned // The number sits directly under the sender address block so the @@ -1516,7 +1520,9 @@ function renderDocument(type, context) { ? t(ctx.locale, 'quote_title') : isStorno ? t(ctx.locale, 'storno_title') - : t(ctx.locale, 'invoice_title'); + : isMahnung + ? t(ctx.locale, 'mahnung_title') + : t(ctx.locale, 'invoice_title'); y = drawTitle(doc, title, leftX, y + 2); // Mandatory Storno reference line — "Bezug: Storno zu Rechnung @@ -1700,7 +1706,7 @@ function renderDocument(type, context) { // Both append a fresh page; 'none' is a no-op. // Suppressed on Stornorechnungen — negative-amount QR codes // aren't a defined construct in either spec. - if (type === 'invoice' && !isStorno) { + if (type === 'invoice' && !isStorno && !isMahnung) { if (ctx.qrFormat === 'swiss') { appendSwissQrBill(doc, ctx); } else if (ctx.qrFormat === 'epc') { diff --git a/backend/src/services/quoteService.js b/backend/src/services/quoteService.js index 8c83686e..98aefae9 100644 --- a/backend/src/services/quoteService.js +++ b/backend/src/services/quoteService.js @@ -305,6 +305,25 @@ async function nextQuoteNumber(trx) { return formatNumberInTemplate(format, year, seq); } +/** + * Resolve the fallback event type for a quote→event conversion when the quote + * itself carries none. Never hardcodes a specific slug (any of them, incl. + * 'other', can be disabled by the admin): prefer the generic 'other' catch-all + * when it's active, else the first active type by display order, and only fall + * back to the literal 'other' if the catalog is somehow empty/unreadable. + */ +async function resolveDefaultEventType(conn) { + const q = conn || db; + try { + const other = await q('event_types').where({ slug_prefix: 'other', is_active: true }).first('slug_prefix'); + if (other) return 'other'; + const firstActive = await q('event_types').where({ is_active: true }).orderBy('display_order', 'asc').first('slug_prefix'); + return firstActive?.slug_prefix || 'other'; + } catch (_) { + return 'other'; + } +} + function ensureCustomerFeatureEnabled(customer, feature) { // Global toggle (`customer_feature_quotes_enabled` / `..._bills_enabled`) // is checked at the route layer (feature flag); here we only enforce @@ -563,6 +582,15 @@ async function createQuote(payload, adminId) { if (payload.vatCode !== undefined && await hasColumnCached('quotes', 'vat_code')) { row.vat_code = payload.vatCode ? String(payload.vatCode).slice(0, 16) : null; } + // Migration 146 — event type (event_types.slug_prefix). Drives the type of + // the event the quote converts into, instead of the old hardcoded 'wedding'. + if (payload.eventType !== undefined && await hasColumnCached('quotes', 'event_type')) { + row.event_type = payload.eventType ? String(payload.eventType).slice(0, 64) : null; + } + // Migration 147 — the booking workflow this quote runs on acceptance. + if (payload.bookingWorkflowId !== undefined && await hasColumnCached('quotes', 'booking_workflow_id')) { + row.booking_workflow_id = payload.bookingWorkflowId || null; + } const inserted = await trx('quotes').insert(row).returning('id'); const quoteId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0]; @@ -691,6 +719,14 @@ async function updateQuote(id, payload, adminId) { if (Object.prototype.hasOwnProperty.call(payload, 'vatCode') && await hasColumnCached('quotes', 'vat_code')) { updates.vat_code = payload.vatCode ? String(payload.vatCode).slice(0, 16) : null; } + // Migration 146 — event type. + if (Object.prototype.hasOwnProperty.call(payload, 'eventType') && await hasColumnCached('quotes', 'event_type')) { + updates.event_type = payload.eventType ? String(payload.eventType).slice(0, 64) : null; + } + // Migration 147 — selected booking workflow. + if (Object.prototype.hasOwnProperty.call(payload, 'bookingWorkflowId') && await hasColumnCached('quotes', 'booking_workflow_id')) { + updates.booking_workflow_id = payload.bookingWorkflowId || null; + } await trx('quotes').where({ id }).update(updates); // When linked to a project, cascade across the deal lineage so the linked @@ -981,6 +1017,11 @@ async function sendQuote(id, adminId) { await logActivity('quote_sent', { quoteId: id, token }, null, `admin:${adminId}`); } catch (_) {} + // Fire the quote.sent workflow trigger (best-effort; emit is fail-closed when + // the workflows flag is off). The accepted/declined emits already exist; this + // closes the gap so flows can react to a quote going out. + await emitQuoteEvent(quote, 'sent'); + logger.info('Quote sent', { adminId, quoteId: id }); return { token, pdfPath }; } @@ -1022,6 +1063,43 @@ async function persistDocPdf(type, doc, buffer) { * the same token may flip accept↔decline. After the window expires the * response is locked. */ +/** + * Fire a quote lifecycle event for the workflow engine. Best-effort: resolves + * the customer email (so send_email actions have a recipient) and never throws + * into the caller. No-op when the workflows flag is off (emit fails closed). + */ +async function emitQuoteEvent(quote, status) { + try { + let customerEmail = null; + if (quote.customer_account_id) { + const c = await db('customer_accounts').where({ id: quote.customer_account_id }).first(); + customerEmail = c?.email || null; + } + // On acceptance, if the admin picked a booking workflow on the quote, run + // ONLY that flow (instead of fanning out to every enabled quote.accepted + // flow). Other statuses keep the normal fan-out. + const targetWorkflowId = (status === 'accepted' && quote.booking_workflow_id) + ? quote.booking_workflow_id + : null; + await require('./workflows').emitWorkflowEvent(`quote.${status}`, { + entityType: 'quote', + entityId: quote.id, + targetWorkflowId, + payload: { + quoteId: quote.id, + quoteNumber: quote.quote_number, + customerAccountId: quote.customer_account_id || null, + customerEmail, + eventName: quote.event_name || null, + eventDate: quote.event_date || null, + eventType: quote.event_type || null, + totalMinor: quote.total_amount_minor ?? null, + bookingWorkflowId: quote.booking_workflow_id || null, + }, + }); + } catch (_) { /* best-effort */ } +} + async function recordResponse({ token, action, ip, tosAccepted }) { if (!['accept', 'decline'].includes(action)) { throw new AppError('Invalid action', 400); @@ -1105,6 +1183,8 @@ async function recordResponse({ token, action, ip, tosAccepted }) { await logActivity(`quote_${newStatus}`, { quoteId: quote.id, token: tokenRow.token }, null, 'customer:public'); } catch (_) {} + await emitQuoteEvent(quote, newStatus); + return { status: newStatus, lockedAt: responseLockedAt }; } @@ -1202,6 +1282,8 @@ async function adminAcceptQuote(id, adminId) { logger.warn('quote_accepted_customer email queue failed', { quoteId: id, err: err.message }); } + await emitQuoteEvent(quote, 'accepted'); + return { status: 'accepted', lockedAt: responseLockedAt }; } @@ -1265,6 +1347,8 @@ async function adminDeclineQuote(id, adminId, reason = null) { await logActivity('quote_declined_by_admin', { quoteId: id, reason: cleanReason }, null, `admin:${adminId}`); } catch (_) {} + await emitQuoteEvent(quote, 'declined'); + return { status: 'declined', declinedAt: now }; } @@ -1443,6 +1527,13 @@ async function convertToEvent(quoteId, adminId, options = {}) { const customerEmail = customer.email || `${quote.quote_number.toLowerCase()}@picpeak.local`; const adminEmail = adminRow?.email || customer.email || 'admin@picpeak.local'; + // Event type for the new event: the type chosen on the quote (migration 146), + // else a configurable org default, else the resolved catch-all (an ACTIVE + // type — never a hardcoded slug the admin may have disabled). + const eventType = (quote.event_type && String(quote.event_type).trim()) + || (await getAppSetting('crm_default_event_type')) + || (await resolveDefaultEventType(trx)); + // Each candidate column is paired with the value we'd write. We // ask the DB which columns exist and only keep the matching pairs // — bullet-proof against schema drift in either direction. @@ -1457,7 +1548,7 @@ async function convertToEvent(quoteId, adminId, options = {}) { customer_email: customerEmail, customer_phone: customer.phone, admin_email: adminEmail, - event_type: 'wedding', + event_type: eventType, password_hash: placeholder, share_link: shareLink, share_token: shareLink, diff --git a/backend/src/services/webhookService.js b/backend/src/services/webhookService.js index 3dca6b02..9f450d66 100644 --- a/backend/src/services/webhookService.js +++ b/backend/src/services/webhookService.js @@ -210,6 +210,38 @@ function parseJsonField(value, fallback) { try { return JSON.parse(value) ?? fallback; } catch { return fallback; } } +/** + * Enqueue a delivery for ONE specific active webhook subscription, bypassing the + * event-type subscription matching that `fire` does. Used by the workflow engine + * `webhook` action: the flow author picks a configured webhook (which carries + * the URL + signing secret + create-time URL validation), and the delivery then + * rides the SAME worker pipeline as every other webhook — per-delivery SSRF + * re-validation, HMAC signing, retries/backoff and the audit log, all for free. + * Never throws. Returns { enqueued, reason?, deliveryId? }. + */ +async function enqueueForWebhook(webhookId, eventType, data) { + try { + const w = await db('webhooks').where({ id: webhookId, active: true }).first(); + if (!w) return { enqueued: false, reason: 'webhook not found or inactive' }; + const now = new Date(); + const deliveryUuid = crypto.randomUUID(); + const envelope = { id: deliveryUuid, type: eventType, created_at: now.toISOString(), data }; + await db('webhook_deliveries').insert({ + webhook_id: w.id, + event_type: String(eventType).slice(0, 64), + payload: JSON.stringify(envelope), + attempt_count: 0, + status: 'pending', + next_retry_at: now, + created_at: now, + }); + return { enqueued: true, webhookId: w.id, deliveryId: deliveryUuid }; + } catch (err) { + logger.error(`[webhookService.enqueueForWebhook] failed for #${webhookId}: ${err.message}`); + return { enqueued: false, reason: err.message }; + } +} + /** * Canonical event sub-object for outbound webhooks (#341). Always returns * the full key set so receivers don't have to handle "field missing vs @@ -235,6 +267,7 @@ function buildEventSubject(input = {}) { module.exports = { fire, + enqueueForWebhook, generateSecret, signPayload, verifySignature, diff --git a/backend/src/services/workflows/actions.js b/backend/src/services/workflows/actions.js new file mode 100644 index 00000000..468a32bb --- /dev/null +++ b/backend/src/services/workflows/actions.js @@ -0,0 +1,220 @@ +/** + * Workflow action + condition handlers that touch real picpeak data. + * + * Registered at load time (index.js requires this module). Kept separate from + * registry.js (which holds only primitives) so the I/O-coupled handlers don't + * bloat the pure core. + * + * Email routing rule (locked requirement): INTERNAL/admin mail sends + * immediately; EXTERNAL/customer mail respects the business-hours floor. The + * action sets queueEmail's `respectBusinessHours` from the recipient class. + * + * The create/prepare-document actions (quote/contract/event/gallery/invoice) + * are registered so flows validate, but are intentionally NOT wired to the + * services yet — they record a `skipped` step with a clear reason so the gap + * is observable rather than silent. Wiring is a follow-up commit. + */ +const registry = require('./registry'); + +const DOCUMENT_ACTIONS = [ + 'prepare_quote', + 'prepare_contract', + 'prepare_event', + 'prepare_gallery', + 'prepare_invoice', + 'send_document', + 'reserve_date', +]; + +// --- Conditions --- + +// True once the run's invoice entity is settled (paid_at set, status paid, or +// the cumulative paid amount covers the total). +registry.registerCondition('invoice_paid', async (ctx) => { + const id = ctx.run.entity_id; + if (!id) return false; + const inv = await ctx.db('invoices').where({ id }).first(); + if (!inv) return false; + if (inv.paid_at) return true; + if (inv.status === 'paid') return true; + const paid = Number(inv.paid_amount_minor) || 0; + const total = Number(inv.total_amount_minor); + return Number.isFinite(total) && total > 0 && paid >= total; +}); + +// --- Actions --- + +// Queue an email. recipientClass 'admin' (internal) sends immediately; +// anything else (customer/external) respects the business-hours floor. +registry.registerAction('send_email', async (ctx) => { + const cfg = ctx.node.config || {}; + if (ctx.vars?.__dryRun) return { dryRun: true, would: 'send_email', recipientClass: cfg.recipientClass || cfg.recipient || 'customer', emailType: cfg.emailType || cfg.template }; + const recipientClass = cfg.recipientClass || cfg.recipient || 'customer'; + const isInternal = recipientClass === 'admin' || recipientClass === 'internal'; + const to = cfg.to + || ctx.vars[isInternal ? 'adminEmail' : 'customerEmail'] + || ctx.vars.recipientEmail; + if (!to) return { skipped: true, reason: 'no recipient resolved' }; + + const emailProcessor = require('../emailProcessor'); + const eventId = ctx.vars.eventId || null; + const emailType = cfg.emailType || cfg.template || 'workflow_notification'; + const emailData = { ...(cfg.emailData || {}), ...(ctx.vars.emailData || {}) }; + + // INTERNAL/admin = immediate; EXTERNAL/customer = business-hours floor. + const respectBusinessHours = !isInternal; + await emailProcessor.queueEmail(eventId, to, emailType, emailData, { respectBusinessHours }); + return { sent_to: to, recipientClass, respectBusinessHours }; +}); + +// Fire the existing admin payment-check email (the dunning gate). Delegates to +// invoiceService.queuePaymentCheckEmail so the proven escalation + +// Mahngebühr / reminder_level state machine (recordPaymentCheckAction) stays +// the single source of truth — the workflow only decides WHEN it fires. This +// is what makes the built-in dunning flow a faithful replacement for the +// hardcoded ladder (paired with the mutual-exclusion guard in runScheduledTasks). +registry.registerAction('queue_payment_check', async (ctx) => { + const id = ctx.run.entity_id; + if (!id) return { skipped: true, reason: 'no invoice entity' }; + if (ctx.vars?.__dryRun) return { dryRun: true, would: 'queue_payment_check', invoiceId: id }; + await require('../invoiceService').queuePaymentCheckEmail(id); + return { payment_check_queued: id }; +}); + +// After the dunning loop exhausts (e.g. 3 unpaid reminders), consolidate +// everything collections needs into ONE email to the admin: customer data, the +// outstanding total (invoice + late fees − paid) and the invoice PDF attached — +// ready to forward to an Inkasso agency / for Betreibung. Internal mail → sent +// immediately. Does NOT touch the invoice. +registry.registerAction('escalate_to_collections', async (ctx) => { + const id = ctx.run.entity_id; + if (!id) return { skipped: true, reason: 'no invoice entity' }; + if (ctx.vars?.__dryRun) return { dryRun: true, would: 'escalate_to_collections', invoiceId: id }; + const { db } = ctx; + const invoice = await db('invoices').where({ id }).first(); + if (!invoice) return { skipped: true, reason: 'invoice not found' }; + const customer = invoice.customer_account_id + ? await db('customer_accounts').where({ id: invoice.customer_account_id }).first() + : null; + const profile = await db('business_profile').where({ id: 1 }).first(); + const adminEmail = ctx.vars?.adminEmail || profile?.email || null; + if (!adminEmail) return { skipped: true, reason: 'no admin email' }; + + const currency = invoice.currency || 'CHF'; + const fmt = (m) => `${currency} ${(Number(m || 0) / 100).toFixed(2)}`; + const total = Number(invoice.total_amount_minor || 0); + const fee = Number(invoice.late_fee_amount_minor || 0); + const paid = Number(invoice.paid_amount_minor || 0); + const outstanding = Math.max(0, total + fee - paid); + const address = [customer?.address, customer?.postal_code, customer?.city, customer?.country_name] + .filter(Boolean).join(', '); + + const attachments = []; + try { + const fs = require('fs'); + if (invoice.pdf_path && fs.existsSync(invoice.pdf_path)) { + attachments.push({ filename: `${invoice.invoice_number}.pdf`, contentPath: invoice.pdf_path, contentType: 'application/pdf' }); + } + } catch (_) { /* attachment is best-effort */ } + + await require('../emailProcessor').queueEmail(invoice.event_id || null, adminEmail, 'invoice_collections_handoff', { + invoice_number: invoice.invoice_number, + customer_name: customer?.display_name || customer?.email || '—', + customer_email: customer?.email || '', + customer_address: address, + event_name: invoice.event_name || '', + original_amount: fmt(total), + late_fee_amount: fee ? fmt(fee) : '', + paid_amount: fmt(paid), + outstanding_amount: fmt(outstanding), + due_date: invoice.due_date ? String(invoice.due_date).slice(0, 10) : '', + reminder_level: invoice.reminder_level || 0, + attachments, + }, { respectBusinessHours: false }); // internal/admin → immediate + + return { collections_handoff_to: adminEmail, outstanding }; +}); + +// --- Gallery / pre-event notification actions (cutover) --- +// +// These DELEGATE to the existing service send functions, so the engine path is +// byte-identical to the legacy hourly checker/pass it replaces (same templates, +// recipients, variables, dedup). The legacy path stands down when the matching +// built-in flow is enabled (isBuiltinFlowActive guard), so exactly one email +// goes out. + +// Send the gallery expiration-warning email for the run's event entity. +registry.registerAction('notify_gallery_expiring', async (ctx) => { + const id = ctx.run.entity_id; + if (!id) return { skipped: true, reason: 'no event entity' }; + if (ctx.vars?.__dryRun) return { dryRun: true, would: 'notify_gallery_expiring', eventId: id }; + const event = await ctx.db('events').where({ id }).first(); + if (!event) return { skipped: true, reason: 'event not found' }; + await require('../expirationChecker').queueExpirationWarning(event); + return { warning_queued: id }; +}); + +// Send the gallery_expired email(s) for the run's event entity. +registry.registerAction('notify_gallery_expired', async (ctx) => { + const id = ctx.run.entity_id; + if (!id) return { skipped: true, reason: 'no event entity' }; + if (ctx.vars?.__dryRun) return { dryRun: true, would: 'notify_gallery_expired', eventId: id }; + const event = await ctx.db('events').where({ id }).first(); + if (!event) return { skipped: true, reason: 'event not found' }; + await require('../expirationChecker').sendGalleryExpiredEmails(event); + return { expired_email_queued: id }; +}); + +// Send the pre-event customer reminder for the run's event entity. Delegates to +// eventReminderService so per-event overrides + sent_at idempotency are honoured. +registry.registerAction('notify_pre_event', async (ctx) => { + const id = ctx.run.entity_id; + if (!id) return { skipped: true, reason: 'no event entity' }; + // The template GROUP is chosen on THIS block (config.templateGroup, e.g. + // 'event_reminder'); the exact template is still auto-picked by event type + // within that group. Blank → the default group. + const templateGroup = ctx.node.config?.templateGroup || null; + if (ctx.vars?.__dryRun) return { dryRun: true, would: 'notify_pre_event', eventId: id, templateGroup }; + const res = await require('../eventReminderService').sendReminderForEvent(id, { templateGroup }); + return res; +}); + +// Call a webhook (the `webhook` node type + the "Call a webhook" action both +// resolve here). The flow author picks a CONFIGURED webhook subscription +// (config.webhookId, managed in Settings → Webhooks); this enqueues a real +// delivery for it, so it rides the same worker pipeline as every other webhook: +// per-delivery SSRF re-validation (validateExternalUrl / GHSA-wmjx-pc37-272r), +// HMAC signing with the subscription's secret, retries/backoff, and the audit +// log — all inherited, nothing reimplemented. Best-effort: an unset / missing / +// inactive webhook records an observable skipped step. +registry.registerAction('webhook', async (ctx) => { + const webhookId = ctx.node.config?.webhookId ? Number(ctx.node.config.webhookId) : null; + if (!webhookId) return { skipped: true, reason: 'no webhook selected (pick one in Settings → Webhooks)' }; + if (ctx.vars?.__dryRun) return { dryRun: true, would: 'webhook', webhookId }; + + const eventType = `workflow.${ctx.run.trigger_event || 'webhook'}`; + const res = await require('../webhookService').enqueueForWebhook(webhookId, eventType, { + workflow: { id: ctx.run.workflow_id, version: ctx.run.version }, + run: { + id: ctx.run.id, + trigger_event: ctx.run.trigger_event, + entity_type: ctx.run.entity_type, + entity_id: ctx.run.entity_id, + }, + vars: ctx.vars || {}, + }); + return res.enqueued + ? { webhook_enqueued: res.webhookId, deliveryId: res.deliveryId } + : { skipped: true, reason: res.reason }; +}); + +// Create/prepare-document actions — registered so flows referencing them are +// valid; service wiring is a follow-up. Records a skipped step (observable). +for (const key of DOCUMENT_ACTIONS) { + registry.registerAction(key, async (ctx) => { + ctx.logger?.warn?.('[workflow] document action not yet wired', { action: key, runId: ctx.run.id }); + return { skipped: true, reason: `action ${key} not yet implemented` }; + }); +} + +module.exports = { DOCUMENT_ACTIONS }; diff --git a/backend/src/services/workflows/approvals.js b/backend/src/services/workflows/approvals.js new file mode 100644 index 00000000..aee175d7 --- /dev/null +++ b/backend/src/services/workflows/approvals.js @@ -0,0 +1,140 @@ +/** + * Workflow approval gates — the human-in-the-loop step. + * + * When the engine hits a `gate` node it calls the registered `gate_setup` + * action, which creates a workflow_approvals row (single-use token stored as a + * SHA-256 hash) and emails the admin a confirm/deny link. The run stays + * `waiting` until the admin acts — via the email link (actByToken) or the + * webview pending-approvals inbox (actById) — at which point the run resumes + * down the matching confirm/deny edge. + * + * Internal/admin mail → sent immediately (respectBusinessHours: false). + */ +const crypto = require('crypto'); +const { db } = require('../../database/db'); +const logger = require('../../utils/logger'); +const registry = require('./registry'); +const engine = require('./engine'); + +function hashToken(raw) { + return crypto.createHash('sha256').update(String(raw)).digest('hex'); +} + +/** + * gate_setup action — create the approval + email the admin. Called by the + * engine when a gate node is reached. Best-effort on the email; the approval + * row (and thus the inbox path) is always created. + */ +async function createApproval(ctx) { + const { run, node } = ctx; + const cfg = node.config || {}; + const raw = crypto.randomBytes(32).toString('hex'); + const expiresAt = cfg.timeoutDays + ? new Date(Date.now() + Number(cfg.timeoutDays) * 86400000).toISOString() + : null; + + await db('workflow_approvals').insert({ + run_id: run.id, + node_key: node.node_key, + type: cfg.type || 'payment_confirm', + status: 'pending', + token_hash: hashToken(raw), + payload: JSON.stringify({ prompt: cfg.prompt || null, vars: ctx.vars || {} }), + expires_at: expiresAt, + created_at: db.fn.now(), + }); + + try { + const { getFrontendBaseUrl } = require('../../utils/frontendUrl'); + const base = (await getFrontendBaseUrl()) || ''; + const confirmUrl = `${base}/api/public/workflow-approvals/${raw}/confirm`; + const denyUrl = `${base}/api/public/workflow-approvals/${raw}/deny`; + + let adminEmail = ctx.vars?.adminEmail || null; + if (!adminEmail) { + const bp = await db('business_profile').where({ id: 1 }).first('email'); + adminEmail = bp?.email || null; + } + if (adminEmail) { + const emailProcessor = require('../emailProcessor'); + await emailProcessor.queueEmail( + ctx.vars?.eventId || null, + adminEmail, + cfg.emailType || 'workflow_approval', + { + prompt: cfg.prompt || 'A workflow needs your confirmation.', + confirm_url: confirmUrl, + deny_url: denyUrl, + ...(ctx.vars?.emailData || {}), + }, + { respectBusinessHours: false }, // internal/admin → immediate + ); + } else { + logger.warn('[workflow] approval created but no admin email to notify', { runId: run.id }); + } + } catch (e) { + logger.error('[workflow] approval email failed', { runId: run.id, error: e.message }); + } + + return { approval: true }; +} + +registry.registerAction('gate_setup', createApproval); + +async function finalizeApproval(approval, decision, actorPatch) { + if (!approval) return { ok: false, reason: 'not_found' }; + if (approval.status !== 'pending') return { ok: true, already: true, status: approval.status }; + if (approval.expires_at && new Date(approval.expires_at).getTime() < Date.now()) { + await db('workflow_approvals').where({ id: approval.id }).update({ status: 'expired' }); + return { ok: false, reason: 'expired' }; + } + const status = decision === 'confirm' ? 'confirmed' : 'denied'; + await db('workflow_approvals').where({ id: approval.id }) + .update({ status, acted_at: db.fn.now(), ...actorPatch }); + // Resume down the matching edge (handles 'confirm' | 'deny'). + await engine.resumeRun(approval.run_id, { decisionHandle: decision }); + return { ok: true, status }; +} + +/** Act on an approval via the emailed single-use token. */ +async function actByToken(rawToken, decision) { + const approval = await db('workflow_approvals').where({ token_hash: hashToken(rawToken) }).first(); + return finalizeApproval(approval, decision, { acted_via: 'email' }); +} + +/** + * Read-only lookup for the emailed token — used to render the confirm/deny + * interstitial WITHOUT mutating state (so email-client prefetchers can't + * advance the gate). Never resumes the run. + */ +async function peekApproval(rawToken) { + const a = await db('workflow_approvals').where({ token_hash: hashToken(rawToken) }).first(); + if (!a) return { found: false }; + let prompt = null; + try { prompt = (JSON.parse(a.payload || '{}') || {}).prompt || null; } catch (_) { /* ignore */ } + const expired = !!(a.expires_at && new Date(a.expires_at).getTime() < Date.now()); + return { found: true, status: a.status, prompt, expired }; +} + +/** Act on an approval from the admin webview inbox. */ +async function actById(id, decision, adminId) { + const approval = await db('workflow_approvals').where({ id }).first(); + return finalizeApproval(approval, decision, { acted_via: 'web', acted_by: adminId || null }); +} + +/** Pending approvals for the webview inbox, newest first, with workflow name. */ +async function listPending(limit = 100) { + return db('workflow_approvals as a') + .join('workflow_runs as r', 'r.id', 'a.run_id') + .join('workflows as w', 'w.id', 'r.workflow_id') + .where('a.status', 'pending') + .select( + 'a.id', 'a.type', 'a.payload', 'a.created_at', 'a.expires_at', + 'r.id as run_id', 'r.entity_type', 'r.entity_id', + 'w.id as workflow_id', 'w.name as workflow_name', + ) + .orderBy('a.created_at', 'desc') + .limit(limit); +} + +module.exports = { hashToken, createApproval, actByToken, actById, listPending, peekApproval }; diff --git a/backend/src/services/workflows/engine.js b/backend/src/services/workflows/engine.js new file mode 100644 index 00000000..7d7cb4c5 --- /dev/null +++ b/backend/src/services/workflows/engine.js @@ -0,0 +1,561 @@ +/** + * Workflow execution engine — walks a flow GRAPH (nodes + edges) per run. + * + * Node types: trigger | condition/branch | loop | wait | gate | action | webhook. + * - condition/branch: run a registered condition → follow the yes/no edge. + * - loop: increment a per-node counter in run.context → follow loop/exit edge + * (bounded by config.maxIterations — no infinite runs). + * - wait: set status='waiting' + wake_at; the scheduler resumes it later. + * - gate: set status='waiting'; an approval (email confirm/deny or the webview + * inbox) resumes it via the matching confirm/deny edge. + * - action/webhook: dispatch to a registered action handler. + * + * Runs are idempotent (unique dedup_key per trigger+entity) and every node + * writes a workflow_run_steps row for observability / System Health. Designed + * to be called AFTER the caller's DB commit (emit never throws into callers). + */ +const { db } = require('../../database/db'); +const logger = require('../../utils/logger'); +const registry = require('./registry'); + +const MAX_STEPS_PER_ADVANCE = 200; + +function parseJson(value, fallback) { + if (value == null) return fallback; + if (typeof value === 'object') return value; + try { return JSON.parse(value); } catch (e) { return fallback; } +} + +async function loadGraph(workflowId, version) { + const nodes = await db('workflow_nodes').where({ workflow_id: workflowId, version }); + const edges = await db('workflow_edges').where({ workflow_id: workflowId, version }); + const nodeByKey = new Map(nodes.map((n) => [n.node_key, { ...n, config: parseJson(n.config, {}) }])); + return { nodeByKey, edges }; +} + +// Pick the outgoing edge from `fromNode`. With a handle, prefer the matching +// handle; otherwise fall back to a default (null-handle) edge or the sole edge. +function outEdge(edges, fromNode, handle) { + const candidates = edges.filter((e) => e.from_node === fromNode); + if (handle != null) { + const exact = candidates.find((e) => (e.from_handle || null) === handle); + if (exact) return exact; + } + return candidates.find((e) => e.from_handle == null) || (candidates.length === 1 ? candidates[0] : null); +} + +function computeWakeAt(config = {}, vars = {}) { + const cfg = config || {}; + if (cfg.untilVar && vars[cfg.untilVar]) return new Date(vars[cfg.untilVar]).toISOString(); + const ms = (Number(cfg.delayDays || 0) * 86400000) + + (Number(cfg.delayHours || 0) * 3600000) + + (Number(cfg.delayMinutes || 0) * 60000); + return new Date(Date.now() + ms).toISOString(); +} + +function gateTimeout(config = {}) { + const days = Number((config || {}).timeoutDays || 0); + return days > 0 ? new Date(Date.now() + days * 86400000).toISOString() : null; +} + +function matchFilter(filter, payload) { + if (!filter || typeof filter !== 'object') return true; + const { field, op = 'eq', value } = filter; + const actual = payload ? payload[field] : undefined; + // Strict equality: a filter {value: 0} must NOT match false/''/null (loose == + // conflated them). Authors must therefore match the payload's actual type. + switch (op) { + case 'neq': return actual !== value; + case 'truthy': return Boolean(actual); + case 'falsy': return !actual; + case 'eq': + default: return actual === value; + } +} + +async function recordStep(runId, node, status, result, error) { + await db('workflow_run_steps').insert({ + run_id: runId, + node_key: node.node_key, + node_type: node.type, + status, + result: result ? JSON.stringify(result) : null, + error: error || null, + finished_at: db.fn.now(), + }); +} + +async function failRun(runId, error) { + await db('workflow_runs').where({ id: runId }).update({ status: 'failed', error, finished_at: db.fn.now() }); + logger.error('[workflow] run failed', { runId, error }); +} + +async function finishRun(runId) { + await db('workflow_runs').where({ id: runId }).update({ status: 'done', finished_at: db.fn.now() }); +} + +/** + * Walk the graph from the run's current node until it ends, fails, or pauses + * (wait / gate). Persists context + current_node after each node. + */ +async function advanceRun(runId) { + let run = await db('workflow_runs').where({ id: runId }).first(); + if (!run || run.status !== 'running') return; + const { nodeByKey, edges } = await loadGraph(run.workflow_id, run.version); + const context = parseJson(run.context, { vars: {} }); + if (!context.vars) context.vars = {}; + + let currentKey = run.current_node; + let steps = 0; + + while (currentKey) { + if (++steps > MAX_STEPS_PER_ADVANCE) { await failRun(runId, 'max steps per advance exceeded'); return; } + const node = nodeByKey.get(currentKey); + if (!node) { await failRun(runId, `node not found: ${currentKey}`); return; } + + const ctx = { run, node, vars: context.vars, db, logger }; + let nextKey = null; + + try { + switch (node.type) { + case 'trigger': { + const e = outEdge(edges, currentKey, null); + nextKey = e ? e.to_node : null; + await recordStep(runId, node, 'done', null); + break; + } + case 'condition': + case 'branch': { + const cond = registry.getCondition(node.config?.condition || 'expr'); + const result = cond ? await cond(ctx) : false; + const handle = result ? (node.config?.trueHandle || 'yes') : (node.config?.falseHandle || 'no'); + const e = outEdge(edges, currentKey, handle) || outEdge(edges, currentKey, result ? 'true' : 'false'); + nextKey = e ? e.to_node : null; + await recordStep(runId, node, 'done', { result, handle }); + break; + } + case 'loop': { + const counterKey = `__loop_${node.node_key}`; + const count = (Number(context.vars[counterKey]) || 0) + 1; + context.vars[counterKey] = count; + const max = Number(node.config?.maxIterations ?? node.config?.max ?? 3); + const handle = count > max ? (node.config?.exitHandle || 'exit') : (node.config?.loopHandle || 'loop'); + const e = outEdge(edges, currentKey, handle); + nextKey = e ? e.to_node : null; + await recordStep(runId, node, 'done', { count, max, handle }); + break; + } + case 'wait': { + // Dry-run (test-fire): don't park — pass straight through so the whole + // flow runs in one shot, recording what it WOULD have waited for. + if (context.vars.__dryRun) { + const e = outEdge(edges, currentKey, null); + nextKey = e ? e.to_node : null; + await recordStep(runId, node, 'skipped', { dryRun: true, wouldWaitUntil: computeWakeAt(node.config, context.vars) }); + break; + } + const wakeAt = computeWakeAt(node.config, context.vars); + await db('workflow_runs').where({ id: runId }) + .update({ status: 'waiting', wake_at: wakeAt, current_node: currentKey, context: JSON.stringify(context) }); + await recordStep(runId, node, 'waiting', { wake_at: wakeAt }); + return; // paused — scheduler resumes when wake_at passes + } + case 'gate': { + // Dry-run (test-fire): auto-take the 'confirm' path so the escalation + // is exercised end-to-end, without creating an approval / emailing. + if (context.vars.__dryRun) { + const e = outEdge(edges, currentKey, 'confirm') || outEdge(edges, currentKey, null); + nextKey = e ? e.to_node : null; + await recordStep(runId, node, 'skipped', { dryRun: true, gateAutoConfirm: true }); + break; + } + await db('workflow_runs').where({ id: runId }) + .update({ status: 'waiting', wake_at: gateTimeout(node.config), current_node: currentKey, context: JSON.stringify(context) }); + await recordStep(runId, node, 'waiting', { gate: true }); + // Optional setup hook (create approval + send admin email) — registered + // by the approval phase. Engine still pauses cleanly without it. + const setup = registry.getAction('gate_setup'); + if (setup) { + try { await setup(ctx); } catch (e) { logger.error('[workflow] gate setup failed', { runId, error: e.message }); } + } + return; // paused — an approval (email or inbox) resumes via resumeRun + } + case 'action': + case 'webhook': { + const actionKey = node.config?.action || (node.type === 'webhook' ? 'webhook' : 'noop'); + const action = registry.getAction(actionKey); + const result = action ? (await action(ctx)) || {} : { skipped: true, reason: `unknown action ${actionKey}` }; + if (result.set && typeof result.set === 'object') Object.assign(context.vars, result.set); + const e = outEdge(edges, currentKey, null); + nextKey = e ? e.to_node : null; + await recordStep(runId, node, result.skipped ? 'skipped' : 'done', result); + break; + } + default: { + await recordStep(runId, node, 'skipped', { reason: `unknown node type ${node.type}` }); + const e = outEdge(edges, currentKey, null); + nextKey = e ? e.to_node : null; + } + } + } catch (err) { + await recordStep(runId, node, 'failed', null, err.message); + await failRun(runId, `node ${currentKey} failed: ${err.message}`); + return; + } + + currentKey = nextKey; + await db('workflow_runs').where({ id: runId }).update({ current_node: currentKey || null, context: JSON.stringify(context), updated_at: db.fn.now() }); + } + + await finishRun(runId); +} + +/** Begin a freshly-created run at its trigger node. */ +async function startRun(runId) { + const run = await db('workflow_runs').where({ id: runId }).first(); + if (!run || ['done', 'failed', 'cancelled'].includes(run.status)) return; + const { nodeByKey } = await loadGraph(run.workflow_id, run.version); + let entry = null; + for (const n of nodeByKey.values()) { if (n.type === 'trigger') { entry = n; break; } } + if (!entry) { await failRun(runId, 'no trigger node'); return; } + await db('workflow_runs').where({ id: runId }).update({ status: 'running', current_node: entry.node_key, updated_at: db.fn.now() }); + await advanceRun(runId); +} + +/** + * Resume a paused (waiting) run. For a wait node, pass no handle. For a gate, + * pass decisionHandle = 'confirm' | 'deny' so the matching edge is taken. + */ +async function resumeRun(runId, { decisionHandle = null } = {}) { + const run = await db('workflow_runs').where({ id: runId }).first(); + if (!run || run.status !== 'waiting') return; + const { edges } = await loadGraph(run.workflow_id, run.version); + // For a gate decision, the edge MUST match the handle exactly — we cannot fall + // back to outEdge's "sole edge" heuristic, or a 'deny' with only a 'confirm' + // edge would silently take the confirm path. A missing handle edge is a broken + // graph → fail loudly (same posture as unknown nodes) so the lost decision is + // visible in run history instead of masquerading as a green 'done'. + let e; + if (decisionHandle != null) { + e = edges.find((x) => x.from_node === run.current_node && (x.from_handle || null) === decisionHandle); + if (!e) { + await failRun(runId, `gate decision '${decisionHandle}' has no matching edge from node '${run.current_node}'`); + return; + } + } else { + e = outEdge(edges, run.current_node, null); + } + const nextKey = e ? e.to_node : null; + await db('workflow_runs').where({ id: runId }).update({ status: 'running', current_node: nextKey, wake_at: null, updated_at: db.fn.now() }); + if (!nextKey) { await finishRun(runId); return; } + await advanceRun(runId); +} + +/** + * Entry point for lifecycle events. Creates one run per matching enabled + * workflow (idempotent via dedup_key) and starts it. Never throws — safe to + * call after a caller's commit. Fails CLOSED if the flag system is unavailable. + */ +async function emitWorkflowEvent(triggerType, { entityType = null, entityId = null, payload = {}, targetWorkflowId = null } = {}) { + try { + const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag'); + let enabled = false; + try { enabled = await isFeatureEnabled('workflows'); } catch (e) { + logger.warn('[workflow] flag check failed — treating workflows as disabled', { error: e.message }); + return []; + } + if (!enabled) return []; + + // targetWorkflowId restricts the fan-out to a SINGLE chosen flow — used when + // the entity explicitly selected which flow to run (e.g. a quote picks its + // booking workflow). Still gated on enabled + matching trigger_type, so a + // disabled/mismatched selection simply runs nothing. + const q = db('workflows').where({ enabled: true, trigger_type: triggerType }); + if (targetWorkflowId != null) q.where({ id: targetWorkflowId }); + const workflows = await q; + const runIds = []; + for (const wf of workflows) { + const tcfg = parseJson(wf.trigger_config, {}); + if (tcfg && tcfg.filter && !matchFilter(tcfg.filter, payload)) continue; + + const dedupKey = `${wf.id}:${wf.version}:${triggerType}:${entityType || ''}:${entityId || ''}`; + const existing = await db('workflow_runs').where({ dedup_key: dedupKey }).first(); + if (existing) continue; + + try { + await db('workflow_runs').insert({ + workflow_id: wf.id, + version: wf.version, + trigger_event: triggerType, + entity_type: entityType, + entity_id: entityId, + status: 'pending', + context: JSON.stringify({ vars: { ...payload } }), + dedup_key: dedupKey, + }); + } catch (e) { + continue; // unique race — another emitter created it + } + const row = await db('workflow_runs').where({ dedup_key: dedupKey }).first(); + if (!row) continue; + runIds.push(row.id); + await startRun(row.id).catch((err) => logger.error('[workflow] start failed', { runId: row.id, error: err.message })); + } + return runIds; + } catch (e) { + logger.error('[workflow] emit failed', { triggerType, error: e.message }); + return []; + } +} + +/** + * Resume runs whose wait has elapsed. Called from the cron scheduler tick. + * Only advances `wait` nodes — gate timeouts are handled by the approvals + * layer. Fails CLOSED if the workflows flag is off (master kill-switch). + */ +async function runDueWaits(limit = 100) { + try { + const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag'); + let enabled = false; + try { enabled = await isFeatureEnabled('workflows'); } catch (e) { return 0; } + if (!enabled) return 0; + + const nowIso = new Date().toISOString(); + const due = await db('workflow_runs') + .where({ status: 'waiting' }) + .whereNotNull('wake_at') + .where('wake_at', '<=', nowIso) + .limit(limit); + + let resumed = 0; + for (const run of due) { + try { + const node = await db('workflow_nodes') + .where({ workflow_id: run.workflow_id, version: run.version, node_key: run.current_node }) + .first(); + if (node && node.type === 'wait') { + await resumeRun(run.id); + resumed += 1; + } + } catch (err) { + logger.error('[workflow] runDueWaits item failed', { runId: run.id, error: err.message }); + } + } + return resumed; + } catch (e) { + logger.error('[workflow] runDueWaits failed', { error: e.message }); + return 0; + } +} + +const RECOVERY_STALE_MS = 10 * 60 * 1000; // a 'running' run idle this long = orphaned by a crash +const MAX_RECOVERY_ATTEMPTS = 5; + +/** + * Resume runs orphaned by a crash. A run left in 'running'/'pending' has nothing + * to resume it (the scheduler only wakes 'waiting'), so this sweep picks up ones + * whose heartbeat (updated_at) has gone stale and re-enters them from their + * persisted node. Re-entry is at-least-once: the current node may re-execute — + * loop counters + the late-fee math are idempotent, so the only residual risk is + * a duplicate reminder email. `attempts` caps recovery so a node that reliably + * crashes the process is marked failed instead of looping forever. Flag-gated + * (fails closed when workflows is off). Called from the scheduler tick + boot. + */ +async function recoverStaleRuns({ staleMs = RECOVERY_STALE_MS, limit = 50 } = {}) { + try { + const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag'); + let enabled = false; + try { enabled = await isFeatureEnabled('workflows'); } catch (e) { return 0; } + if (!enabled) return 0; + if (!(await db.schema.hasColumn('workflow_runs', 'updated_at'))) return 0; + + const cutoff = new Date(Date.now() - staleMs).toISOString(); + const stale = await db('workflow_runs') + .whereIn('status', ['running', 'pending']) + .where('updated_at', '<=', cutoff) + .limit(limit); + + let recovered = 0; + for (const run of stale) { + try { + const attempts = Number(run.attempts) || 0; + if (attempts >= MAX_RECOVERY_ATTEMPTS) { + await failRun(run.id, `abandoned after ${attempts} recovery attempts (suspected crash loop)`); + continue; + } + await db('workflow_runs').where({ id: run.id }).update({ attempts: attempts + 1, updated_at: db.fn.now() }); + if (!run.current_node) { + await startRun(run.id); + } else { + await db('workflow_runs').where({ id: run.id }).update({ status: 'running', updated_at: db.fn.now() }); + await advanceRun(run.id); + } + recovered += 1; + } catch (err) { + logger.error('[workflow] recovery failed', { runId: run.id, error: err.message }); + } + } + return recovered; + } catch (e) { + logger.error('[workflow] recoverStaleRuns failed', { error: e.message }); + return 0; + } +} + +/** + * True when the workflows flag is on AND a built-in flow with this key is + * enabled. The hardcoded automations (reminder ladder, expiry emails, pre-event + * reminders) call this to STAND DOWN when their engine flow is live — so the + * engine and the legacy path never double-fire. Fails CLOSED (returns false) on + * any error so the legacy path keeps running if the workflow subsystem is down. + */ +async function isBuiltinFlowActive(builtinKey) { + try { + const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag'); + if (!(await isFeatureEnabled('workflows'))) return false; + if (!(await db.schema.hasTable('workflows'))) return false; + const wf = await db('workflows').where({ builtin_key: builtinKey, enabled: true }).first(); + return !!wf; + } catch (e) { + return false; + } +} + +/** + * Emit `event.date_approaching` for events entering an enabled flow's lead + * window. This is the trigger source for the pre-event reminder built-in, so it + * faithfully honours the same per-event controls the legacy eventReminderService + * pass uses (migration 143): skips `event_reminder_disabled` events, skips ones + * already sent (`event_reminder_sent_at`), and fires at `event_date − offset` + * where offset = the event's `event_reminder_offset_days` override else the + * flow's `daysBefore`. emitWorkflowEvent's per-(flow,entity) dedup_key keeps the + * hourly sweep to a single run per event. Fails CLOSED when the flag is off. + */ +async function emitDueEventReminders(limit = 200) { + try { + const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag'); + let enabled = false; + try { enabled = await isFeatureEnabled('workflows'); } catch (e) { return 0; } + if (!enabled) return 0; + if (!(await db.schema.hasTable('events'))) return 0; + + const flows = await db('workflows').where({ enabled: true, trigger_type: 'event.date_approaching' }); + if (!flows.length) return 0; + + const { hasColumnCached } = require('../../utils/schemaCache'); + const hasReminderCols = await hasColumnCached('events', 'event_reminder_sent_at'); + + // The admin heads-up resolves its recipient from ctx.vars.adminEmail; events + // don't carry one, so source it from the business profile (best-effort). + let adminEmail = null; + try { + if (await db.schema.hasTable('business_profile')) { + const profile = await db('business_profile').where({ id: 1 }).first(); + adminEmail = profile?.email || null; + } + } catch (_) { /* best-effort */ } + + const now = Date.now(); + const todayIso = new Date(now).toISOString().slice(0, 10); + let emitted = 0; + for (const wf of flows) { + const cfg = parseJson(wf.trigger_config, {}); + const daysBefore = Number(cfg.daysBefore) > 0 ? Number(cfg.daysBefore) : 3; + // Surface every still-upcoming event up to the widest the offset could be; + // the per-event triggerAt check below decides if it's actually due. + const maxOffset = Math.max(daysBefore, 60); + const windowEndIso = new Date(now + maxOffset * 86400000).toISOString().slice(0, 10); + + let q = db('events') + .where('is_active', true) + .where('is_archived', false) + .whereNotNull('event_date') + .where('event_date', '>=', todayIso) + .where('event_date', '<=', windowEndIso); + // Faithful to the legacy pass: never remind a disabled or already-sent event. + if (hasReminderCols) { + q = q.where('event_reminder_disabled', false).whereNull('event_reminder_sent_at'); + } + const events = await q.limit(limit); + + for (const ev of events) { + // A null/blank per-event offset means "use the flow's daysBefore" — guard + // against Number(null)===0 silently making the reminder fire on the event day. + const rawOffset = hasReminderCols ? ev.event_reminder_offset_days : null; + const offset = (rawOffset != null && rawOffset !== '' && Number.isFinite(Number(rawOffset))) + ? Number(rawOffset) + : daysBefore; + const ed = ev.event_date instanceof Date ? ev.event_date : new Date(ev.event_date); + const triggerAt = ed.getTime() - offset * 86400000; + if (now < triggerAt) continue; // not yet inside this event's lead window + + const runIds = await emitWorkflowEvent('event.date_approaching', { + entityType: 'event', + entityId: ev.id, + payload: { + eventId: ev.id, + eventName: ev.event_name || null, + eventDate: ev.event_date, + eventType: ev.event_type || null, + hostName: ev.host_name || null, + customerEmail: ev.customer_email || ev.host_email || null, + adminEmail, + daysBefore: offset, + }, + }); + emitted += runIds.length; + } + } + return emitted; + } catch (e) { + logger.error('[workflow] emitDueEventReminders failed', { error: e.message }); + return 0; + } +} + +/** + * Test-fire a workflow on demand (admin testing). Creates a run for the given + * entity/payload and starts it. Defaults to dryRun: side-effecting actions are + * mocked, waits pass through, and gates auto-take 'confirm' — so the WHOLE flow + * runs in one shot and the step log shows exactly what it would do, without + * sending real customer mail or charging fees. + */ +async function testRun(workflowId, { entityType = null, entityId = null, payload = {}, dryRun = true } = {}) { + const wf = await db('workflows').where({ id: workflowId }).first(); + if (!wf) throw new Error('Workflow not found'); + const vars = { ...(payload || {}), __test: true }; + if (dryRun) vars.__dryRun = true; + const dedupKey = `test:${workflowId}:${Date.now()}:${Math.round(Math.random() * 1e9)}`; + await db('workflow_runs').insert({ + workflow_id: wf.id, + version: wf.version, + trigger_event: `test:${wf.trigger_type}`, + entity_type: entityType, + entity_id: entityId, + status: 'pending', + context: JSON.stringify({ vars }), + dedup_key: dedupKey, + updated_at: db.fn.now(), + }); + const row = await db('workflow_runs').where({ dedup_key: dedupKey }).first(); + await startRun(row.id); + return row.id; +} + +module.exports = { + emitWorkflowEvent, + isBuiltinFlowActive, + runDueWaits, + emitDueEventReminders, + recoverStaleRuns, + testRun, + startRun, + advanceRun, + resumeRun, + finishRun, + failRun, + // exported for tests / introspection + loadGraph, + outEdge, + computeWakeAt, +}; diff --git a/backend/src/services/workflows/index.js b/backend/src/services/workflows/index.js new file mode 100644 index 00000000..e41742bf --- /dev/null +++ b/backend/src/services/workflows/index.js @@ -0,0 +1,22 @@ +/** + * Workflow engine public surface. + * + * const { emitWorkflowEvent } = require('./services/workflows'); + * + * `engine` holds the executor (start/advance/resume), `registry` the catalog + * of conditions/actions. Action/condition handler modules require `registry` + * and call registerAction/registerCondition at load time. + */ +const engine = require('./engine'); +const registry = require('./registry'); +// Side-effect import: registers the data-touching action/condition handlers +// (send_email, invoice_paid, prepare_* document actions) onto the registry. +require('./actions'); +// Registers the gate_setup action + exposes approval helpers. +const approvals = require('./approvals'); + +module.exports = { + ...engine, + ...approvals, + registry, +}; diff --git a/backend/src/services/workflows/registry.js b/backend/src/services/workflows/registry.js new file mode 100644 index 00000000..ae21ca87 --- /dev/null +++ b/backend/src/services/workflows/registry.js @@ -0,0 +1,61 @@ +/** + * Workflow registry — the curated catalog of CONDITIONS and ACTIONS the engine + * can run. Node `config.condition` / `config.action` keys map to handlers here. + * + * Handlers are async `(ctx) => result`, where ctx = { run, node, vars, db, + * logger }. `vars` is the run's mutable context bag (loop counters, accumulated + * values, the trigger payload). A condition returns a boolean; an action may + * return `{ set: {...} }` to merge values back into `vars`. + * + * Keep handlers curated and typed — this is NOT arbitrary code execution. New + * triggers/actions register here; the canvas palette is derived from these. + */ +const conditions = new Map(); +const actions = new Map(); + +function registerCondition(key, fn) { conditions.set(key, fn); } +function registerAction(key, fn) { actions.set(key, fn); } +function getCondition(key) { return conditions.get(key); } +function getAction(key) { return actions.get(key); } +function listConditions() { return Array.from(conditions.keys()); } +function listActions() { return Array.from(actions.keys()); } + +// --- Primitive conditions --- +registerCondition('always', async () => true); +registerCondition('never', async () => false); +// Generic field/op/value compare against the run's `vars` bag. +registerCondition('expr', async (ctx) => { + const { field, op = 'truthy', value } = ctx.node.config || {}; + const actual = field != null ? ctx.vars[field] : undefined; + switch (op) { + case 'eq': return actual == value; // eslint-disable-line eqeqeq + case 'neq': return actual != value; // eslint-disable-line eqeqeq + case 'gt': return Number(actual) > Number(value); + case 'gte': return Number(actual) >= Number(value); + case 'lt': return Number(actual) < Number(value); + case 'lte': return Number(actual) <= Number(value); + case 'falsy': return !actual; + case 'truthy': + default: return Boolean(actual); + } +}); + +// --- Primitive actions --- +registerAction('noop', async () => ({})); +registerAction('log', async (ctx) => { + ctx.logger?.info?.('[workflow] log action', { runId: ctx.run.id, message: ctx.node.config?.message }); + return { logged: true }; +}); +// Merge a static object into the run context (handy for tests + seeding flags). +registerAction('set_context', async (ctx) => ({ set: ctx.node.config?.set || {} })); + +module.exports = { + registerCondition, + registerAction, + getCondition, + getAction, + listConditions, + listActions, + conditions, + actions, +}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9aac8719..2287d0c4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,13 +1,14 @@ { "name": "picpeak-frontend", - "version": "3.47.2-beta.0", + "version": "3.69.0-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "picpeak-frontend", - "version": "3.47.2-beta.0", + "version": "3.69.0-beta.0", "dependencies": { + "@dagrejs/dagre": "^3.0.0", "@fullcalendar/core": "^6.1.20", "@fullcalendar/daygrid": "^6.1.20", "@fullcalendar/interaction": "^6.1.20", @@ -25,6 +26,7 @@ "@types/dompurify": "^3.0.5", "@types/lodash": "^4.17.20", "@types/react-google-recaptcha": "^2.1.9", + "@xyflow/react": "^12.11.1", "axios": "1.15.2", "clsx": "^2.0.0", "date-fns": "4.1.0", @@ -548,6 +550,21 @@ "node": ">=18" } }, + "node_modules/@dagrejs/dagre": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.0.0.tgz", + "integrity": "sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "4.0.1" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-4.0.1.tgz", + "integrity": "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==", + "license": "MIT" + }, "node_modules/@epic-web/invariant": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", @@ -3460,6 +3477,55 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -3996,6 +4062,48 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xyflow/react": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.1.tgz", + "integrity": "sha512-L+zBoLGSXham0MnlY8QqjfR7/C5JNw0zxkaey5aZ5XmCgJBAdH4+WRIu8CR40d3l/BdU635V6YbhBK1jMo8/6Q==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.78", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.78", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.78.tgz", + "integrity": "sha512-lY0z2qP33fUhTva9Vaxrk0lqZta2pkbxB1trHAx1omnJqRtPvDlAQYV2r5fhS6AdpkulYmbNW0svy+A4/t4B/g==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -4435,6 +4543,12 @@ "node": ">= 6" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -4634,6 +4748,111 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", @@ -9297,6 +9516,34 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/frontend/package.json b/frontend/package.json index e7ef4266..6582e5eb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "i18n:ci": "i18next-cli extract --ci --dry-run" }, "dependencies": { + "@dagrejs/dagre": "^3.0.0", "@fullcalendar/core": "^6.1.20", "@fullcalendar/daygrid": "^6.1.20", "@fullcalendar/interaction": "^6.1.20", @@ -32,6 +33,7 @@ "@types/dompurify": "^3.0.5", "@types/lodash": "^4.17.20", "@types/react-google-recaptcha": "^2.1.9", + "@xyflow/react": "^12.11.1", "axios": "1.15.2", "clsx": "^2.0.0", "date-fns": "4.1.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 55ae58fa..6aa17d84 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -46,6 +46,9 @@ import { QuoteResponsePage } from './pages/public/QuoteResponsePage'; import { ContractResponsePage } from './pages/public/ContractResponsePage'; import { ProjectsListPage } from './pages/admin/projects/ProjectsListPage'; import { ProjectCockpitPage } from './pages/admin/projects/ProjectCockpitPage'; +import { WorkflowsListPage } from './pages/admin/workflows/WorkflowsListPage'; +import { WorkflowApprovalsPage } from './pages/admin/workflows/WorkflowApprovalsPage'; +import { WorkflowEditorPage } from './pages/admin/workflows/WorkflowEditorPage'; import { ContractsListPage } from './pages/admin/contracts/ContractsListPage'; import { ContractEditorPage } from './pages/admin/contracts/ContractEditorPage'; import { ContractDetailPage } from './pages/admin/contracts/ContractDetailPage'; @@ -347,6 +350,14 @@ function App() { } /> } /> + {/* Workflows (automation engine) — top-level area gated + by the `workflows` flag. */} + }> + } /> + } /> + } /> + + } /> } /> } /> diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx index 2dbd26d0..6a18cd09 100644 --- a/frontend/src/components/admin/AdminSidebar.tsx +++ b/frontend/src/components/admin/AdminSidebar.tsx @@ -11,6 +11,7 @@ import { Users, Briefcase, Landmark, + Workflow, PanelLeftClose, PanelLeftOpen, } from 'lucide-react'; @@ -110,6 +111,12 @@ const navigation: NavItem[] = [ permission: 'accounting.view', featureFlag: 'accounting', }, + // Workflows (automation engine) — top-level, gated by the `workflows` flag. + { + nameKey: 'navigation.workflows', href: '/admin/workflows', icon: Workflow, + permission: 'workflows.view', + featureFlag: 'workflows', + }, ]; export const AdminSidebar: React.FC = ({ isOpen, onClose, collapsed = false, onToggleCollapse }) => { diff --git a/frontend/src/contexts/FeatureFlagsContext.tsx b/frontend/src/contexts/FeatureFlagsContext.tsx index c97d4c1b..218d81d2 100644 --- a/frontend/src/contexts/FeatureFlagsContext.tsx +++ b/frontend/src/contexts/FeatureFlagsContext.tsx @@ -64,6 +64,9 @@ export const DEFAULT_FLAGS: FeatureFlags = { whatsapp: false, // Live Slideshow ("Diashow") — opt-in; gates all slideshow admin UI. slideshow: false, + // Workflow / automation engine — opt-in; gates the Workflows admin area + // and the engine runtime (triggers/actions/gates). + workflows: false, }; export const FEATURE_FLAGS_QUERY_KEY = ['feature-flags'] as const; diff --git a/frontend/src/features/settings/tabs/FeaturesTab.tsx b/frontend/src/features/settings/tabs/FeaturesTab.tsx index 5816bb3f..9434b923 100644 --- a/frontend/src/features/settings/tabs/FeaturesTab.tsx +++ b/frontend/src/features/settings/tabs/FeaturesTab.tsx @@ -23,6 +23,7 @@ import { Wallet, FolderKanban, MonitorPlay, + Workflow, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Button, Card } from '../../../components/common'; @@ -134,6 +135,24 @@ export const FeaturesTab: React.FC = () => { /> + {/* Automation — the visual workflow engine. Master kill-switch for the + Workflows admin area and the runtime; off by default. */} +
+ setFlag('workflows', next)} + /> +
+ {/* Clients (#354 follow-up). Visual grouping for the CRM-area sub-features. The "Clients" sidebar section itself is gated by a derived `clients` flag (computed from whether any diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index f1a84d4e..9f5de7c5 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -199,8 +199,96 @@ "calendar": "Kalender", "clients": "CRM", "accounting": "Buchhaltung", + "workflows": "Workflows", "betaTag": "Beta" }, + "workflows": { + "title": "Workflows", + "subtitle": "Visuelle Automatisierungen – Auslöser, Bedingungen, Freigaben und Aktionen.", + "new": "Neuer Workflow", + "empty": "Noch keine Workflows. Erstelle einen, um Rechnungsstellung und Buchungsschritte zu automatisieren.", + "builtin": "integriert", + "triggerLabel": "Auslöser", + "enabled": "Aktiv", + "disabled": "Inaktiv", + "confirmDelete": "Diesen Workflow löschen?", + "toggle": { + "confirmDisableBuiltin": "Das Deaktivieren dieses eingebauten Ablaufs stellt das vorherige Standardverhalten wieder her – die Automatisierung wird dadurch nicht abgeschaltet. Fortfahren?" + }, + "test": { + "title": "Testlauf", + "hint": "Probelauf: durchläuft den ganzen Ablauf sofort (Wartezeiten übersprungen, Gates automatisch bestätigt), Nebeneffekte werden nur simuliert – keine echten E-Mails. Optional eine Entitäts-ID (z. B. eine Rechnung) angeben, damit Bedingungen sie lesen können.", + "entityId": "Entitäts-ID (optional, z. B. Rechnungs-ID)", + "run": "Probelauf starten", + "result": "Ergebnis", + "failed": "Testlauf fehlgeschlagen" + }, + "toast": { + "createFailed": "Workflow konnte nicht erstellt werden", + "deleted": "Workflow gelöscht", + "deleteFailed": "Workflow konnte nicht gelöscht werden" + }, + "approvals": { + "title": "Freigaben", + "subtitle": "Workflow-Durchläufe, die auf deine Bestätigung warten.", + "empty": "Aktuell wartet nichts auf dich.", + "confirm": "Bestätigen", + "deny": "Ablehnen", + "recorded": "Antwort gespeichert", + "defaultPrompt": "Ein Workflow benötigt deine Bestätigung.", + "pendingTitle": "Offene Freigaben", + "viewAll": "Alle Freigaben ansehen", + "acted": "Erledigt" + }, + "editor": { + "namePlaceholder": "Workflow-Name", + "when": "Wenn", + "cleanUp": "Layout aufräumen", + "textView": "Text", + "canvasView": "Canvas", + "loadText": "In Editor laden", + "textLoaded": "Geladen – prüfen und speichern", + "textNeedsArrays": "Benötigt „nodes“- und „edges“-Arrays", + "textNeedsTrigger": "Benötigt genau einen Trigger-Knoten", + "textHint": "Der gesamte Ablauf als JSON – zum Teilen kopieren oder an ein LLM geben, oder einen Ablauf einfügen und in den Editor laden. Nach dem Import „Layout aufräumen“ klicken.", + "saved": "Workflow gespeichert", + "saveFailed": "Speichern fehlgeschlagen", + "badJson": "Konfiguration ist kein gültiges JSON", + "daysBefore": "Tage vor dem Anlass", + "templateGroup": "Vorlagengruppe für Erinnerungen", + "templateGroupHint": "Die genaue Vorlage wird je Anlasstyp innerhalb dieser Gruppe automatisch gewählt: «Gruppe»_«Anlasstyp», falls vorhanden, sonst «Gruppe»_default. Leer = event_reminder.", + "showAdvanced": "Erweitert (JSON)", + "hideAdvanced": "Erweitert ausblenden (JSON)", + "triggerHint": "Der Auslöser wird oben in der Leiste gesetzt (Wenn …).", + "actionLabel": "Aktion", + "recipient": "Empfänger", + "recipientCustomer": "Kunde (berücksichtigt Geschäftszeiten)", + "recipientAdmin": "Admin (sofort gesendet)", + "emailTemplate": "E-Mail-Vorlagenschlüssel", + "webhookUrl": "Webhook-URL", + "webhookTarget": "Webhook", + "webhookNone": "— Konfigurierten Webhook wählen —", + "webhookInactive": "(inaktiv)", + "webhookHint": "Wird über die Webhook-Pipeline zugestellt (Signatur, Wiederholungen, SSRF-Prüfungen). Endpunkte unter Einstellungen → Webhooks verwalten.", + "condition": "Bedingung", + "exprField": "Feld", + "exprOp": "Operator", + "exprValue": "Wert", + "conditionHint": "Führt bei „wahr“ zur „yes“-Kante, sonst zur „no“-Kante.", + "waitType": "Warten", + "waitUntil": "Bis zu einem Datum", + "waitDelay": "Feste Verzögerung", + "waitAnchor": "Warten bis", + "days": "Tage", + "hours": "Stunden", + "minutes": "Min", + "maxIterations": "Höchstens wiederholen (mal)", + "gatePrompt": "Frage an den Admin", + "gatePromptPh": "z. B. Keine Zahlung erhalten – Mahnung senden?", + "gateTimeout": "Automatisch ablaufen nach (Tagen, optional)", + "gateHint": "Sendet dem Admin einen Bestätigen/Ablehnen-Link; führt zur „confirm“- oder „deny“-Kante." + } + }, "eventTypes": { "title": "Veranstaltungsarten", "subtitle": "Veranstaltungsarten und deren Standard-Themes anpassen", @@ -1683,7 +1771,8 @@ "accounting": "Buchhaltung", "insights": "Auswertungen & Zugriff", "customers": "Kunden", - "clients": "CRM" + "clients": "CRM", + "automation": "Automatisierung" }, "status": { "stable": "stabil", @@ -1799,6 +1888,11 @@ "slideshow": { "title": "Live-Diashow", "description": "Ein separater Vollbild-„Diashow“-Link pro Event für Beamer bei Live-Events – übernimmt neue Uploads automatisch, mit Voreinstellungen je Event-Typ und globalen Wasserzeichen-Vorgaben unter Einstellungen → Diashow." + }, + "workflows": { + "title": "Workflows", + "description": "Visuelle Automatisierungen auf einer Canvas erstellen – Auslöser, Bedingungen, Verzweigungen, Schleifen und Freigabe-Gates für Admins. Deine Mahnstufen und Buchungsschritte werden zu bearbeitbaren Abläufen. Strikt optional.", + "sidebar": "Workflows" } }, "customerSurface": { @@ -4845,6 +4939,25 @@ "crm_invoices_late_fee_minor": { "label": "Mahngebühr (Rappen / Cent)" }, + "crm_invoices_late_fee_type": { + "label": "Art der Mahngebühr" + }, + "crm_invoices_late_fee_percent": { + "label": "Mahngebühr (% der Rechnung)" + }, + "lateFeeType": { + "flat": "Fester Betrag (Rappen)", + "percent": "Prozentsatz der Rechnung" + }, + "lateFeeAgb": { + "title": "Mahngebühren müssen in den AGB stehen", + "body": "Vertragliche Pflicht: Sätze wie „Es werden Mahnspesen erhoben“ reichen nicht aus. In den AGB muss die konkrete Gebühr klar beziffert sein (z.B. „CHF 20 ab der 2. Mahnung“). Mit dem Treuhänder prüfen." + }, + "dunningMoved": { + "title": "Der Mahnrhythmus liegt jetzt in den Workflows", + "body": "Wann und wie oft Zahlungserinnerungen für überfällige Rechnungen verschickt werden, wird im Workflow „Rechnungsmahnung“ festgelegt. Die Mahngebühren unten gelten weiterhin.", + "link": "Workflows öffnen" + }, "crm_invoices_late_fee_label": { "label": "Bezeichnung Mahngebühr" }, @@ -4879,7 +4992,7 @@ "label": "Automatische Mahnungen aktivieren" }, "crm_invoices_late_fee_enabled": { - "label": "Mahngebühr aktivieren" + "label": "Mahngebühr ab der 2. Mahnung (jede weitere Mahnung)" }, "crm_quotes_tos_required": { "label": "Kunden müssen „Ich akzeptiere die AGB“ ankreuzen, bevor sie annehmen können" @@ -4948,6 +5061,11 @@ }, "reminderTemplates": { "title": "Erinnerungs-E-Mails vor dem Anlass", + "scheduleMoved": { + "title": "Der Versandzeitpunkt liegt jetzt in den Workflows", + "body": "Ob Erinnerungen vor dem Anlass verschickt werden und wie viele Tage vorher, wird im Workflow „Erinnerung vor dem Anlass“ festgelegt. Auf dieser Seite werden die E-Mail-Vorlagen bearbeitet; anlassspezifische Überschreibungen bleiben auf der jeweiligen Anlass-Detailseite.", + "link": "Workflows öffnen" + }, "globalSection": "Globales Verhalten", "globalHelp": "Standardmässig aus — aktiviere, um Erinnerungen zu senden. Der unten gesetzte Offset ist der Standard; jeder Anlass kann ihn auf der Detailseite überschreiben.", "enableLabel": "Erinnerungs-E-Mails vor dem Anlass senden", @@ -5144,6 +5262,13 @@ "eventHelp": "Wird auf den Vertrag übernommen und an jeden daraus erzeugten Anlass / jede Rechnung weitergegeben. Setzen Sie dies, damit Kundenportal und Mahn-E-Mails die richtige Bezeichnung \"Hochzeit Doe / Müller\" anzeigen.", "eventName": "Anlassname", "eventNamePlaceholder": "z. B. Hochzeit Doe / Müller", + "eventType": "Anlasstyp", + "eventTypeNone": "— Standard verwenden —", + "eventTypeHint": "Wird für den Anlass verwendet, der bei Annahme dieses Angebots erstellt wird.", + "bookingWorkflow": "Buchungs-Workflow (bei Annahme)", + "bookingWorkflowNone": "— Keiner —", + "bookingWorkflowDisabled": "(deaktiviert)", + "bookingWorkflowHint": "Der Ablauf, der startet, wenn die Kundin/der Kunde annimmt. „Keiner“ = kein Buchungsablauf. Der Ablauf muss aktiviert sein, um zu starten.", "eventSection": "Anlass (optional)", "eventTimeEnd": "Ende", "eventTimeStart": "Beginn", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index a1d09a29..8c7c51f9 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -199,8 +199,96 @@ "calendar": "Calendar", "clients": "CRM", "accounting": "Accounting", + "workflows": "Workflows", "betaTag": "Beta" }, + "workflows": { + "title": "Workflows", + "subtitle": "Visual automations — triggers, conditions, gates and actions.", + "new": "New workflow", + "empty": "No workflows yet. Create one to automate your invoicing and booking steps.", + "builtin": "built-in", + "triggerLabel": "Trigger", + "enabled": "Enabled", + "disabled": "Disabled", + "confirmDelete": "Delete this workflow?", + "toggle": { + "confirmDisableBuiltin": "Disabling this built-in reverts to the previous built-in behaviour — it does not turn the automation off. Continue?" + }, + "test": { + "title": "Test run", + "hint": "Dry run: walks the whole flow now (waits skipped, gates auto-confirmed) with side effects mocked — no real emails. Optionally give an entity id (e.g. an invoice) so conditions can read it.", + "entityId": "Entity id (optional, e.g. invoice id)", + "run": "Run dry test", + "result": "Result", + "failed": "Test run failed" + }, + "toast": { + "createFailed": "Could not create workflow", + "deleted": "Workflow deleted", + "deleteFailed": "Could not delete workflow" + }, + "approvals": { + "title": "Approvals", + "subtitle": "Workflow runs waiting on your confirmation.", + "empty": "Nothing waiting for you right now.", + "confirm": "Confirm", + "deny": "Deny", + "recorded": "Response recorded", + "defaultPrompt": "A workflow needs your confirmation.", + "pendingTitle": "Pending approvals", + "viewAll": "View all approvals", + "acted": "Done" + }, + "editor": { + "namePlaceholder": "Workflow name", + "when": "When", + "cleanUp": "Clean up layout", + "textView": "Text", + "canvasView": "Canvas", + "loadText": "Load into editor", + "textLoaded": "Loaded — review and Save", + "textNeedsArrays": "Needs \"nodes\" and \"edges\" arrays", + "textNeedsTrigger": "Needs exactly one trigger node", + "textHint": "The whole flow as JSON — copy it to share or hand to an LLM, or paste a flow and load it into the editor. Click “Clean up layout” after importing.", + "saved": "Workflow saved", + "saveFailed": "Could not save", + "badJson": "Config is not valid JSON", + "daysBefore": "days before event", + "templateGroup": "Reminder template group", + "templateGroupHint": "The exact template is auto-picked per event type within this group: «group»_«eventType» if you authored one, else «group»_default. Blank = event_reminder.", + "showAdvanced": "Advanced (JSON)", + "hideAdvanced": "Hide advanced (JSON)", + "triggerHint": "The trigger is set in the toolbar above (When …).", + "actionLabel": "Action", + "recipient": "Recipient", + "recipientCustomer": "Customer (respects business hours)", + "recipientAdmin": "Admin (sent immediately)", + "emailTemplate": "Email template key", + "webhookUrl": "Webhook URL", + "webhookTarget": "Webhook", + "webhookNone": "— Select a configured webhook —", + "webhookInactive": "(inactive)", + "webhookHint": "Delivered via the webhook pipeline (signing, retries, SSRF checks). Manage endpoints in Settings → Webhooks.", + "condition": "Condition", + "exprField": "Field", + "exprOp": "Operator", + "exprValue": "Value", + "conditionHint": "Routes to the “yes” edge when true, “no” when false.", + "waitType": "Wait", + "waitUntil": "Until a date", + "waitDelay": "A fixed delay", + "waitAnchor": "Wait until", + "days": "Days", + "hours": "Hours", + "minutes": "Min", + "maxIterations": "Repeat at most (times)", + "gatePrompt": "Question for the admin", + "gatePromptPh": "e.g. No payment received — send a reminder?", + "gateTimeout": "Auto-expire after (days, optional)", + "gateHint": "Emails the admin a confirm/deny link; routes to the “confirm” or “deny” edge." + } + }, "archives": { "title": "Archives", "subtitle": "Manage archived photo galleries", @@ -1239,7 +1327,8 @@ "accounting": "Accounting", "insights": "Insights & Access", "customers": "Customers", - "clients": "CRM" + "clients": "CRM", + "automation": "Automation" }, "status": { "stable": "stable", @@ -1355,6 +1444,11 @@ "slideshow": { "title": "Live Slideshow", "description": "A separate fullscreen \"Diashow\" link per event for projectors at live events — auto-picks-up new uploads, with per-event-type presets and global watermark defaults under Settings → Slideshow." + }, + "workflows": { + "title": "Workflows", + "description": "Build visual automations on a canvas — triggers, conditions, branches, loops and admin approval gates. Your reminder ladder and booking steps become editable flows. Strictly opt-in.", + "sidebar": "Workflows" } }, "customerSurface": { @@ -4843,6 +4937,25 @@ "crm_invoices_late_fee_minor": { "label": "Late fee (minor units / Rappen)" }, + "crm_invoices_late_fee_type": { + "label": "Late fee type" + }, + "crm_invoices_late_fee_percent": { + "label": "Late fee (% of invoice)" + }, + "lateFeeType": { + "flat": "Flat amount (Rappen)", + "percent": "Percentage of invoice" + }, + "lateFeeAgb": { + "title": "Late fees must be itemised in your terms (AGB)", + "body": "A contractual duty: phrases like “late fees apply” aren't enough. Your terms must state the concrete fee (e.g. “CHF 20 from the 2nd reminder”). Verify with your Treuhänder." + }, + "dunningMoved": { + "title": "Reminder schedule is now in Workflows", + "body": "When and how often overdue reminders go out is configured in the “Invoice dunning” workflow. The late-fee amounts below still apply.", + "link": "Open Workflows" + }, "crm_invoices_late_fee_label": { "label": "Late fee label" }, @@ -4877,7 +4990,7 @@ "label": "Send automatic reminders for overdue invoices" }, "crm_invoices_late_fee_enabled": { - "label": "Add a late fee on the second reminder" + "label": "Add a late fee on every reminder after the first" }, "crm_quotes_tos_required": { "label": "Require customers to tick \"I accept the Terms of Service\" before accepting" @@ -4946,6 +5059,11 @@ }, "reminderTemplates": { "title": "Pre-event reminder emails", + "scheduleMoved": { + "title": "The reminder schedule is now in Workflows", + "body": "Whether pre-event reminders are sent, and how many days before the event, is configured in the “Pre-event reminder” workflow. This page edits the email templates; per-event overrides stay on each event’s detail page.", + "link": "Open Workflows" + }, "globalSection": "Global behaviour", "globalHelp": "Off by default — turn on to start sending pre-event reminders. The offset below is the default; each event can override on its detail page.", "enableLabel": "Send pre-event reminder emails", @@ -5142,6 +5260,13 @@ "eventHelp": "Snapshotted onto the contract and propagated to any event / invoice generated from it. Set this so the customer portal and dunning emails show the right \"Wedding Doe / Müller\" label.", "eventName": "Event name", "eventNamePlaceholder": "e.g. Wedding Doe / Müller", + "eventType": "Event type", + "eventTypeNone": "— Use default —", + "eventTypeHint": "Used for the event created when this quote is accepted.", + "bookingWorkflow": "Booking workflow (on acceptance)", + "bookingWorkflowNone": "— None —", + "bookingWorkflowDisabled": "(disabled)", + "bookingWorkflowHint": "The flow that runs when the customer accepts. Leave as None to run no booking flow. The flow must be enabled to fire.", "eventSection": "Event (optional)", "eventTimeEnd": "End", "eventTimeStart": "Start", diff --git a/frontend/src/pages/admin/AdminDashboard.tsx b/frontend/src/pages/admin/AdminDashboard.tsx index 4fd0ee0f..142dc4f3 100644 --- a/frontend/src/pages/admin/AdminDashboard.tsx +++ b/frontend/src/pages/admin/AdminDashboard.tsx @@ -10,18 +10,24 @@ import { HardDrive, Image, Archive, - Heart + Heart, + Inbox, + Check, + X } from 'lucide-react'; import { differenceInDays, parseISO } from 'date-fns'; import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { Button, Card, Loading } from '../../components/common'; import { UpdateNotification } from '../../components/admin/UpdateNotification'; import { CrmOverviewSection } from '../../components/admin/CrmOverviewSection'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { eventsService } from '../../services/events.service'; import { adminService, ActivityType } from '../../services/admin.service'; +import { workflowsService } from '../../services/workflows.service'; +import { useFeatureFlags } from '../../contexts/FeatureFlagsContext'; interface StatCard { title: string; @@ -72,6 +78,24 @@ export const AdminDashboard: React.FC = () => { queryFn: () => eventsService.getEvents(1, 5, 'expiring'), }); + // Pending workflow approvals — only when the workflow engine is live. These + // are the human-in-the-loop gates (e.g. "review invoice before sending"). + const { flags } = useFeatureFlags(); + const qc = useQueryClient(); + const { data: pendingApprovals } = useQuery({ + queryKey: ['workflow-approvals'], + queryFn: () => workflowsService.approvals(), + enabled: !!flags.workflows, + }); + const approvalMutation = useMutation({ + mutationFn: ({ id, action }: { id: number; action: 'confirm' | 'deny' }) => workflowsService.actApproval(id, action), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['workflow-approvals'] }); + toast.success(t('workflows.approvals.acted', 'Done') as string); + }, + onError: (err: any) => toast.error(err?.response?.data?.error || (t('common.error', 'Something went wrong') as string)), + }); + const isLoading = statsLoading || eventsLoading; if (isLoading) { @@ -241,6 +265,52 @@ export const AdminDashboard: React.FC = () => { )} + + {/* Pending workflow approvals — the human-in-the-loop gates. Only + rendered when the workflow engine is live and something is waiting. */} + {!!flags.workflows && pendingApprovals && pendingApprovals.length > 0 && ( + +
+

{t('workflows.approvals.pendingTitle', 'Pending approvals')}

+ +
+
+ {pendingApprovals.slice(0, 5).map((a) => { + const prompt = (a.payload as any)?.prompt as string | undefined; + return ( +
+
+

{a.workflow_name}

+

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

+
+
+ + +
+
+ ); + })} +
+ {pendingApprovals.length > 5 && ( + + )} +
+ )} {/* Recent Activity */} diff --git a/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx b/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx index 4e3ac177..cea8f4b6 100644 --- a/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx +++ b/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx @@ -28,6 +28,9 @@ import { ProjectSelect } from '../../../components/admin/ProjectSelect'; import { VatRateSelect } from '../../../components/admin/VatRateSelect'; import { accountingService } from '../../../services/accounting.service'; import { vatCodesService } from '../../../services/vatCodes.service'; +import { eventTypesService } from '../../../services/eventTypes.service'; +import { workflowsService } from '../../../services/workflows.service'; +import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext'; import { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel'; import { customerAdminService } from '../../../services/customerAdmin.service'; import { userManagementService } from '../../../services/userManagement.service'; @@ -47,6 +50,8 @@ interface FormState { validUntil: string; eventName: string; eventDate: string; + eventType: string; + bookingWorkflowId: number | null; eventTimeStart: string; eventTimeEnd: string; expectedDurationHours: string; @@ -83,6 +88,8 @@ const empty: FormState = { validUntil: '', eventName: '', eventDate: '', + eventType: '', + bookingWorkflowId: null, eventTimeStart: '', eventTimeEnd: '', expectedDurationHours: '', @@ -115,6 +122,8 @@ function buildPayload(f: FormState): QuoteCreatePayload { validUntil: f.validUntil || undefined, eventName: f.eventName || undefined, eventDate: f.eventDate || undefined, + eventType: f.eventType || null, + bookingWorkflowId: f.bookingWorkflowId, eventTimeStart: f.eventTimeStart || undefined, eventTimeEnd: f.eventTimeEnd || undefined, expectedDurationHours: f.expectedDurationHours ? Number(f.expectedDurationHours) : undefined, @@ -197,6 +206,22 @@ export const QuoteEditorPage: React.FC = () => { // convert to) don't silently start at 0%. Never clobbers a touched value. const { data: acctSettings } = useQuery({ queryKey: ['accounting-settings'], queryFn: () => accountingService.getSettings() }); const { data: outputVatCodes } = useQuery({ queryKey: ['vat-codes', 'output'], queryFn: () => vatCodesService.listOutput() }); + // Active event types — drives the event-type dropdown (and the type of the + // event this quote converts into). + const { data: eventTypes = [] } = useQuery({ queryKey: ['event-types-active'], queryFn: () => eventTypesService.getActiveEventTypes() }); + // Booking-workflow picker: the flows that run on quote acceptance. Only shown + // when the workflow engine is live. + const { flags } = useFeatureFlags(); + const workflowsLive = !!flags.workflows; + const { data: allWorkflows = [] } = useQuery({ + queryKey: ['workflows'], + queryFn: () => workflowsService.list(), + enabled: workflowsLive, + }); + const bookingWorkflows = useMemo( + () => allWorkflows.filter((w) => w.trigger_type === 'quote.accepted'), + [allWorkflows], + ); const didSeedVatRef = useRef(false); useEffect(() => { if (isEdit || didSeedVatRef.current) return; @@ -231,6 +256,8 @@ export const QuoteEditorPage: React.FC = () => { validUntil: q.validUntil || '', eventName: q.eventName || '', eventDate: q.eventDate || '', + eventType: q.eventType || '', + bookingWorkflowId: q.bookingWorkflowId ?? null, eventTimeStart: q.eventTimeStart || '', eventTimeEnd: q.eventTimeEnd || '', expectedDurationHours: q.expectedDurationHours?.toString() || '', @@ -531,6 +558,46 @@ export const QuoteEditorPage: React.FC = () => {
setForm((f) => ({ ...f, eventName: e.target.value }))} /> +
+ + +

+ {t('quotes.field.eventTypeHint', 'Used for the event created when this quote is accepted.')} +

+
+ {workflowsLive && ( +
+ + +

+ {t('quotes.field.bookingWorkflowHint', 'The flow that runs when the customer accepts. Leave as None to run no booking flow. The flow must be enabled to fire.')} +

+
+ )} setForm((f) => ({ ...f, eventDate: iso }))} /> { // invoice-pipeline + revenue tiles, all of which are CRM-money). const showQuotes = !!flags.quotes; const showInvoices = !!flags.bills; + // When the workflow engine is live, reminder TIMING is owned by the Invoice + // dunning flow — show a pointer instead of the legacy schedule controls. When + // it's off, the legacy reminder ladder still runs, so keep its controls. + const workflowsLive = !!flags.workflows; const showContracts = !!flags.contracts; const showDashboardOverview = !!(flags.quotes || flags.bills); const anySection = showQuotes || showInvoices || showContracts || showDashboardOverview; @@ -241,21 +249,69 @@ export const CrmSettingsPage: React.FC = () => {

{t('crmSettings.section.invoices', 'Invoices')}

{checkbox('crm_invoices_qr_enabled', 'Render payment QR on invoice PDFs')} - {checkbox('crm_invoices_reminders_enabled', 'Send automatic reminders for overdue invoices')} - {checkbox('crm_invoices_late_fee_enabled', 'Add a late fee on the second reminder')} + + {/* Reminder TIMING: owned by the Invoice dunning workflow when the + engine is live (callout); otherwise the legacy schedule controls. The + late-fee math below is configured here in both cases — it's the fee + the dunning path applies, not part of the schedule. */} + {workflowsLive ? ( +
+ +
+

{t('crmSettings.dunningMoved.title', 'Reminder schedule is now in Workflows')}

+

+ {t('crmSettings.dunningMoved.body', 'When and how often overdue reminders go out is configured in the “Invoice dunning” workflow. Late-fee amounts below still apply.')}{' '} + {t('crmSettings.dunningMoved.link', 'Open Workflows')} +

+
+
+ ) : ( + checkbox('crm_invoices_reminders_enabled', 'Send automatic reminders for overdue invoices') + )} + + {checkbox('crm_invoices_late_fee_enabled', 'Add a late fee (Mahngebühr) on every reminder after the first')} +
+

{t('crmSettings.lateFeeAgb.title', 'Late fees must be itemised in your terms (AGB)')}

+

{t('crmSettings.lateFeeAgb.body', 'Vertragliche Pflicht: Sätze wie „Es werden Mahnspesen erhoben“ reichen nicht aus. In den AGB muss die konkrete Gebühr klar beziffert sein (z.B. „CHF 20 ab der 2. Mahnung“). Mit dem Treuhänder prüfen.')}

+
+ {checkbox('crm_invoices_late_fee_vat_enabled', 'Charge VAT on late fees (Switzerland — leave off for DE/AT; no effect if your organisation has no VAT rate)')}
- setVal('crm_invoices_reminder_first_days', Number(e.target.value))} /> - setVal('crm_invoices_reminder_second_days', Number(e.target.value))} /> - setVal('crm_invoices_late_fee_minor', Number(e.target.value))} /> + {!workflowsLive && ( + <> + setVal('crm_invoices_reminder_first_days', Number(e.target.value))} /> + setVal('crm_invoices_reminder_second_days', Number(e.target.value))} /> + + )} +
+ + +
+ {(values.crm_invoices_late_fee_type ?? 'flat') === 'percent' ? ( + setVal('crm_invoices_late_fee_percent', Number(e.target.value))} /> + ) : ( + setVal('crm_invoices_late_fee_minor', Number(e.target.value))} /> + )} { const { t } = useTranslation(); const queryClient = useQueryClient(); - // ---- Global toggles --------------------------------------------------- - // Only two keys are read on this page — fetch just those instead of - // the full ~100-row app_settings dict. Saves transferring + parsing - // every unrelated setting. + // Global on/off + lead time: owned by the "Pre-event reminder" workflow when + // the engine is live; otherwise the legacy crm_event_reminders_* settings drive + // the hourly pass, so we keep their controls. Per-event override (disable / + // offset / custom body) always lives on the event detail page. + const { flags } = useFeatureFlags(); + const workflowsLive = !!flags.workflows; + const { data: settings } = useQuery({ queryKey: ['reminder-settings'], queryFn: () => settingsService.getSettings([ 'crm_event_reminders_enabled', 'crm_event_reminders_days_before', ]), + enabled: !workflowsLive, }); const [enabled, setEnabled] = useState(false); const [daysBefore, setDaysBefore] = useState(2); @@ -259,49 +264,51 @@ export const ReminderTemplatesPage: React.FC = () => {
- {/* Global toggles strip */} + {/* Schedule (on/off + lead time): in Workflows when the engine is live, + else the legacy global controls. */} -

- {t('reminderTemplates.globalSection', 'Global behaviour')} -

-

- {t('reminderTemplates.globalHelp', - 'Off by default — turn on to start sending pre-event reminders. The offset below is the default; each event can override on its detail page.')} -

-
- -
- - setDaysBefore(Number(e.target.value))} - className="w-24" - /> + {workflowsLive ? ( +
+ +
+

{t('reminderTemplates.scheduleMoved.title', 'The reminder schedule is now in Workflows')}

+

+ {t('reminderTemplates.scheduleMoved.body', 'Whether pre-event reminders are sent, and how many days before the event, is configured in the “Pre-event reminder” workflow. This page edits the email templates; per-event overrides stay on each event’s detail page.')}{' '} + {t('reminderTemplates.scheduleMoved.link', 'Open Workflows')} +

+
- -
+ ) : ( + <> +

+ {t('reminderTemplates.globalSection', 'Global behaviour')} +

+

+ {t('reminderTemplates.globalHelp', + 'Off by default — turn on to start sending pre-event reminders. The offset below is the default; each event can override on its detail page.')} +

+
+ +
+ + setDaysBefore(Number(e.target.value))} className="w-24" /> +
+ +
+ + )}
diff --git a/frontend/src/pages/admin/workflows/NodeConfigPanel.tsx b/frontend/src/pages/admin/workflows/NodeConfigPanel.tsx new file mode 100644 index 00000000..9856eae3 --- /dev/null +++ b/frontend/src/pages/admin/workflows/NodeConfigPanel.tsx @@ -0,0 +1,225 @@ +/** + * Structured config editor for a workflow node — dropdowns + typed fields per + * node type, so admins don't hand-edit JSON. An "Advanced (JSON)" expander is + * kept for power users / config the form doesn't cover. Changes are applied + * live to the node (the global Save persists them). + */ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +type Cfg = Record; + +interface WebhookOption { id: number; name: string; active: boolean } + +interface Props { + nodeType: string; + config: Cfg; + onChange: (next: Cfg) => void; + webhooks?: WebhookOption[]; +} + +const field = 'w-full px-2 py-1.5 rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 text-sm'; +const lbl = 'block text-xs text-neutral-500 dark:text-neutral-400 mb-1'; + +const ACTIONS = [ + ['queue_payment_check', 'Send payment-check email (dunning gate)'], + ['escalate_to_collections', 'Hand off to collections (email admin)'], + ['send_email', 'Send email'], + ['notify_pre_event', 'Send pre-event reminder'], + ['notify_gallery_expiring', 'Send gallery-expiring warning'], + ['notify_gallery_expired', 'Send gallery-expired email'], + ['reserve_date', 'Reserve the event date'], + ['prepare_quote', 'Prepare a quote (draft)'], + ['prepare_contract', 'Prepare a contract (draft)'], + ['prepare_invoice', 'Prepare an invoice (draft)'], + ['prepare_event', 'Create an event (draft)'], + ['prepare_gallery', 'Create a gallery (draft)'], + ['send_document', 'Send the document'], + ['webhook', 'Call a webhook'], + ['noop', 'Do nothing'], +]; +const CONDITIONS = [ + ['invoice_paid', 'Invoice is paid'], + ['expr', 'Compare a field'], + ['always', 'Always → yes'], + ['never', 'Never → no'], +]; +const WAIT_ANCHORS = [ + ['dueDate', 'the invoice due date'], + ['issueDate', 'the invoice date'], + ['eventDate', 'the event date'], +]; +const OPS = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'truthy', 'falsy']; + +const Row: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => ( +
{children}
+); + +export const NodeConfigPanel: React.FC = ({ nodeType, config, onChange, webhooks = [] }) => { + const { t } = useTranslation(); + const [showJson, setShowJson] = useState(false); + const [jsonText, setJsonText] = useState(JSON.stringify(config || {}, null, 2)); + const [jsonErr, setJsonErr] = useState(null); + + const set = (patch: Cfg) => onChange({ ...config, ...patch }); + const num = (v: string) => (v === '' ? undefined : Number(v)); + + const applyJson = (text: string) => { + setJsonText(text); + try { onChange(JSON.parse(text || '{}')); setJsonErr(null); } + catch (e) { setJsonErr(t('workflows.editor.badJson', 'Config is not valid JSON') as string); } + }; + + const waitMode = config.untilVar ? 'until' : 'delay'; + + return ( +
+ {nodeType === 'trigger' && ( +

+ {t('workflows.editor.triggerHint', 'The trigger is set in the toolbar above (When …).')} +

+ )} + + {(nodeType === 'action' || nodeType === 'webhook') && ( + + + + )} + + {nodeType === 'action' && config.action === 'send_email' && ( + <> + + + + + set({ emailType: e.target.value })} placeholder="invoice_reminder" /> + + + )} + + {nodeType === 'action' && config.action === 'notify_pre_event' && ( + + set({ templateGroup: e.target.value })} placeholder="event_reminder" /> +

+ {t('workflows.editor.templateGroupHint', 'The exact template is auto-picked per event type within this group: «group»_«eventType» if you authored one, else «group»_default. Blank = event_reminder.')} +

+
+ )} + + {(nodeType === 'action' || nodeType === 'webhook') && (config.action === 'webhook' || nodeType === 'webhook') && ( + + +

+ {t('workflows.editor.webhookHint', 'Delivered via the webhook pipeline (signing, retries, SSRF checks). Manage endpoints in Settings → Webhooks.')} +

+
+ )} + + {(nodeType === 'condition' || nodeType === 'branch') && ( + <> + + + + {config.condition === 'expr' && ( + <> + + set({ field: e.target.value })} /> + + + + + {!['truthy', 'falsy'].includes(config.op) && ( + + set({ value: e.target.value })} /> + + )} + + )} +

+ {t('workflows.editor.conditionHint', 'Routes to the “yes” edge when true, “no” when false.')} +

+ + )} + + {nodeType === 'wait' && ( + <> + + + + {waitMode === 'until' ? ( + + + + ) : ( +
+ + set({ delayDays: num(e.target.value) })} /> + + + set({ delayHours: num(e.target.value) })} /> + + + set({ delayMinutes: num(e.target.value) })} /> + +
+ )} + + )} + + {nodeType === 'loop' && ( + + set({ maxIterations: num(e.target.value) })} /> + + )} + + {nodeType === 'gate' && ( + <> + +