Merge pull request #667 from Luca-Timo/feat/workflow-engine
feat: admin-configurable workflow engine + dunning/Mahngebühr rework (RFC — feedback welcome)
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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'));
|
||||
}
|
||||
};
|
||||
@@ -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'));
|
||||
}
|
||||
};
|
||||
@@ -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'));
|
||||
}
|
||||
};
|
||||
@@ -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'));
|
||||
}
|
||||
};
|
||||
@@ -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'));
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
@@ -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 `<!doctype html><html><head><meta charset="utf-8">`
|
||||
+ `<meta name="viewport" content="width=device-width, initial-scale=1">`
|
||||
+ `<title>${title}</title></head>`
|
||||
+ `<body style="font-family:system-ui,sans-serif;max-width:480px;margin:64px auto;padding:0 20px;text-align:center;color:#1f2937">`
|
||||
+ `<h2 style="font-weight:600">${title}</h2><p style="color:#4b5563;line-height:1.6">${body}</p></body></html>`;
|
||||
}
|
||||
|
||||
// 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) => `<form method="POST" action="${href}" style="display:inline">`
|
||||
+ `<button type="submit" style="cursor:pointer;margin:6px;padding:12px 20px;border-radius:8px;border:1px solid #d1d5db;`
|
||||
+ `font-size:15px;font-weight:600;${primary
|
||||
? 'background:#1d9e75;color:#fff;border-color:#1d9e75'
|
||||
: 'background:#fff;color:#374151'}">${label}</button></form>`;
|
||||
const body = (prompt ? `<span style="display:block;margin-bottom:16px">${esc(prompt)}</span>` : '')
|
||||
+ `<div>`
|
||||
+ btn(`confirm`, 'Confirm payment received', emphasis === 'confirm')
|
||||
+ btn(`deny`, 'No payment received', emphasis === 'deny')
|
||||
+ `</div>`
|
||||
+ `<p style="color:#9ca3af;font-size:13px;margin-top:20px">Choosing is a single, final action.</p>`;
|
||||
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;
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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: `<h2>Ready to hand to collections</h2>
|
||||
<p>Invoice <strong>{{invoice_number}}</strong>{{#if event_name}} ({{event_name}}){{/if}} is still unpaid after {{reminder_level}} reminders. The invoice PDF is attached for forwarding.</p>
|
||||
<table role="presentation" cellpadding="6" cellspacing="0" border="0" style="border-collapse: collapse; margin: 16px 0;">
|
||||
<tr><td style="color:#666;">Customer</td><td><strong>{{customer_name}}</strong></td></tr>
|
||||
{{#if customer_email}}<tr><td style="color:#666;">Email</td><td>{{customer_email}}</td></tr>{{/if}}
|
||||
{{#if customer_address}}<tr><td style="color:#666;">Address</td><td>{{customer_address}}</td></tr>{{/if}}
|
||||
<tr><td style="color:#666;">Due date</td><td>{{due_date}}</td></tr>
|
||||
<tr><td style="color:#666;">Original amount</td><td>{{original_amount}}</td></tr>
|
||||
{{#if late_fee_amount}}<tr><td style="color:#666;">Late fees</td><td>{{late_fee_amount}}</td></tr>{{/if}}
|
||||
<tr><td style="color:#666;">Paid</td><td>{{paid_amount}}</td></tr>
|
||||
<tr><td style="color:#666;"><strong>Outstanding</strong></td><td><strong>{{outstanding_amount}}</strong></td></tr>
|
||||
</table>
|
||||
<p style="font-size:13px;color:#666;">Forward to your collections agency / for Betreibung. Automatic notification.</p>`,
|
||||
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: `<h2>Bereit zur Inkasso-Übergabe</h2>
|
||||
<p>Rechnung <strong>{{invoice_number}}</strong>{{#if event_name}} ({{event_name}}){{/if}} ist nach {{reminder_level}} Mahnungen weiterhin offen. Das Rechnungs-PDF ist zur Weiterleitung angehängt.</p>
|
||||
<table role="presentation" cellpadding="6" cellspacing="0" border="0" style="border-collapse: collapse; margin: 16px 0;">
|
||||
<tr><td style="color:#666;">Kunde</td><td><strong>{{customer_name}}</strong></td></tr>
|
||||
{{#if customer_email}}<tr><td style="color:#666;">E-Mail</td><td>{{customer_email}}</td></tr>{{/if}}
|
||||
{{#if customer_address}}<tr><td style="color:#666;">Adresse</td><td>{{customer_address}}</td></tr>{{/if}}
|
||||
<tr><td style="color:#666;">Fälligkeit</td><td>{{due_date}}</td></tr>
|
||||
<tr><td style="color:#666;">Rechnungsbetrag</td><td>{{original_amount}}</td></tr>
|
||||
{{#if late_fee_amount}}<tr><td style="color:#666;">Mahngebühren</td><td>{{late_fee_amount}}</td></tr>{{/if}}
|
||||
<tr><td style="color:#666;">Bezahlt</td><td>{{paid_amount}}</td></tr>
|
||||
<tr><td style="color:#666;"><strong>Offen</strong></td><td><strong>{{outstanding_amount}}</strong></td></tr>
|
||||
</table>
|
||||
<p style="font-size:13px;color:#666;">Zur Weiterleitung an das Inkasso / für die Betreibung. Automatische Benachrichtigung.</p>`,
|
||||
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.`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
* `<group>_<eventType>` if a template exists → else `<group>_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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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_<slug>). 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);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
Generated
+249
-2
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
<Route path="customers" element={<Navigate to="/admin/clients/accounts" replace />} />
|
||||
<Route path="customers/:id" element={<RedirectCustomerDetail />} />
|
||||
|
||||
{/* Workflows (automation engine) — top-level area gated
|
||||
by the `workflows` flag. */}
|
||||
<Route element={<RequireFeature flag="workflows" />}>
|
||||
<Route path="workflows" element={<WorkflowsListPage />} />
|
||||
<Route path="workflows/approvals" element={<WorkflowApprovalsPage />} />
|
||||
<Route path="workflows/:id" element={<WorkflowEditorPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="system-health" element={<SystemHealthPage />} />
|
||||
<Route path="webhooks/:id/deliveries" element={<WebhookDeliveriesPage />} />
|
||||
|
||||
@@ -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<AdminSidebarProps> = ({ isOpen, onClose, collapsed = false, onToggleCollapse }) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = () => {
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Automation — the visual workflow engine. Master kill-switch for the
|
||||
Workflows admin area and the runtime; off by default. */}
|
||||
<Section title={t('settings.features.sections.automation', 'Automation')}>
|
||||
<FeatureCard
|
||||
icon={Workflow}
|
||||
title={t('settings.features.workflows.title', 'Workflows')}
|
||||
description={t(
|
||||
'settings.features.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.',
|
||||
)}
|
||||
status="new"
|
||||
statusLabel={statusLabel('new')}
|
||||
sidebarLabel={t('settings.features.workflows.sidebar', 'Workflows')}
|
||||
enabled={staged.workflows}
|
||||
onToggle={(next) => setFlag('workflows', next)}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* 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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = () => {
|
||||
</button>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 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 && (
|
||||
<Card padding="md" className="mt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('workflows.approvals.pendingTitle', 'Pending approvals')}</h2>
|
||||
<Inbox className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{pendingApprovals.slice(0, 5).map((a) => {
|
||||
const prompt = (a.payload as any)?.prompt as string | undefined;
|
||||
return (
|
||||
<div key={a.id} className="flex items-center justify-between gap-3 p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-medium text-neutral-900 dark:text-neutral-100 truncate">{a.workflow_name}</h3>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 truncate">
|
||||
{prompt || a.type}{a.entity_type ? ` · ${a.entity_type} #${a.entity_id}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button variant="primary" size="sm" isLoading={approvalMutation.isPending}
|
||||
onClick={() => approvalMutation.mutate({ id: a.id, action: 'confirm' })}
|
||||
leftIcon={<Check className="w-4 h-4" />}>
|
||||
{t('workflows.approvals.confirm', 'Confirm')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" isLoading={approvalMutation.isPending}
|
||||
onClick={() => approvalMutation.mutate({ id: a.id, action: 'deny' })}
|
||||
leftIcon={<X className="w-4 h-4" />}>
|
||||
{t('workflows.approvals.deny', 'Deny')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{pendingApprovals.length > 5 && (
|
||||
<button
|
||||
onClick={() => navigate('/admin/workflows/approvals')}
|
||||
className="w-full mt-4 text-sm text-accent hover:opacity-80 font-medium"
|
||||
>
|
||||
{t('workflows.approvals.viewAll', 'View all approvals')} →
|
||||
</button>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
|
||||
@@ -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 = () => {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Input label={t('quotes.field.eventName', 'Event name') as string} value={form.eventName}
|
||||
onChange={(e) => setForm((f) => ({ ...f, eventName: e.target.value }))} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('quotes.field.eventType', 'Event type')}
|
||||
</label>
|
||||
<select
|
||||
value={form.eventType}
|
||||
onChange={(e) => setForm((f) => ({ ...f, eventType: e.target.value }))}
|
||||
className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
<option value="">{t('quotes.field.eventTypeNone', '— Use default —')}</option>
|
||||
{eventTypes.map((et) => (
|
||||
<option key={et.id} value={et.slug_prefix}>{et.emoji ? `${et.emoji} ` : ''}{et.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('quotes.field.eventTypeHint', 'Used for the event created when this quote is accepted.')}
|
||||
</p>
|
||||
</div>
|
||||
{workflowsLive && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('quotes.field.bookingWorkflow', 'Booking workflow (on acceptance)')}
|
||||
</label>
|
||||
<select
|
||||
value={form.bookingWorkflowId ?? ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, bookingWorkflowId: e.target.value ? Number(e.target.value) : null }))}
|
||||
className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
<option value="">{t('quotes.field.bookingWorkflowNone', '— None —')}</option>
|
||||
{bookingWorkflows.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.name}{(w.enabled === true || w.enabled === 1) ? '' : ` ${t('quotes.field.bookingWorkflowDisabled', '(disabled)')}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{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.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<LocalizedDateInput label={t('quotes.field.eventDate', 'Event date') as string} value={form.eventDate}
|
||||
onChange={(iso) => setForm((f) => ({ ...f, eventDate: iso }))} />
|
||||
<TimeField label={t('quotes.field.eventTimeStart', 'Start time') as string} value={form.eventTimeStart}
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Save as SaveIcon } from 'lucide-react';
|
||||
import { Save as SaveIcon, Workflow as WorkflowIcon } from 'lucide-react';
|
||||
import { Button, Card, Loading, Input } from '../../../components/common';
|
||||
import { settingsService } from '../../../services/settings.service';
|
||||
import { quotesService } from '../../../services/quotes.service';
|
||||
@@ -31,7 +32,10 @@ const SETTING_KEYS = [
|
||||
'crm_invoices_reminder_first_days',
|
||||
'crm_invoices_reminder_second_days',
|
||||
'crm_invoices_late_fee_enabled',
|
||||
'crm_invoices_late_fee_type',
|
||||
'crm_invoices_late_fee_minor',
|
||||
'crm_invoices_late_fee_percent',
|
||||
'crm_invoices_late_fee_vat_enabled',
|
||||
'crm_invoices_late_fee_label',
|
||||
'crm_invoices_skonto_business_days',
|
||||
'crm_invoices_skonto_percent_default',
|
||||
@@ -80,6 +84,10 @@ export const CrmSettingsPage: React.FC = () => {
|
||||
// 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 = () => {
|
||||
<Card>
|
||||
<h3 className="font-semibold mb-3">{t('crmSettings.section.invoices', 'Invoices')}</h3>
|
||||
{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 ? (
|
||||
<div className="mt-2 rounded-lg border border-blue-300 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/20 p-3 text-sm text-blue-800 dark:text-blue-200 flex items-start gap-2">
|
||||
<WorkflowIcon className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">{t('crmSettings.dunningMoved.title', 'Reminder schedule is now in Workflows')}</p>
|
||||
<p className="mt-1">
|
||||
{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.')}{' '}
|
||||
<Link to="/admin/workflows" className="underline font-medium">{t('crmSettings.dunningMoved.link', 'Open Workflows')}</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
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')}
|
||||
<div className="mt-2 rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-3 text-sm text-amber-800 dark:text-amber-200">
|
||||
<p className="font-medium">{t('crmSettings.lateFeeAgb.title', 'Late fees must be itemised in your terms (AGB)')}</p>
|
||||
<p className="mt-1">{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.')}</p>
|
||||
</div>
|
||||
{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)')}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
|
||||
<Input type="number" min={1} max={365}
|
||||
label={t('crmSettings.crm_invoices_reminder_first_days.label', 'First reminder after (days past due)') as string}
|
||||
value={values.crm_invoices_reminder_first_days ?? 14}
|
||||
onChange={(e) => setVal('crm_invoices_reminder_first_days', Number(e.target.value))} />
|
||||
<Input type="number" min={1} max={365}
|
||||
label={t('crmSettings.crm_invoices_reminder_second_days.label', 'Second reminder after (days past due)') as string}
|
||||
value={values.crm_invoices_reminder_second_days ?? 30}
|
||||
onChange={(e) => setVal('crm_invoices_reminder_second_days', Number(e.target.value))} />
|
||||
<Input type="number" min={0}
|
||||
label={t('crmSettings.crm_invoices_late_fee_minor.label', 'Late fee (minor units / Rappen)') as string}
|
||||
value={values.crm_invoices_late_fee_minor ?? 0}
|
||||
onChange={(e) => setVal('crm_invoices_late_fee_minor', Number(e.target.value))} />
|
||||
{!workflowsLive && (
|
||||
<>
|
||||
<Input type="number" min={1} max={365}
|
||||
label={t('crmSettings.crm_invoices_reminder_first_days.label', 'First reminder after (days past due)') as string}
|
||||
value={values.crm_invoices_reminder_first_days ?? 14}
|
||||
onChange={(e) => setVal('crm_invoices_reminder_first_days', Number(e.target.value))} />
|
||||
<Input type="number" min={1} max={365}
|
||||
label={t('crmSettings.crm_invoices_reminder_second_days.label', 'Second reminder after (days past due)') as string}
|
||||
value={values.crm_invoices_reminder_second_days ?? 30}
|
||||
onChange={(e) => setVal('crm_invoices_reminder_second_days', Number(e.target.value))} />
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('crmSettings.crm_invoices_late_fee_type.label', 'Late fee type')}
|
||||
</label>
|
||||
<select
|
||||
value={values.crm_invoices_late_fee_type ?? 'flat'}
|
||||
onChange={(e) => setVal('crm_invoices_late_fee_type', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
<option value="flat">{t('crmSettings.lateFeeType.flat', 'Flat amount (Rappen)')}</option>
|
||||
<option value="percent">{t('crmSettings.lateFeeType.percent', 'Percentage of invoice')}</option>
|
||||
</select>
|
||||
</div>
|
||||
{(values.crm_invoices_late_fee_type ?? 'flat') === 'percent' ? (
|
||||
<Input type="number" min={0} step="0.01" max={100}
|
||||
label={t('crmSettings.crm_invoices_late_fee_percent.label', 'Late fee (% of invoice)') as string}
|
||||
value={values.crm_invoices_late_fee_percent ?? 0}
|
||||
onChange={(e) => setVal('crm_invoices_late_fee_percent', Number(e.target.value))} />
|
||||
) : (
|
||||
<Input type="number" min={0}
|
||||
label={t('crmSettings.crm_invoices_late_fee_minor.label', 'Late fee (minor units / Rappen)') as string}
|
||||
value={values.crm_invoices_late_fee_minor ?? 0}
|
||||
onChange={(e) => setVal('crm_invoices_late_fee_minor', Number(e.target.value))} />
|
||||
)}
|
||||
<Input
|
||||
label={t('crmSettings.crm_invoices_late_fee_label.label', 'Late fee label') as string}
|
||||
value={values.crm_invoices_late_fee_label ?? 'Mahngebühr'}
|
||||
|
||||
@@ -33,13 +33,14 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { ArrowLeft, Save, AlertTriangle } from 'lucide-react';
|
||||
import { ArrowLeft, Save, AlertTriangle, Workflow as WorkflowIcon } from 'lucide-react';
|
||||
import { Button, Card, Loading, Input } from '../../../components/common';
|
||||
import { SUPPORTED_LANGUAGES } from '../../../components/common/LanguageSelector';
|
||||
import { EmailTemplateEditor } from '../../../components/admin/EmailTemplateEditor';
|
||||
import { eventTypesService } from '../../../services/eventTypes.service';
|
||||
import { emailService, type EmailTemplateTranslation } from '../../../services/email.service';
|
||||
import { settingsService } from '../../../services/settings.service';
|
||||
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
|
||||
|
||||
const TEMPLATE_KEY_DEFAULT = 'event_reminder_default';
|
||||
const TEMPLATE_KEY_PREFIX = 'event_reminder_';
|
||||
@@ -61,16 +62,20 @@ export const ReminderTemplatesPage: React.FC = () => {
|
||||
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<boolean>(false);
|
||||
const [daysBefore, setDaysBefore] = useState<number>(2);
|
||||
@@ -259,49 +264,51 @@ export const ReminderTemplatesPage: React.FC = () => {
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Global toggles strip */}
|
||||
{/* Schedule (on/off + lead time): in Workflows when the engine is live,
|
||||
else the legacy global controls. */}
|
||||
<Card className="mb-4">
|
||||
<h3 className="font-semibold text-sm mb-2">
|
||||
{t('reminderTemplates.globalSection', 'Global behaviour')}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-theme mb-3">
|
||||
{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.')}
|
||||
</p>
|
||||
<div className="flex items-center gap-6 flex-wrap">
|
||||
<label className="inline-flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
{t('reminderTemplates.enableLabel', 'Send pre-event reminder emails')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="reminder-days-before" className="text-sm">
|
||||
{t('reminderTemplates.daysBeforeLabel', 'Days before the event')}
|
||||
</label>
|
||||
<Input
|
||||
id="reminder-days-before"
|
||||
type="number"
|
||||
min={0}
|
||||
max={365}
|
||||
value={daysBefore}
|
||||
onChange={(e) => setDaysBefore(Number(e.target.value))}
|
||||
className="w-24"
|
||||
/>
|
||||
{workflowsLive ? (
|
||||
<div className="flex items-start gap-2 text-sm text-blue-800 dark:text-blue-200">
|
||||
<WorkflowIcon className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">{t('reminderTemplates.scheduleMoved.title', 'The reminder schedule is now in Workflows')}</p>
|
||||
<p className="mt-1 text-muted-theme">
|
||||
{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.')}{' '}
|
||||
<Link to="/admin/workflows" className="underline font-medium">{t('reminderTemplates.scheduleMoved.link', 'Open Workflows')}</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => saveSettingsMutation.mutate()}
|
||||
isLoading={saveSettingsMutation.isPending}
|
||||
disabled={saveSettingsMutation.isPending}
|
||||
leftIcon={<Save className="w-4 h-4" />}
|
||||
>
|
||||
{t('reminderTemplates.saveSettings', 'Save global settings')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="font-semibold text-sm mb-2">
|
||||
{t('reminderTemplates.globalSection', 'Global behaviour')}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-theme mb-3">
|
||||
{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.')}
|
||||
</p>
|
||||
<div className="flex items-center gap-6 flex-wrap">
|
||||
<label className="inline-flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||
{t('reminderTemplates.enableLabel', 'Send pre-event reminder emails')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="reminder-days-before" className="text-sm">
|
||||
{t('reminderTemplates.daysBeforeLabel', 'Days before the event')}
|
||||
</label>
|
||||
<Input id="reminder-days-before" type="number" min={0} max={365}
|
||||
value={daysBefore} onChange={(e) => setDaysBefore(Number(e.target.value))} className="w-24" />
|
||||
</div>
|
||||
<Button variant="outline" size="sm"
|
||||
onClick={() => saveSettingsMutation.mutate()}
|
||||
isLoading={saveSettingsMutation.isPending}
|
||||
disabled={saveSettingsMutation.isPending}
|
||||
leftIcon={<Save className="w-4 h-4" />}>
|
||||
{t('reminderTemplates.saveSettings', 'Save global settings')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
@@ -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<string, any>;
|
||||
|
||||
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 }) => (
|
||||
<div><label className={lbl}>{label}</label>{children}</div>
|
||||
);
|
||||
|
||||
export const NodeConfigPanel: React.FC<Props> = ({ 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<string | null>(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 (
|
||||
<div className="space-y-3">
|
||||
{nodeType === 'trigger' && (
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{t('workflows.editor.triggerHint', 'The trigger is set in the toolbar above (When …).')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(nodeType === 'action' || nodeType === 'webhook') && (
|
||||
<Row label={t('workflows.editor.actionLabel', 'Action')}>
|
||||
<select className={field} value={config.action || (nodeType === 'webhook' ? 'webhook' : 'noop')} onChange={(e) => set({ action: e.target.value })}>
|
||||
{ACTIONS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{nodeType === 'action' && config.action === 'send_email' && (
|
||||
<>
|
||||
<Row label={t('workflows.editor.recipient', 'Recipient')}>
|
||||
<select className={field} value={config.recipientClass || 'customer'} onChange={(e) => set({ recipientClass: e.target.value })}>
|
||||
<option value="customer">{t('workflows.editor.recipientCustomer', 'Customer (respects business hours)')}</option>
|
||||
<option value="admin">{t('workflows.editor.recipientAdmin', 'Admin (sent immediately)')}</option>
|
||||
</select>
|
||||
</Row>
|
||||
<Row label={t('workflows.editor.emailTemplate', 'Email template key')}>
|
||||
<input className={field} value={config.emailType || ''} onChange={(e) => set({ emailType: e.target.value })} placeholder="invoice_reminder" />
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
|
||||
{nodeType === 'action' && config.action === 'notify_pre_event' && (
|
||||
<Row label={t('workflows.editor.templateGroup', 'Reminder template group')}>
|
||||
<input className={field} value={config.templateGroup || ''} onChange={(e) => set({ templateGroup: e.target.value })} placeholder="event_reminder" />
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{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.')}
|
||||
</p>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{(nodeType === 'action' || nodeType === 'webhook') && (config.action === 'webhook' || nodeType === 'webhook') && (
|
||||
<Row label={t('workflows.editor.webhookTarget', 'Webhook')}>
|
||||
<select className={field} value={config.webhookId ?? ''} onChange={(e) => set({ webhookId: e.target.value ? Number(e.target.value) : undefined })}>
|
||||
<option value="">{t('workflows.editor.webhookNone', '— Select a configured webhook —')}</option>
|
||||
{webhooks.map((w) => (
|
||||
<option key={w.id} value={w.id}>{w.name}{w.active ? '' : ` ${t('workflows.editor.webhookInactive', '(inactive)')}`}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('workflows.editor.webhookHint', 'Delivered via the webhook pipeline (signing, retries, SSRF checks). Manage endpoints in Settings → Webhooks.')}
|
||||
</p>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{(nodeType === 'condition' || nodeType === 'branch') && (
|
||||
<>
|
||||
<Row label={t('workflows.editor.condition', 'Condition')}>
|
||||
<select className={field} value={config.condition || 'expr'} onChange={(e) => set({ condition: e.target.value })}>
|
||||
{CONDITIONS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</Row>
|
||||
{config.condition === 'expr' && (
|
||||
<>
|
||||
<Row label={t('workflows.editor.exprField', 'Field')}>
|
||||
<input className={field} value={config.field || ''} onChange={(e) => set({ field: e.target.value })} />
|
||||
</Row>
|
||||
<Row label={t('workflows.editor.exprOp', 'Operator')}>
|
||||
<select className={field} value={config.op || 'truthy'} onChange={(e) => set({ op: e.target.value })}>
|
||||
{OPS.map((o) => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
</Row>
|
||||
{!['truthy', 'falsy'].includes(config.op) && (
|
||||
<Row label={t('workflows.editor.exprValue', 'Value')}>
|
||||
<input className={field} value={config.value ?? ''} onChange={(e) => set({ value: e.target.value })} />
|
||||
</Row>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('workflows.editor.conditionHint', 'Routes to the “yes” edge when true, “no” when false.')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{nodeType === 'wait' && (
|
||||
<>
|
||||
<Row label={t('workflows.editor.waitType', 'Wait')}>
|
||||
<select
|
||||
className={field}
|
||||
value={waitMode}
|
||||
onChange={(e) => (e.target.value === 'until'
|
||||
? onChange({ untilVar: 'dueDate' })
|
||||
: onChange({ delayDays: config.delayDays || 1 }))}
|
||||
>
|
||||
<option value="until">{t('workflows.editor.waitUntil', 'Until a date')}</option>
|
||||
<option value="delay">{t('workflows.editor.waitDelay', 'A fixed delay')}</option>
|
||||
</select>
|
||||
</Row>
|
||||
{waitMode === 'until' ? (
|
||||
<Row label={t('workflows.editor.waitAnchor', 'Wait until')}>
|
||||
<select className={field} value={config.untilVar || 'dueDate'} onChange={(e) => onChange({ untilVar: e.target.value })}>
|
||||
{WAIT_ANCHORS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</Row>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Row label={t('workflows.editor.days', 'Days')}>
|
||||
<input type="number" min={0} className={field} value={config.delayDays ?? ''} onChange={(e) => set({ delayDays: num(e.target.value) })} />
|
||||
</Row>
|
||||
<Row label={t('workflows.editor.hours', 'Hours')}>
|
||||
<input type="number" min={0} className={field} value={config.delayHours ?? ''} onChange={(e) => set({ delayHours: num(e.target.value) })} />
|
||||
</Row>
|
||||
<Row label={t('workflows.editor.minutes', 'Min')}>
|
||||
<input type="number" min={0} className={field} value={config.delayMinutes ?? ''} onChange={(e) => set({ delayMinutes: num(e.target.value) })} />
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{nodeType === 'loop' && (
|
||||
<Row label={t('workflows.editor.maxIterations', 'Repeat at most (times)')}>
|
||||
<input type="number" min={1} className={field} value={config.maxIterations ?? 3} onChange={(e) => set({ maxIterations: num(e.target.value) })} />
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{nodeType === 'gate' && (
|
||||
<>
|
||||
<Row label={t('workflows.editor.gatePrompt', 'Question for the admin')}>
|
||||
<textarea className={field} rows={2} value={config.prompt || ''} onChange={(e) => set({ prompt: e.target.value })} placeholder={t('workflows.editor.gatePromptPh', 'e.g. No payment received — send a reminder?') as string} />
|
||||
</Row>
|
||||
<Row label={t('workflows.editor.gateTimeout', 'Auto-expire after (days, optional)')}>
|
||||
<input type="number" min={0} className={field} value={config.timeoutDays ?? ''} onChange={(e) => set({ timeoutDays: num(e.target.value) })} />
|
||||
</Row>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('workflows.editor.gateHint', 'Emails the admin a confirm/deny link; routes to the “confirm” or “deny” edge.')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button type="button" className="text-xs text-neutral-500 dark:text-neutral-400 underline" onClick={() => { setJsonText(JSON.stringify(config || {}, null, 2)); setShowJson((s) => !s); }}>
|
||||
{showJson ? t('workflows.editor.hideAdvanced', 'Hide advanced (JSON)') : t('workflows.editor.showAdvanced', 'Advanced (JSON)')}
|
||||
</button>
|
||||
{showJson && (
|
||||
<div className="space-y-1">
|
||||
<textarea className={`${field} font-mono`} rows={8} value={jsonText} onChange={(e) => applyJson(e.target.value)} />
|
||||
{jsonErr && <p className="text-xs text-red-600 dark:text-red-400">{jsonErr}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Admin → Workflows → Approvals inbox. Lists workflow runs paused on a gate
|
||||
* (e.g. "confirm there's no payment") and lets the admin confirm or deny,
|
||||
* resuming the run down the matching edge. The same decision is also possible
|
||||
* from the emailed confirm/deny link.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { ArrowLeft, Check, X } from 'lucide-react';
|
||||
import { Button, Card, Loading } from '../../../components/common';
|
||||
import { workflowsService, type WorkflowApproval } from '../../../services/workflows.service';
|
||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||
|
||||
export const WorkflowApprovalsPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const { formatDateTime } = useLocalizedDate();
|
||||
|
||||
const { data: approvals, isLoading } = useQuery({
|
||||
queryKey: ['workflow-approvals'],
|
||||
queryFn: () => workflowsService.approvals(),
|
||||
});
|
||||
|
||||
const actMutation = useMutation({
|
||||
mutationFn: ({ id, action }: { id: number; action: 'confirm' | 'deny' }) => workflowsService.actApproval(id, action),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['workflow-approvals'] });
|
||||
toast.success(t('workflows.approvals.recorded', 'Response recorded') as string);
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || (t('common.error', 'Something went wrong') as string)),
|
||||
});
|
||||
|
||||
const promptOf = (a: WorkflowApproval) => (a.payload && (a.payload.prompt as string)) || t('workflows.approvals.defaultPrompt', 'A workflow needs your confirmation.');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/admin/workflows')} aria-label={t('common.back', 'Back') as string}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">{t('workflows.approvals.title', 'Approvals')}</h1>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('workflows.approvals.subtitle', 'Workflow runs waiting on your confirmation.')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card padding="none">
|
||||
{isLoading ? (
|
||||
<div className="p-10"><Loading /></div>
|
||||
) : !approvals || approvals.length === 0 ? (
|
||||
<div className="p-10 text-center text-neutral-500 dark:text-neutral-400">{t('workflows.approvals.empty', 'Nothing waiting for you right now.')}</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||
{approvals.map((a) => (
|
||||
<li key={a.id} className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm text-neutral-900 dark:text-neutral-100">{promptOf(a)}</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">
|
||||
{a.workflow_name}
|
||||
{a.entity_type ? ` · ${a.entity_type} #${a.entity_id}` : ''}
|
||||
{a.created_at ? ` · ${formatDateTime(a.created_at)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" isLoading={actMutation.isPending} onClick={() => actMutation.mutate({ id: a.id, action: 'confirm' })} leftIcon={<Check className="w-4 h-4" />}>
|
||||
{t('workflows.approvals.confirm', 'Confirm')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => actMutation.mutate({ id: a.id, action: 'deny' })} leftIcon={<X className="w-4 h-4" />}>
|
||||
{t('workflows.approvals.deny', 'Deny')}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* Admin → Workflows → canvas editor (React Flow).
|
||||
*
|
||||
* Drag nodes from the palette, connect handle→handle (branch/gate/loop expose
|
||||
* yes/no · confirm/deny · loop/exit handles, labelled on the node), click a
|
||||
* node to edit it in the structured side panel, and Save (writes a new
|
||||
* version; in-flight runs keep theirs). The graph maps 1:1 onto
|
||||
* workflow_nodes/workflow_edges. Honours the admin light/dark theme.
|
||||
*/
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import {
|
||||
ReactFlow, Background, Controls, MiniMap, addEdge, useNodesState, useEdgesState,
|
||||
Handle, Position, type Connection, type Node, type Edge,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import dagre from '@dagrejs/dagre';
|
||||
import { ArrowLeft, Save, Trash2, Wand2, Code } from 'lucide-react';
|
||||
import { Button, Loading } from '../../../components/common';
|
||||
import { api } from '../../../config/api';
|
||||
import { useAdminDarkMode } from '../../../contexts/AdminDarkModeContext';
|
||||
import { workflowsService, type WorkflowNodeType } from '../../../services/workflows.service';
|
||||
import { NodeConfigPanel } from './NodeConfigPanel';
|
||||
|
||||
const PALETTE: WorkflowNodeType[] = ['trigger', 'condition', 'branch', 'loop', 'wait', 'action', 'gate', 'webhook'];
|
||||
const TRIGGERS = [
|
||||
'invoice.sent', 'invoice.paid', 'invoice.overdue',
|
||||
'quote.sent', 'quote.accepted', 'quote.declined',
|
||||
'contract.sent', 'contract.signed',
|
||||
'event.date_approaching',
|
||||
'gallery.published', 'gallery.expiring', 'gallery.expired',
|
||||
'customer.created',
|
||||
];
|
||||
|
||||
const COLORS: Record<string, string> = {
|
||||
trigger: '#1D9E75', condition: '#BA7517', branch: '#BA7517', loop: '#378ADD',
|
||||
wait: '#888780', action: '#534AB7', gate: '#7F77DD', webhook: '#888780',
|
||||
};
|
||||
const SOURCE_HANDLES: Record<string, string[]> = {
|
||||
condition: ['yes', 'no'], branch: ['yes', 'no'], gate: ['confirm', 'deny'], loop: ['loop', 'exit'],
|
||||
};
|
||||
|
||||
const WAIT_ANCHOR_LABEL: Record<string, string> = { dueDate: 'due date', issueDate: 'invoice date', eventDate: 'event date' };
|
||||
const ACTION_LABEL: Record<string, string> = {
|
||||
queue_payment_check: 'Send payment-check email', escalate_to_collections: 'Collections handoff', send_email: 'Send email', reserve_date: 'Reserve the date',
|
||||
notify_pre_event: 'Send pre-event reminder', notify_gallery_expiring: 'Send gallery-expiring warning', notify_gallery_expired: 'Send gallery-expired email',
|
||||
prepare_quote: 'Prepare quote', prepare_contract: 'Prepare contract', prepare_invoice: 'Prepare invoice',
|
||||
prepare_event: 'Create event', prepare_gallery: 'Create gallery', send_document: 'Send document',
|
||||
webhook: 'Call webhook', noop: 'Do nothing', set_context: 'Set value',
|
||||
};
|
||||
|
||||
// Human-readable label derived live from a node's type + config.
|
||||
function describeNode(type: string, config: any = {}, triggerType?: string): string {
|
||||
const c = config || {};
|
||||
switch (type) {
|
||||
case 'trigger': return triggerType || 'When…';
|
||||
case 'wait':
|
||||
if (c.untilVar) return `Wait until ${WAIT_ANCHOR_LABEL[c.untilVar] || c.untilVar}`;
|
||||
return `Wait ${[c.delayDays && `${c.delayDays}d`, c.delayHours && `${c.delayHours}h`, c.delayMinutes && `${c.delayMinutes}m`].filter(Boolean).join(' ') || '…'}`;
|
||||
case 'condition':
|
||||
case 'branch':
|
||||
if (c.condition === 'invoice_paid') return 'Invoice paid?';
|
||||
if (c.condition === 'expr') return `${c.field || 'field'} ${c.op || ''} ${c.value ?? ''}`.trim();
|
||||
if (c.condition === 'always') return 'Always';
|
||||
if (c.condition === 'never') return 'Never';
|
||||
return c.condition || 'Condition';
|
||||
case 'loop': return `Repeat ≤ ${c.maxIterations ?? 3}×`;
|
||||
case 'action': return ACTION_LABEL[c.action] || c.action || 'Action';
|
||||
case 'gate': return c.prompt ? `Ask: ${String(c.prompt).slice(0, 28)}${String(c.prompt).length > 28 ? '…' : ''}` : 'Admin confirm';
|
||||
case 'webhook': return 'Call webhook';
|
||||
default: return type;
|
||||
}
|
||||
}
|
||||
|
||||
function WfNode({ data }: { data: any }) {
|
||||
const handles = SOURCE_HANDLES[data.nodeType];
|
||||
const color = COLORS[data.nodeType] || '#888780';
|
||||
const label = describeNode(data.nodeType, data.config, data.triggerType);
|
||||
const pos = (i: number, n: number) => `${(100 / (n + 1)) * (i + 1)}%`;
|
||||
return (
|
||||
<div style={{ borderColor: color }} className="relative rounded-md border-2 bg-white dark:bg-neutral-900 px-3 pt-2 pb-4 min-w-[152px] text-center shadow-sm">
|
||||
{data.nodeType !== 'trigger' && <Handle type="target" position={Position.Top} />}
|
||||
<div className="text-[10px] uppercase tracking-wide" style={{ color }}>{data.nodeType}</div>
|
||||
<div className="text-sm text-neutral-900 dark:text-neutral-100">{label}</div>
|
||||
{handles ? handles.map((h, i) => (
|
||||
<React.Fragment key={h}>
|
||||
<span className="absolute text-[9px] text-neutral-400 dark:text-neutral-500" style={{ bottom: 3, left: pos(i, handles.length), transform: 'translateX(-50%)' }}>{h}</span>
|
||||
<Handle id={h} type="source" position={Position.Bottom} style={{ left: pos(i, handles.length) }} />
|
||||
</React.Fragment>
|
||||
)) : <Handle type="source" position={Position.Bottom} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeTypes = { wf: WfNode };
|
||||
|
||||
// Tidy the graph into a top-to-bottom tree with dagre (handles the loop-back
|
||||
// cycle by breaking it internally).
|
||||
function layoutGraph(nodes: Node[], edges: Edge[]): Node[] {
|
||||
const g = new dagre.graphlib.Graph();
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
g.setGraph({ rankdir: 'TB', nodesep: 70, ranksep: 80 });
|
||||
const W = 170;
|
||||
const H = 70;
|
||||
nodes.forEach((n) => g.setNode(n.id, { width: W, height: H }));
|
||||
edges.forEach((e) => g.setEdge(e.source, e.target));
|
||||
dagre.layout(g);
|
||||
return nodes.map((n) => {
|
||||
const p = g.node(n.id);
|
||||
return p ? { ...n, position: { x: p.x - W / 2, y: p.y - H / 2 } } : n;
|
||||
});
|
||||
}
|
||||
|
||||
export const WorkflowEditorPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const { isDark } = useAdminDarkMode();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const workflowId = Number(id);
|
||||
|
||||
const { data: workflow, isLoading } = useQuery({
|
||||
queryKey: ['workflow', workflowId],
|
||||
queryFn: () => workflowsService.get(workflowId),
|
||||
enabled: Number.isFinite(workflowId),
|
||||
});
|
||||
|
||||
// Configured webhook subscriptions — the webhook node references one of these
|
||||
// (the delivery then rides the webhook worker pipeline).
|
||||
const { data: webhooks = [] } = useQuery({
|
||||
queryKey: ['admin-webhooks'],
|
||||
queryFn: async () => (await api.get<Array<{ id: number; name: string; active: boolean }>>('/admin/webhooks')).data,
|
||||
});
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
const [name, setName] = useState('');
|
||||
const [triggerType, setTriggerType] = useState('invoice.sent');
|
||||
const [triggerConfig, setTriggerConfig] = useState<Record<string, any>>({});
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [counter, setCounter] = useState(1);
|
||||
const rfRef = useRef<any>(null);
|
||||
|
||||
const cleanUp = useCallback(() => {
|
||||
setNodes((nds) => layoutGraph(nds, edges));
|
||||
setTimeout(() => rfRef.current?.fitView?.({ padding: 0.2, duration: 300 }), 60);
|
||||
}, [edges, setNodes]);
|
||||
|
||||
// --- Advanced text mode: the whole flow as JSON (share / LLM round-trip) ---
|
||||
const [textMode, setTextMode] = useState(false);
|
||||
const [text, setText] = useState('');
|
||||
const [textErr, setTextErr] = useState<string | null>(null);
|
||||
|
||||
const serializeFlow = () => JSON.stringify({
|
||||
name,
|
||||
trigger_type: triggerType,
|
||||
trigger_config: triggerConfig,
|
||||
enabled,
|
||||
nodes: nodes.map((n) => ({
|
||||
node_key: n.id, type: (n.data as any).nodeType, config: (n.data as any).config || {},
|
||||
pos_x: Math.round(n.position.x), pos_y: Math.round(n.position.y),
|
||||
})),
|
||||
edges: edges.map((e) => ({ from_node: e.source, from_handle: e.sourceHandle || null, to_node: e.target })),
|
||||
}, null, 2);
|
||||
|
||||
const openText = () => { setText(serializeFlow()); setTextErr(null); setTextMode(true); };
|
||||
|
||||
const applyText = () => {
|
||||
let p: any;
|
||||
try { p = JSON.parse(text); } catch (e) { setTextErr(t('workflows.editor.badJson', 'Config is not valid JSON') as string); return; }
|
||||
if (!Array.isArray(p.nodes) || !Array.isArray(p.edges)) { setTextErr(t('workflows.editor.textNeedsArrays', 'Needs "nodes" and "edges" arrays') as string); return; }
|
||||
if (p.nodes.filter((n: any) => n.type === 'trigger').length !== 1) { setTextErr(t('workflows.editor.textNeedsTrigger', 'Needs exactly one trigger node') as string); return; }
|
||||
const tt = p.trigger_type || triggerType;
|
||||
if (p.name != null) setName(p.name);
|
||||
if (p.trigger_type) setTriggerType(p.trigger_type);
|
||||
if (p.trigger_config && typeof p.trigger_config === 'object') setTriggerConfig(p.trigger_config);
|
||||
if (p.enabled != null) setEnabled(!!p.enabled);
|
||||
setNodes(p.nodes.map((n: any) => ({
|
||||
id: n.node_key, type: 'wf', position: { x: n.pos_x || 0, y: n.pos_y || 0 },
|
||||
data: { nodeType: n.type, config: n.config || {}, triggerType: tt },
|
||||
})));
|
||||
setEdges(p.edges.map((e: any, i: number) => ({
|
||||
id: `e${i}`, source: e.from_node, target: e.to_node, sourceHandle: e.from_handle || undefined, label: e.from_handle || undefined,
|
||||
})));
|
||||
setTextErr(null);
|
||||
setTextMode(false);
|
||||
toast.success(t('workflows.editor.textLoaded', 'Loaded — review and Save') as string);
|
||||
};
|
||||
|
||||
const copyText = () => { navigator.clipboard?.writeText(text); toast.success(t('common.copied', 'Copied') as string); };
|
||||
|
||||
useEffect(() => {
|
||||
if (!workflow) return;
|
||||
setName(workflow.name);
|
||||
setTriggerType(workflow.trigger_type);
|
||||
setTriggerConfig((workflow.trigger_config as Record<string, any>) || {});
|
||||
setEnabled(workflow.enabled === true || workflow.enabled === 1);
|
||||
setNodes(workflow.nodes.map((n) => ({
|
||||
id: n.node_key,
|
||||
type: 'wf',
|
||||
position: { x: n.pos_x || 0, y: n.pos_y || 0 },
|
||||
data: { nodeType: n.type, config: n.config || {}, triggerType: workflow.trigger_type },
|
||||
})));
|
||||
setEdges(workflow.edges.map((e, i) => ({
|
||||
id: `e${i}`,
|
||||
source: e.from_node,
|
||||
target: e.to_node,
|
||||
sourceHandle: e.from_handle || undefined,
|
||||
label: e.from_handle || undefined,
|
||||
})));
|
||||
}, [workflow, setNodes, setEdges]);
|
||||
|
||||
// Keep the trigger node's label in sync when the trigger is changed in the toolbar.
|
||||
useEffect(() => {
|
||||
setNodes((nds) => nds.map((n) => (n.data?.nodeType === 'trigger' ? { ...n, data: { ...n.data, triggerType } } : n)));
|
||||
}, [triggerType, setNodes]);
|
||||
|
||||
const onConnect = useCallback((c: Connection) => {
|
||||
setEdges((eds) => addEdge({ ...c, label: c.sourceHandle || undefined }, eds));
|
||||
}, [setEdges]);
|
||||
|
||||
const addNode = (type: WorkflowNodeType) => {
|
||||
const key = type === 'trigger' ? `trigger_${counter}` : `${type}_${counter}`;
|
||||
setCounter((c) => c + 1);
|
||||
setNodes((nds) => nds.concat({
|
||||
id: key, type: 'wf', position: { x: 140 + Math.random() * 220, y: 140 + Math.random() * 220 },
|
||||
data: { nodeType: type, config: {}, triggerType },
|
||||
}));
|
||||
};
|
||||
|
||||
const selectedNode = useMemo(() => nodes.find((n) => n.id === selectedId) || null, [nodes, selectedId]);
|
||||
|
||||
const updateNodeConfig = (nodeId: string, config: Record<string, unknown>) => {
|
||||
setNodes((nds) => nds.map((n) => (n.id === nodeId ? { ...n, data: { ...n.data, config } } : n)));
|
||||
};
|
||||
|
||||
const deleteSelected = () => {
|
||||
if (!selectedId) return;
|
||||
setNodes((nds) => nds.filter((n) => n.id !== selectedId));
|
||||
setEdges((eds) => eds.filter((e) => e.source !== selectedId && e.target !== selectedId));
|
||||
setSelectedId(null);
|
||||
};
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => workflowsService.update(workflowId, {
|
||||
name: name.trim() || 'Untitled',
|
||||
trigger_type: triggerType,
|
||||
trigger_config: triggerConfig,
|
||||
enabled,
|
||||
nodes: nodes.map((n) => ({
|
||||
node_key: n.id, type: (n.data as any).nodeType, config: (n.data as any).config || {},
|
||||
pos_x: Math.round(n.position.x), pos_y: Math.round(n.position.y),
|
||||
})),
|
||||
edges: edges.map((e) => ({ from_node: e.source, from_handle: e.sourceHandle || null, to_node: e.target })),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['workflow', workflowId] });
|
||||
qc.invalidateQueries({ queryKey: ['workflows'] });
|
||||
toast.success(t('workflows.editor.saved', 'Workflow saved') as string);
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || (t('workflows.editor.saveFailed', 'Could not save') as string)),
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="p-10"><Loading /></div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/admin/workflows')} aria-label={t('common.back', 'Back') as string}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<input
|
||||
value={name} onChange={(e) => setName(e.target.value)}
|
||||
className="px-2 py-1 rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100"
|
||||
placeholder={t('workflows.editor.namePlaceholder', 'Workflow name') as string}
|
||||
/>
|
||||
<label className="text-sm text-neutral-600 dark:text-neutral-400">{t('workflows.editor.when', 'When')}</label>
|
||||
<select
|
||||
value={triggerType} onChange={(e) => setTriggerType(e.target.value)}
|
||||
className="px-2 py-1 rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 text-sm"
|
||||
>
|
||||
{TRIGGERS.map((tr) => <option key={tr} value={tr}>{tr}</option>)}
|
||||
</select>
|
||||
{triggerType === 'event.date_approaching' && (
|
||||
<label className="text-sm text-neutral-600 dark:text-neutral-400 flex items-center gap-1.5">
|
||||
{t('workflows.editor.daysBefore', 'days before event')}
|
||||
<input
|
||||
type="number" min={0} max={365}
|
||||
value={triggerConfig.daysBefore ?? 2}
|
||||
onChange={(e) => setTriggerConfig((c) => ({ ...c, daysBefore: Number(e.target.value) }))}
|
||||
className="w-16 px-2 py-1 rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 text-sm"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="text-sm text-neutral-700 dark:text-neutral-300 flex items-center gap-1.5">
|
||||
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||
{t('workflows.enabled', 'Enabled')}
|
||||
</label>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => (textMode ? setTextMode(false) : openText())} leftIcon={<Code className="w-4 h-4" />}>
|
||||
{textMode ? t('workflows.editor.canvasView', 'Canvas') : t('workflows.editor.textView', 'Text')}
|
||||
</Button>
|
||||
<Button variant="primary" isLoading={saveMutation.isPending} onClick={() => saveMutation.mutate()} leftIcon={<Save className="w-4 h-4" />}>
|
||||
{t('common.saveChanges', 'Save changes')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!textMode && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{PALETTE.map((type) => (
|
||||
<button
|
||||
key={type} type="button" onClick={() => addNode(type)}
|
||||
className="text-xs px-2 py-1 rounded border border-neutral-300 dark:border-neutral-600 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-800"
|
||||
>
|
||||
+ {type}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button" onClick={cleanUp}
|
||||
className="ml-auto inline-flex items-center gap-1 text-xs px-2 py-1 rounded border border-neutral-300 dark:border-neutral-600 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<Wand2 className="w-3.5 h-3.5" /> {t('workflows.editor.cleanUp', 'Clean up layout')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{textMode ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('workflows.editor.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.')}
|
||||
</p>
|
||||
<textarea
|
||||
value={text} onChange={(e) => setText(e.target.value)} spellCheck={false}
|
||||
className="w-full font-mono text-xs p-2 rounded border border-neutral-300 dark:border-neutral-600 bg-neutral-50 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
|
||||
style={{ height: '62vh' }}
|
||||
/>
|
||||
{textErr && <p className="text-xs text-red-600 dark:text-red-400">{textErr}</p>}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={copyText}>{t('common.copy', 'Copy')}</Button>
|
||||
<Button variant="primary" onClick={applyText}>{t('workflows.editor.loadText', 'Load into editor')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-3" style={{ height: '70vh' }}>
|
||||
<div className="flex-1 rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
|
||||
<ReactFlow
|
||||
colorMode={isDark ? 'dark' : 'light'}
|
||||
nodes={nodes} edges={edges} nodeTypes={nodeTypes}
|
||||
onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect}
|
||||
onNodeClick={(_, n) => setSelectedId(n.id)} onInit={(inst) => { rfRef.current = inst; }} fitView
|
||||
>
|
||||
<Background />
|
||||
<Controls />
|
||||
<MiniMap pannable zoomable />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
{selectedNode && (
|
||||
<div className="w-80 rounded-lg border border-neutral-200 dark:border-neutral-700 p-3 space-y-3 bg-white dark:bg-neutral-900 overflow-y-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{(selectedNode.data as any).nodeType} · {selectedNode.id}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={deleteSelected} aria-label={t('common.delete', 'Delete') as string}>
|
||||
<Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||
</Button>
|
||||
</div>
|
||||
<NodeConfigPanel
|
||||
nodeType={(selectedNode.data as any).nodeType}
|
||||
config={(selectedNode.data as any).config || {}}
|
||||
onChange={(cfg) => updateNodeConfig(selectedNode.id, cfg)}
|
||||
webhooks={webhooks}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Admin → Workflows list. Shows every automation flow with its trigger and
|
||||
* enabled state, a quick enable toggle, edit (canvas) and delete. "New
|
||||
* workflow" mints a minimal trigger→action graph and opens the editor. A
|
||||
* pending-approvals shortcut sits in the header.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Plus, Workflow as WorkflowIcon, Inbox, Trash2, Pencil, FlaskConical } from 'lucide-react';
|
||||
import { Button, Card, Loading } from '../../../components/common';
|
||||
import { workflowsService, type WorkflowSummary, type WorkflowSavePayload, type WorkflowTestResult } from '../../../services/workflows.service';
|
||||
|
||||
const NEW_WORKFLOW: WorkflowSavePayload = {
|
||||
name: 'New workflow',
|
||||
trigger_type: 'invoice.sent',
|
||||
enabled: false,
|
||||
nodes: [
|
||||
{ node_key: 'trigger', type: 'trigger', pos_x: 240, pos_y: 40 },
|
||||
{ node_key: 'step1', type: 'action', config: { action: 'noop' }, pos_x: 240, pos_y: 200 },
|
||||
],
|
||||
edges: [{ from_node: 'trigger', to_node: 'step1' }],
|
||||
};
|
||||
|
||||
export const WorkflowsListPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data: workflows, isLoading } = useQuery({
|
||||
queryKey: ['workflows'],
|
||||
queryFn: () => workflowsService.list(),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => workflowsService.create(NEW_WORKFLOW),
|
||||
onSuccess: (res) => {
|
||||
qc.invalidateQueries({ queryKey: ['workflows'] });
|
||||
navigate(`/admin/workflows/${res.id}`);
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || (t('workflows.toast.createFailed', 'Could not create workflow') as string)),
|
||||
});
|
||||
|
||||
const [testTarget, setTestTarget] = useState<WorkflowSummary | null>(null);
|
||||
const [testEntityId, setTestEntityId] = useState('');
|
||||
const [testResult, setTestResult] = useState<WorkflowTestResult | null>(null);
|
||||
const testMutation = useMutation({
|
||||
mutationFn: () => workflowsService.testRun(testTarget!.id, {
|
||||
entityId: testEntityId ? Number(testEntityId) : null,
|
||||
dryRun: true,
|
||||
}),
|
||||
onSuccess: (res) => setTestResult(res),
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || (t('workflows.test.failed', 'Test run failed') as string)),
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) => workflowsService.setEnabled(id, enabled),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['workflows'] }),
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || (t('common.error', 'Something went wrong') as string)),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => workflowsService.remove(id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['workflows'] });
|
||||
toast.success(t('workflows.toast.deleted', 'Workflow deleted') as string);
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || (t('workflows.toast.deleteFailed', 'Could not delete workflow') as string)),
|
||||
});
|
||||
|
||||
const isEnabled = (w: WorkflowSummary) => w.enabled === true || w.enabled === 1;
|
||||
const isBuiltin = (w: WorkflowSummary) => w.is_builtin === true || w.is_builtin === 1;
|
||||
|
||||
// Disabling a built-in reverts to the legacy hardcoded behaviour (it does NOT
|
||||
// stop the automation) — warn so the admin isn't surprised. Enabling is guarded
|
||||
// server-side (a flow using unimplemented actions is refused with a clear error).
|
||||
const toggle = (w: WorkflowSummary) => {
|
||||
const next = !isEnabled(w);
|
||||
if (!next && isBuiltin(w)) {
|
||||
const msg = t('workflows.toggle.confirmDisableBuiltin',
|
||||
'Disabling this built-in reverts to the previous built-in behaviour — it does not turn the automation off. Continue?') as string;
|
||||
if (!window.confirm(msg)) return;
|
||||
}
|
||||
toggleMutation.mutate({ id: w.id, enabled: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-accent-soft text-on-accent-soft flex items-center justify-center">
|
||||
<WorkflowIcon className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">{t('workflows.title', 'Workflows')}</h1>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('workflows.subtitle', 'Visual automations — triggers, conditions, gates and actions.')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => navigate('/admin/workflows/approvals')} leftIcon={<Inbox className="w-4 h-4" />}>
|
||||
{t('workflows.approvals.title', 'Approvals')}
|
||||
</Button>
|
||||
<Button variant="primary" isLoading={createMutation.isPending} onClick={() => createMutation.mutate()} leftIcon={<Plus className="w-4 h-4" />}>
|
||||
{t('workflows.new', 'New workflow')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card padding="none">
|
||||
{isLoading ? (
|
||||
<div className="p-10"><Loading /></div>
|
||||
) : !workflows || workflows.length === 0 ? (
|
||||
<div className="p-10 text-center text-neutral-500 dark:text-neutral-400">{t('workflows.empty', 'No workflows yet. Create one to automate your invoicing and booking steps.')}</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||
{workflows.map((w) => (
|
||||
<li key={w.id} className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to={`/admin/workflows/${w.id}`} className="font-medium text-neutral-900 dark:text-neutral-100 truncate hover:underline">{w.name}</Link>
|
||||
{isBuiltin(w) && (
|
||||
<span className="text-[11px] px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-300">{t('workflows.builtin', 'built-in')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">
|
||||
{t('workflows.triggerLabel', 'Trigger')}: <code>{w.trigger_type}</code> · v{w.version}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(w)}
|
||||
className={`text-xs px-2 py-1 rounded-full border ${isEnabled(w)
|
||||
? 'bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 border-green-300 dark:border-green-700'
|
||||
: 'bg-neutral-50 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400 border-neutral-300 dark:border-neutral-600'}`}
|
||||
>
|
||||
{isEnabled(w) ? t('workflows.enabled', 'Enabled') : t('workflows.disabled', 'Disabled')}
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" onClick={() => { setTestResult(null); setTestEntityId(''); setTestTarget(w); }} aria-label={t('workflows.test.title', 'Test run') as string}>
|
||||
<FlaskConical className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate(`/admin/workflows/${w.id}`)} aria-label={t('common.edit', 'Edit') as string}>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
{!isBuiltin(w) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => { if (window.confirm(t('workflows.confirmDelete', 'Delete this workflow?') as string)) deleteMutation.mutate(w.id); }}
|
||||
aria-label={t('common.delete', 'Delete') as string}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{testTarget && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={() => setTestTarget(null)}>
|
||||
<div className="w-full max-w-lg rounded-lg bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 p-4 space-y-3" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('workflows.test.title', 'Test run')} — {testTarget.name}</h2>
|
||||
<button type="button" onClick={() => setTestTarget(null)} aria-label={t('common.close', 'Close') as string} className="text-neutral-500 dark:text-neutral-400">✕</button>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('workflows.test.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.')}
|
||||
</p>
|
||||
<input
|
||||
value={testEntityId} onChange={(e) => setTestEntityId(e.target.value)}
|
||||
placeholder={t('workflows.test.entityId', 'Entity id (optional, e.g. invoice id)') as string}
|
||||
className="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"
|
||||
/>
|
||||
<Button variant="primary" isLoading={testMutation.isPending} onClick={() => testMutation.mutate()}>
|
||||
{t('workflows.test.run', 'Run dry test')}
|
||||
</Button>
|
||||
{testResult && (
|
||||
<div className="mt-2">
|
||||
<div className="text-sm mb-1 text-neutral-700 dark:text-neutral-300">
|
||||
{t('workflows.test.result', 'Result')}: <span className="font-medium">{testResult.status}</span>
|
||||
</div>
|
||||
<ol className="text-xs space-y-1 max-h-72 overflow-y-auto">
|
||||
{testResult.steps.map((s, i) => (
|
||||
<li key={i} className="flex items-start gap-2 border-b border-neutral-100 dark:border-neutral-800 pb-1">
|
||||
<span className="text-neutral-400 w-6 shrink-0">{i + 1}.</span>
|
||||
<span className="font-mono text-neutral-700 dark:text-neutral-300">{s.node_type}:{s.node_key}</span>
|
||||
<span className="text-neutral-500 dark:text-neutral-400">{s.status}</span>
|
||||
{s.result && (s.result as any).would ? <span className="text-purple-600 dark:text-purple-400">→ would {String((s.result as any).would)}</span> : null}
|
||||
{s.error ? <span className="text-red-600 dark:text-red-400">{s.error}</span> : null}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -66,7 +66,11 @@ export type FeatureKey =
|
||||
| 'whatsapp'
|
||||
// Live Slideshow ("Diashow") — per-event fullscreen kiosk link + presets +
|
||||
// global watermark settings tab. Strictly opt-in; gates all slideshow UI.
|
||||
| 'slideshow';
|
||||
| 'slideshow'
|
||||
// Workflow / automation engine — admin-configurable visual flows (triggers,
|
||||
// conditions, branches, loops, approval gates) built on a canvas. Strictly
|
||||
// opt-in; gates the Workflows admin area and the engine runtime.
|
||||
| 'workflows';
|
||||
|
||||
export type FeatureFlags = Record<FeatureKey, boolean>;
|
||||
|
||||
|
||||
@@ -63,6 +63,8 @@ export interface QuoteSummary {
|
||||
validUntil: string | null;
|
||||
eventName: string | null;
|
||||
eventDate: string | null;
|
||||
eventType: string | null;
|
||||
bookingWorkflowId: number | null;
|
||||
totalAmountMinor: number;
|
||||
sentAt: string | null;
|
||||
acceptedAt: string | null;
|
||||
@@ -178,6 +180,8 @@ export interface QuoteCreatePayload {
|
||||
validUntil?: string;
|
||||
eventName?: string;
|
||||
eventDate?: string;
|
||||
eventType?: string | null;
|
||||
bookingWorkflowId?: number | null;
|
||||
eventTimeStart?: string;
|
||||
eventTimeEnd?: string;
|
||||
expectedDurationHours?: number;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { api } from '../config/api';
|
||||
|
||||
export type WorkflowNodeType =
|
||||
| 'trigger' | 'condition' | 'branch' | 'loop' | 'wait' | 'action' | 'gate' | 'webhook';
|
||||
|
||||
export interface WorkflowNode {
|
||||
id?: number;
|
||||
node_key: string;
|
||||
type: WorkflowNodeType;
|
||||
config?: Record<string, unknown>;
|
||||
pos_x?: number;
|
||||
pos_y?: number;
|
||||
}
|
||||
|
||||
export interface WorkflowEdge {
|
||||
id?: number;
|
||||
from_node: string;
|
||||
from_handle?: string | null;
|
||||
to_node: string;
|
||||
label?: string | null;
|
||||
loop_back?: boolean;
|
||||
}
|
||||
|
||||
export interface WorkflowSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
enabled: boolean | number;
|
||||
version: number;
|
||||
trigger_type: string;
|
||||
is_builtin?: boolean | number;
|
||||
builtin_key?: string | null;
|
||||
}
|
||||
|
||||
export interface WorkflowDetail extends WorkflowSummary {
|
||||
trigger_config?: Record<string, unknown> | null;
|
||||
nodes: WorkflowNode[];
|
||||
edges: WorkflowEdge[];
|
||||
}
|
||||
|
||||
export interface WorkflowRun {
|
||||
id: number;
|
||||
workflow_id: number;
|
||||
version: number;
|
||||
trigger_event: string;
|
||||
entity_type?: string | null;
|
||||
entity_id?: number | null;
|
||||
status: string;
|
||||
current_node?: string | null;
|
||||
started_at?: string;
|
||||
finished_at?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface WorkflowApproval {
|
||||
id: number;
|
||||
type: string;
|
||||
payload: Record<string, unknown>;
|
||||
created_at: string;
|
||||
expires_at?: string | null;
|
||||
run_id: number;
|
||||
entity_type?: string | null;
|
||||
entity_id?: number | null;
|
||||
workflow_id: number;
|
||||
workflow_name: string;
|
||||
}
|
||||
|
||||
export interface WorkflowSavePayload {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
enabled?: boolean;
|
||||
trigger_type: string;
|
||||
trigger_config?: Record<string, unknown> | null;
|
||||
nodes: WorkflowNode[];
|
||||
edges: WorkflowEdge[];
|
||||
}
|
||||
|
||||
export const workflowsService = {
|
||||
list: async (): Promise<WorkflowSummary[]> => (await api.get<WorkflowSummary[]>('/admin/workflows')).data,
|
||||
get: async (id: number): Promise<WorkflowDetail> => (await api.get<WorkflowDetail>(`/admin/workflows/${id}`)).data,
|
||||
create: async (payload: WorkflowSavePayload): Promise<{ id: number }> =>
|
||||
(await api.post<{ id: number }>('/admin/workflows', payload)).data,
|
||||
update: async (id: number, payload: WorkflowSavePayload): Promise<{ id: number; version: number }> =>
|
||||
(await api.put<{ id: number; version: number }>(`/admin/workflows/${id}`, payload)).data,
|
||||
setEnabled: async (id: number, enabled: boolean) =>
|
||||
(await api.patch(`/admin/workflows/${id}/enabled`, { enabled })).data,
|
||||
remove: async (id: number) => (await api.delete(`/admin/workflows/${id}`)).data,
|
||||
runs: async (id: number): Promise<WorkflowRun[]> => (await api.get<WorkflowRun[]>(`/admin/workflows/${id}/runs`)).data,
|
||||
approvals: async (): Promise<WorkflowApproval[]> => (await api.get<WorkflowApproval[]>('/admin/workflows/approvals')).data,
|
||||
actApproval: async (id: number, action: 'confirm' | 'deny') =>
|
||||
(await api.post(`/admin/workflows/approvals/${id}/${action}`)).data,
|
||||
testRun: async (
|
||||
id: number,
|
||||
body: { entityType?: string | null; entityId?: number | null; payload?: Record<string, unknown>; dryRun?: boolean },
|
||||
): Promise<WorkflowTestResult> => (await api.post<WorkflowTestResult>(`/admin/workflows/${id}/test-run`, body)).data,
|
||||
};
|
||||
|
||||
export interface WorkflowTestStep {
|
||||
node_key: string;
|
||||
node_type?: string | null;
|
||||
status: string;
|
||||
result?: Record<string, unknown> | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface WorkflowTestResult {
|
||||
runId: number;
|
||||
dryRun: boolean;
|
||||
status: string;
|
||||
steps: WorkflowTestStep[];
|
||||
}
|
||||
Reference in New Issue
Block a user