From 96fb44045e49c4cbc9b221780f7c725f80751993 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:28:02 +0200 Subject: [PATCH] feat(workflows): data-touching action + condition handlers Adds send_email (INTERNAL/admin = immediate, EXTERNAL/customer = business- hours floor via queueEmail's respectBusinessHours) and the invoice_paid condition (paid_at / status / cumulative paid_amount). Registers the prepare_quote/contract/event/gallery/invoice + send_document + reserve_date document actions as recognized-but-not-yet-wired (record an observable skipped step rather than crashing a flow). index.js side-effect-imports the handlers. Tests cover the customer-mail routing + the invoice_paid logic. --- .../integration/workflowEngine.test.js | 29 +++++++ backend/src/services/workflows/actions.js | 78 +++++++++++++++++++ backend/src/services/workflows/index.js | 3 + 3 files changed, 110 insertions(+) create mode 100644 backend/src/services/workflows/actions.js diff --git a/backend/__tests__/integration/workflowEngine.test.js b/backend/__tests__/integration/workflowEngine.test.js index 433b169c..a448a9c7 100644 --- a/backend/__tests__/integration/workflowEngine.test.js +++ b/backend/__tests__/integration/workflowEngine.test.js @@ -158,4 +158,33 @@ describe('workflow engine', () => { 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); + }); }); diff --git a/backend/src/services/workflows/actions.js b/backend/src/services/workflows/actions.js new file mode 100644 index 00000000..7b3077cd --- /dev/null +++ b/backend/src/services/workflows/actions.js @@ -0,0 +1,78 @@ +/** + * 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 || {}; + 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 }; +}); + +// Create/prepare-document actions — registered so flows referencing them are +// valid; service wiring is a follow-up. Records a skipped step (observable). +for (const key of DOCUMENT_ACTIONS) { + registry.registerAction(key, async (ctx) => { + ctx.logger?.warn?.('[workflow] document action not yet wired', { action: key, runId: ctx.run.id }); + return { skipped: true, reason: `action ${key} not yet implemented` }; + }); +} + +module.exports = { DOCUMENT_ACTIONS }; diff --git a/backend/src/services/workflows/index.js b/backend/src/services/workflows/index.js index cfff36bf..1c386ed3 100644 --- a/backend/src/services/workflows/index.js +++ b/backend/src/services/workflows/index.js @@ -9,6 +9,9 @@ */ 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'); module.exports = { ...engine,