From 182e655fcf0fa119714e6fd34cad5f33d0087045 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:11:18 +0200 Subject: [PATCH] feat(workflows): invoice prepared+approved early, dispatch waits; daysBefore in editor; dashboard approvals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Booking built-ins reordered: prepare the invoice EARLY (admin adjusts line items), admin approves at the review gate whenever, then the wait holds dispatch until the event date and it sends itself. prepInvoice → reviewInvoice → waitEvent → sendInvoice (both booking_full and booking_simple; v3). - Flow editor now reads/edits/saves trigger_config; the pre-event "days before event" lead time is editable in the canvas toolbar (was only in settings, which the cutover removed — closing that gap). - Dashboard: pending-approvals card under "Events Expiring Soon" (workflows flag + non-empty only), with inline Confirm/Deny. Confirms the design: a gate's confirm edge can feed a wait, so an admin OK before the event parks the run at the wait and the scheduler dispatches on the date. New test covers confirm-early-then-wait-dispatches. --- .../integration/workflowEngine.test.js | 47 +++++++++++- backend/src/services/_workflowSeedBoot.js | 51 +++++++------ frontend/src/i18n/locales/de.json | 6 +- frontend/src/i18n/locales/en.json | 6 +- frontend/src/pages/admin/AdminDashboard.tsx | 74 ++++++++++++++++++- .../admin/workflows/WorkflowEditorPage.tsx | 16 ++++ 6 files changed, 170 insertions(+), 30 deletions(-) diff --git a/backend/__tests__/integration/workflowEngine.test.js b/backend/__tests__/integration/workflowEngine.test.js index 0980c2cb..f7c43993 100644 --- a/backend/__tests__/integration/workflowEngine.test.js +++ b/backend/__tests__/integration/workflowEngine.test.js @@ -315,15 +315,19 @@ describe('workflow engine', () => { 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 ; reviewInvoice --confirm--> sendInvoice + // 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 === 'sendInvoice')).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 === 'sendInvoice')).toBe(true); + 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(); @@ -386,6 +390,43 @@ describe('workflow engine', () => { expect(res.sent).toBe(0); }); + 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', diff --git a/backend/src/services/_workflowSeedBoot.js b/backend/src/services/_workflowSeedBoot.js index 981aad27..37bad478 100644 --- a/backend/src/services/_workflowSeedBoot.js +++ b/backend/src/services/_workflowSeedBoot.js @@ -73,14 +73,14 @@ function buildBookingFullGraph() { { 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: 'waitEvent', type: 'wait', config: { untilVar: 'eventDate' }, pos_x: 320, pos_y: 660 }, - { node_key: 'prepInvoice', type: 'action', config: { action: 'prepare_invoice' }, pos_x: 320, pos_y: 770 }, - { node_key: 'reviewInvoice', type: 'gate', config: { label: 'Review invoice before sending' }, pos_x: 320, pos_y: 880 }, + { 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: 880 }, + { node_key: 'cancelInvoice', type: 'action', config: { action: 'noop' }, pos_x: 620, pos_y: 770 }, ]; const edges = [ { from_node: 't', to_node: 'prepContract' }, @@ -90,11 +90,13 @@ function buildBookingFullGraph() { { from_node: 'sendContract', to_node: 'gateSigned' }, { from_node: 'gateSigned', from_handle: 'confirm', to_node: 'prepEvent' }, { from_node: 'gateSigned', from_handle: 'deny', to_node: 'declined' }, - { from_node: 'prepEvent', to_node: 'waitEvent' }, - { from_node: 'waitEvent', to_node: 'prepInvoice' }, + // 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: 'sendInvoice' }, + { 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 }; @@ -108,20 +110,21 @@ 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: 'waitEvent', type: 'wait', config: { untilVar: 'eventDate' }, pos_x: 320, pos_y: 220 }, - { node_key: 'prepInvoice', type: 'action', config: { action: 'prepare_invoice' }, pos_x: 320, pos_y: 330 }, - { node_key: 'reviewInvoice', type: 'gate', config: { label: 'Review invoice before sending' }, pos_x: 320, pos_y: 440 }, + { 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: 440 }, + { node_key: 'cancelInvoice', type: 'action', config: { action: 'noop' }, pos_x: 620, pos_y: 330 }, ]; const edges = [ { from_node: 't', to_node: 'prepEvent' }, - { from_node: 'prepEvent', to_node: 'waitEvent' }, - { from_node: 'waitEvent', to_node: 'prepInvoice' }, + // 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: 'sendInvoice' }, + { 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 }; @@ -258,7 +261,7 @@ const BUILTINS = [ }, { key: 'booking_full', - version: 2, + version: 3, enabled: false, name: 'Booking — quote → contract → event → invoice (built-in)', trigger_type: 'quote.accepted', @@ -266,23 +269,25 @@ const BUILTINS = [ 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, wait to the shoot date, prepare the invoice and — after a ' - + 'second admin review gate — send it. No document is ever 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.', + + '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: 2, + 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, wait to the ' - + 'shoot date, prepare the invoice and — after an admin review gate — send it. Same ' - + 'review-before-send rule and stub caveat as the full booking flow; disabled by default.', + '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(), }, ]; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 39da90d2..d642b2a2 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -232,7 +232,10 @@ "confirm": "Bestätigen", "deny": "Ablehnen", "recorded": "Antwort gespeichert", - "defaultPrompt": "Ein Workflow benötigt deine Bestätigung." + "defaultPrompt": "Ein Workflow benötigt deine Bestätigung.", + "pendingTitle": "Offene Freigaben", + "viewAll": "Alle Freigaben ansehen", + "acted": "Erledigt" }, "editor": { "namePlaceholder": "Workflow-Name", @@ -248,6 +251,7 @@ "saved": "Workflow gespeichert", "saveFailed": "Speichern fehlgeschlagen", "badJson": "Konfiguration ist kein gültiges JSON", + "daysBefore": "Tage vor dem Anlass", "showAdvanced": "Erweitert (JSON)", "hideAdvanced": "Erweitert ausblenden (JSON)", "triggerHint": "Der Auslöser wird oben in der Leiste gesetzt (Wenn …).", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 4197e32b..17137338 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -232,7 +232,10 @@ "confirm": "Confirm", "deny": "Deny", "recorded": "Response recorded", - "defaultPrompt": "A workflow needs your confirmation." + "defaultPrompt": "A workflow needs your confirmation.", + "pendingTitle": "Pending approvals", + "viewAll": "View all approvals", + "acted": "Done" }, "editor": { "namePlaceholder": "Workflow name", @@ -248,6 +251,7 @@ "saved": "Workflow saved", "saveFailed": "Could not save", "badJson": "Config is not valid JSON", + "daysBefore": "days before event", "showAdvanced": "Advanced (JSON)", "hideAdvanced": "Hide advanced (JSON)", "triggerHint": "The trigger is set in the toolbar above (When …).", diff --git a/frontend/src/pages/admin/AdminDashboard.tsx b/frontend/src/pages/admin/AdminDashboard.tsx index 4fd0ee0f..142dc4f3 100644 --- a/frontend/src/pages/admin/AdminDashboard.tsx +++ b/frontend/src/pages/admin/AdminDashboard.tsx @@ -10,18 +10,24 @@ import { HardDrive, Image, Archive, - Heart + Heart, + Inbox, + Check, + X } from 'lucide-react'; import { differenceInDays, parseISO } from 'date-fns'; import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { Button, Card, Loading } from '../../components/common'; import { UpdateNotification } from '../../components/admin/UpdateNotification'; import { CrmOverviewSection } from '../../components/admin/CrmOverviewSection'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { eventsService } from '../../services/events.service'; import { adminService, ActivityType } from '../../services/admin.service'; +import { workflowsService } from '../../services/workflows.service'; +import { useFeatureFlags } from '../../contexts/FeatureFlagsContext'; interface StatCard { title: string; @@ -72,6 +78,24 @@ export const AdminDashboard: React.FC = () => { queryFn: () => eventsService.getEvents(1, 5, 'expiring'), }); + // Pending workflow approvals — only when the workflow engine is live. These + // are the human-in-the-loop gates (e.g. "review invoice before sending"). + const { flags } = useFeatureFlags(); + const qc = useQueryClient(); + const { data: pendingApprovals } = useQuery({ + queryKey: ['workflow-approvals'], + queryFn: () => workflowsService.approvals(), + enabled: !!flags.workflows, + }); + const approvalMutation = useMutation({ + mutationFn: ({ id, action }: { id: number; action: 'confirm' | 'deny' }) => workflowsService.actApproval(id, action), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['workflow-approvals'] }); + toast.success(t('workflows.approvals.acted', 'Done') as string); + }, + onError: (err: any) => toast.error(err?.response?.data?.error || (t('common.error', 'Something went wrong') as string)), + }); + const isLoading = statsLoading || eventsLoading; if (isLoading) { @@ -241,6 +265,52 @@ export const AdminDashboard: React.FC = () => { )} + + {/* Pending workflow approvals — the human-in-the-loop gates. Only + rendered when the workflow engine is live and something is waiting. */} + {!!flags.workflows && pendingApprovals && pendingApprovals.length > 0 && ( + + + {t('workflows.approvals.pendingTitle', 'Pending approvals')} + + + + {pendingApprovals.slice(0, 5).map((a) => { + const prompt = (a.payload as any)?.prompt as string | undefined; + return ( + + + {a.workflow_name} + + {prompt || a.type}{a.entity_type ? ` · ${a.entity_type} #${a.entity_id}` : ''} + + + + approvalMutation.mutate({ id: a.id, action: 'confirm' })} + leftIcon={}> + {t('workflows.approvals.confirm', 'Confirm')} + + approvalMutation.mutate({ id: a.id, action: 'deny' })} + leftIcon={}> + {t('workflows.approvals.deny', 'Deny')} + + + + ); + })} + + {pendingApprovals.length > 5 && ( + 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')} → + + )} + + )} {/* Recent Activity */} diff --git a/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx b/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx index a3b4ce05..6b6fee4a 100644 --- a/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx +++ b/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx @@ -130,6 +130,7 @@ export const WorkflowEditorPage: React.FC = () => { const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [name, setName] = useState(''); const [triggerType, setTriggerType] = useState('invoice.sent'); + const [triggerConfig, setTriggerConfig] = useState>({}); const [enabled, setEnabled] = useState(false); const [selectedId, setSelectedId] = useState(null); const [counter, setCounter] = useState(1); @@ -148,6 +149,7 @@ export const WorkflowEditorPage: React.FC = () => { 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 || {}, @@ -166,6 +168,7 @@ export const WorkflowEditorPage: React.FC = () => { 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 }, @@ -185,6 +188,7 @@ export const WorkflowEditorPage: React.FC = () => { if (!workflow) return; setName(workflow.name); setTriggerType(workflow.trigger_type); + setTriggerConfig((workflow.trigger_config as Record) || {}); setEnabled(workflow.enabled === true || workflow.enabled === 1); setNodes(workflow.nodes.map((n) => ({ id: n.node_key, @@ -236,6 +240,7 @@ export const WorkflowEditorPage: React.FC = () => { 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 || {}, @@ -271,6 +276,17 @@ export const WorkflowEditorPage: React.FC = () => { > {TRIGGERS.map((tr) => {tr})} + {triggerType === 'event.date_approaching' && ( + + {t('workflows.editor.daysBefore', 'days before event')} + 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" + /> + + )} setEnabled(e.target.checked)} /> {t('workflows.enabled', 'Enabled')}
+ {prompt || a.type}{a.entity_type ? ` · ${a.entity_type} #${a.entity_id}` : ''} +