From 0b6c33e59a2e1ba15210645a65800543eaf6305b Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:58:19 +0200 Subject: [PATCH] feat(workflows): hard cutover of gallery-expiry + dunning + pre-event to flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seed gallery_expiring / gallery_expired built-ins and make the live automations flow-owned, with zero feature loss: - New delegating actions (notify_gallery_expiring / notify_gallery_expired / notify_pre_event) call the EXISTING send functions, so the engine path is byte-identical to the legacy hourly checker/pass (same templates, recipients, variables, dedup, per-event overrides, sent_at idempotency). - Cutover built-ins (invoice_dunning, gallery_expiring, gallery_expired, pre_event_email) now ship ENABLED; booking flows stay disabled (stubs). - The legacy paths stand down via existence-based isBuiltinFlowPresent guards: once a built-in is seeded (flag on) the engine is the single switch — flow enabled = it sends, flow disabled = off — so no double-send and reminders/ expiry emails can still be fully turned off. - emitDueEventReminders now honours the per-event reminder controls (disabled / offset / sent_at) so pre-event timing is faithful; fixed a Number(null)===0 offset bug. Settings UI cutover is gated on the `workflows` flag (default off): when the engine is live, the dunning reminder schedule (CRM settings) and the pre-event global toggle (Reminder emails) are replaced with a "now in Workflows" callout; when it's off, the legacy controls stay so flag-off installs lose nothing. The late-fee math and installment-trigger defaults stay (fee math / scheduler-owned). Split/installment invoices intentionally remain scheduler-driven (no flow). --- .../integration/workflowEngine.test.js | 56 ++++- backend/src/services/_workflowSeedBoot.js | 155 ++++++++++---- backend/src/services/eventReminderService.js | 79 +++++++ backend/src/services/expirationChecker.js | 201 +++++++++++------- backend/src/services/invoiceService.js | 14 +- backend/src/services/workflows/actions.js | 40 ++++ backend/src/services/workflows/engine.js | 95 +++++++-- frontend/src/i18n/locales/de.json | 10 + frontend/src/i18n/locales/en.json | 10 + .../pages/admin/settings/CrmSettingsPage.tsx | 48 ++++- .../admin/settings/ReminderTemplatesPage.tsx | 99 +++++---- 11 files changed, 602 insertions(+), 205 deletions(-) diff --git a/backend/__tests__/integration/workflowEngine.test.js b/backend/__tests__/integration/workflowEngine.test.js index 38abaca3..0980c2cb 100644 --- a/backend/__tests__/integration/workflowEngine.test.js +++ b/backend/__tests__/integration/workflowEngine.test.js @@ -239,7 +239,7 @@ describe('workflow engine', () => { expect(again.already).toBe(true); }); - test('seeds the invoice-dunning built-in as the delegation graph (v2, disabled)', async () => { + test('seeds the invoice-dunning built-in as the delegation graph (v5, enabled cutover)', async () => { const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot'); const noopLogger = { info() {}, warn() {} }; await seedBuiltinWorkflowsAtBoot(db, noopLogger); @@ -247,8 +247,8 @@ describe('workflow engine', () => { 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); - expect(JSON.parse(wf.trigger_config).seedVersion).toBe(4); + expect(!!wf.enabled).toBe(true); // cutover: dunning ships live + expect(JSON.parse(wf.trigger_config).seedVersion).toBe(5); const nodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: wf.version }); expect(nodes.filter((n) => n.type === 'trigger')).toHaveLength(1); @@ -273,7 +273,8 @@ describe('workflow engine', () => { 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(4); + expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(5); + expect(!!reseeded.enabled).toBe(true); // cutover default applied on re-seed 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 @@ -285,13 +286,28 @@ describe('workflow engine', () => { expect(after.version).toBe(before.version); // unchanged }); - test('seeds the booking + pre-event built-ins (disabled, correct triggers)', async () => { + test('seeds the gallery, pre-event (enabled cutover) + booking (disabled) built-ins', async () => { const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot'); await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} }); + // Cutover flows ship ENABLED, delegating to the proven send functions. + const expiring = await db('workflows').where({ builtin_key: 'gallery_expiring' }).first(); + expect(expiring).toBeTruthy(); + expect(!!expiring.enabled).toBe(true); + 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(true); + 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); + const bookingFull = await db('workflows').where({ builtin_key: 'booking_full' }).first(); expect(bookingFull).toBeTruthy(); - expect(!!bookingFull.enabled).toBe(false); + 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); @@ -311,10 +327,11 @@ describe('workflow engine', () => { const preEvent = await db('workflows').where({ builtin_key: 'pre_event_email' }).first(); expect(preEvent).toBeTruthy(); + expect(!!preEvent.enabled).toBe(true); // cutover: pre-event reminder ships live expect(preEvent.trigger_type).toBe('event.date_approaching'); - expect(JSON.parse(preEvent.trigger_config).daysBefore).toBe(3); + 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 === 'send_email')).toBe(true); + 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 () => { @@ -346,6 +363,29 @@ describe('workflow engine', () => { expect(runs2.length).toBe(1); }); + test('isBuiltinFlowActive reflects the built-in enabled state (cutover guard)', async () => { + const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot'); + await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} }); + // Cutover built-ins ship enabled; booking stays disabled; unknown key → false. + expect(await engine.isBuiltinFlowActive('gallery_expiring')).toBe(true); + expect(await engine.isBuiltinFlowActive('pre_event_email')).toBe(true); + expect(await engine.isBuiltinFlowActive('booking_full')).toBe(false); + expect(await engine.isBuiltinFlowActive('does_not_exist')).toBe(false); + }); + + test('legacy event-reminder pass stands down when the pre_event_email flow is active', async () => { + const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot'); + await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} }); // pre_event_email enabled + // Reach the mutual-exclusion guard: the pass returns early on the global + // enable check unless the setting is on. + await db('app_settings') + .insert({ setting_key: 'crm_event_reminders_enabled', setting_value: JSON.stringify(true), setting_type: 'boolean' }) + .onConflict('setting_key').merge(); + const res = await require('../../src/services/eventReminderService').runEventReminderPass(); + expect(res.byWorkflow).toBe(true); + expect(res.sent).toBe(0); + }); + 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 741323ed..981aad27 100644 --- a/backend/src/services/_workflowSeedBoot.js +++ b/backend/src/services/_workflowSeedBoot.js @@ -127,33 +127,69 @@ function buildBookingSimpleGraph() { return { nodes, edges }; } -// Pre-event email — fired by the scheduler `daysBefore` the event date (see -// emitDueEventReminders in the engine). Sends a customer reminder, then a heads- -// up to the admin. Unlike the booking flows this uses the already-wired -// send_email action, so it is functional once enabled (the customer template -// `pre_event_reminder` should exist / be authored). +// 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: 'emailCustomer', type: 'action', config: { action: 'send_email', recipientClass: 'customer', emailType: 'pre_event_reminder' }, pos_x: 240, pos_y: 110 }, - { node_key: 'emailAdmin', type: 'action', config: { action: 'send_email', recipientClass: 'admin', emailType: 'pre_event_internal' }, pos_x: 240, pos_y: 220 }, - { node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 240, pos_y: 330 }, + { node_key: 'notify', type: 'action', config: { action: 'notify_pre_event' }, 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: 'emailCustomer' }, - { from_node: 'emailCustomer', to_node: 'emailAdmin' }, - { from_node: 'emailAdmin', to_node: 'done' }, + { 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 -// so a disabled, never-activated copy is re-seeded on boot. -// invoice_dunning v4 = collections handoff after the loop exhausts. +// (or to re-assert the default `enabled` state) so a never-admin-touched copy is +// re-seeded on boot. `enabled` is the cutover default: the live automations +// (dunning, gallery expiry, pre-event) ship ENABLED and their legacy hardcoded +// paths stand down (isBuiltinFlowActive guards), so behaviour is preserved with +// zero double-send. Illustrative/stub flows (booking) ship disabled. +// invoice_dunning v5 = enabled-by-default cutover (was v4: collections handoff). const BUILTINS = [ { key: DUNNING_KEY, - version: 4, + version: 5, + enabled: true, name: 'Invoice dunning (built-in)', trigger_type: 'invoice.sent', trigger_config: {}, @@ -161,9 +197,9 @@ const BUILTINS = [ '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; ' + + 'flow; after the cycles exhaust it hands the case to collections. ENABLED by default; ' + 'while it is enabled the hardcoded reminder ladder is skipped automatically, so the two ' - + 'never double-send.', + + 'never double-send. Reminder timing is now edited here (no longer in Settings → CRM).', build: async () => { const firstDays = Number(await getAppSetting('crm_invoices_reminder_first_days')) || 14; const secondDays = Number(await getAppSetting('crm_invoices_reminder_second_days')) || 30; @@ -171,9 +207,59 @@ const BUILTINS = [ return buildDunningGraph({ firstDays, gapDays, maxReminders: 3 }); }, }, + { + key: 'gallery_expiring', + version: 1, + enabled: true, + 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. ' + + 'ENABLED by default; it delegates to the same email the hourly expiration checker used to ' + + 'send, and that legacy email stands down while this flow is on (no double-send). Edit or ' + + 'extend it here (e.g. add a final-download nudge).', + build: async () => buildGalleryExpiringGraph(), + }, + { + key: 'gallery_expired', + version: 1, + enabled: true, + 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. ' + + 'ENABLED by default; delegates to the same email the expiration checker used to send, ' + + 'and that legacy email stands down while this flow is on. The gallery is still archived ' + + 'automatically regardless of this flow.', + build: async () => buildGalleryExpiredGraph(), + }, + { + key: 'pre_event_email', + version: 2, + enabled: true, + name: 'Pre-event reminder (built-in)', + trigger_type: 'event.date_approaching', + // daysBefore seeds the scheduler emitter from the current global setting so + // upgrades preserve timing; per-event offset overrides still win. This flow + // is now the source of truth for the lead time (was Settings → Reminder emails). + 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. ENABLED by ' + + 'default; the notify_pre_event action delegates to the proven reminder logic (per-type ' + + 'template, per-event override, send-once), and the legacy reminder pass stands down while ' + + 'this flow is on. 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: 2, + enabled: false, name: 'Booking — quote → contract → event → invoice (built-in)', trigger_type: 'quote.accepted', trigger_config: {}, @@ -189,6 +275,7 @@ const BUILTINS = [ { key: 'booking_simple', version: 2, + enabled: false, name: 'Booking — quote → event → invoice (built-in)', trigger_type: 'quote.accepted', trigger_config: {}, @@ -198,21 +285,6 @@ const BUILTINS = [ + 'review-before-send rule and stub caveat as the full booking flow; disabled by default.', build: async () => buildBookingSimpleGraph(), }, - { - key: 'pre_event_email', - version: 1, - name: 'Pre-event email (built-in)', - trigger_type: 'event.date_approaching', - // daysBefore drives the scheduler emitter — how many days before the event - // date the reminder fires. - trigger_config: { daysBefore: 3 }, - description: - 'A few days before the event date, send the customer a reminder and the admin a heads-up. ' - + 'Fired by the scheduler from the event date (daysBefore in the trigger config). Uses the ' - + 'wired send_email action, so it works once enabled and the pre_event_reminder template ' - + 'exists. Disabled by default.', - build: async () => buildPreEventEmailGraph(), - }, ]; let booted = false; @@ -240,14 +312,20 @@ async function writeGraph(trx, workflowId, version, nodes, edges) { async function seedOneBuiltin(db, logger, def) { const { nodes, edges } = await def.build(); - const triggerConfig = { ...(def.trigger_config || {}), seedVersion: def.version }; + 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) { - // Re-seed the graph only when (a) it has never been activated and (b) our - // seed version moved on. Once the admin enables it, it's their live flow — - // never overwrite it. + // Re-seed only a never-admin-activated copy whose SEED_VERSION moved on. An + // already-ENABLED built-in is the admin's live (possibly customised) flow — + // never overwrite it. The version bump carries the cutover default (incl. + // flipping a still-disabled flow to enabled); the cutover targets flows that + // shipped disabled and were never touched, so this leaves admin choices alone. const storedVersion = Number(parseSeedConfig(existing.trigger_config).seedVersion) || 0; const isEnabled = existing.enabled === true || existing.enabled === 1; if (isEnabled || storedVersion >= def.version) return; @@ -259,12 +337,13 @@ async function seedOneBuiltin(db, logger, def) { 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})`); + logger?.info?.(`Re-seeded built-in workflow: ${def.key} (v${def.version}, enabled=${defEnabled})`); return; } @@ -272,7 +351,7 @@ async function seedOneBuiltin(db, logger, def) { const ins = await trx('workflows').insert({ name: def.name, description: def.description, - enabled: false, + enabled: defEnabled, version: 1, trigger_type: def.trigger_type, trigger_config: JSON.stringify(triggerConfig), @@ -285,7 +364,7 @@ async function seedOneBuiltin(db, logger, def) { const workflowId = ins[0]?.id ?? ins[0]; await writeGraph(trx, workflowId, 1, nodes, edges); }); - logger?.info?.(`Seeded built-in workflow: ${def.key} (disabled)`); + logger?.info?.(`Seeded built-in workflow: ${def.key} (enabled=${defEnabled})`); } async function seedBuiltinWorkflowsAtBoot(db, logger) { diff --git a/backend/src/services/eventReminderService.js b/backend/src/services/eventReminderService.js index 74e4601b..07b92908 100644 --- a/backend/src/services/eventReminderService.js +++ b/backend/src/services/eventReminderService.js @@ -128,6 +128,17 @@ async function runEventReminderPass() { return { scanned: 0, sent: 0, skipped: 0, disabled: true }; } + // Mutual exclusion with the workflow engine: once the pre_event_email built-in + // is seeded (flag on), the engine OWNS the reminder — it sends via the + // notify_pre_event action when the flow is enabled, or nothing when the admin + // disabled it. Either way the legacy pass stands down so the two never + // double-send. Fails closed → legacy pass keeps running if the subsystem is down. + try { + if (await require('./workflows').isBuiltinFlowPresent('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'); @@ -254,8 +265,76 @@ 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) { + 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 }); + } + + const row = await db('events') + .leftJoin('customer_accounts', 'customer_accounts.id', 'events.customer_account_id') + .where('events.id', eventId) + .select( + 'events.id', 'events.event_name', 'events.event_type', 'events.event_date', + 'events.is_active', 'events.is_archived', + 'events.event_reminder_disabled', 'events.event_reminder_offset_days', + 'events.event_reminder_body_override', 'events.event_reminder_sent_at', + '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', + ) + .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.customer_email) 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 || ''; + + 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 }); + if (row.event_reminder_body_override) payload.body_override = row.event_reminder_body_override; + + await emailProcessor.queueEmail(row.id, customer.email, templateKey, payload); + await db('events').where({ id: row.id }).update({ event_reminder_sent_at: new Date() }); + return { sent: 1, skipped: 0 }; +} + module.exports = { runEventReminderPass, + sendReminderForEvent, // exported for tests _internal: { resolveTemplateKey, diff --git a/backend/src/services/expirationChecker.js b/backend/src/services/expirationChecker.js index 58d67688..f5db10d9 100644 --- a/backend/src/services/expirationChecker.js +++ b/backend/src/services/expirationChecker.js @@ -11,7 +11,7 @@ function startExpirationChecker() { cron.schedule('0 * * * *', async () => { await checkExpirations(); }); - + logger.info('Expiration checker started'); } @@ -19,7 +19,20 @@ 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. + // Existence-based: once the built-in is seeded the engine OWNS the email, so + // the legacy send stands down whether the flow is enabled (it sends) or + // disabled (admin turned it off). The trigger is still emitted regardless. + const { isBuiltinFlowPresent } = require('./workflows'); + const warningFlowOwns = await isBuiltinFlowPresent('gallery_expiring'); + const expiredFlowOwns = await isBuiltinFlowPresent('gallery_expired'); + // Check for events needing warning emails // Skip events with null expires_at (they never expire) const eventsNeedingWarning = await db('events') @@ -28,19 +41,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 +56,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; @@ -88,34 +136,52 @@ async function queueExpirationWarning(event) { // Relationship mail — hold to business hours (no-op unless configured). }, { respectBusinessHours: true }); - // Fire gallery.expiring so admins can build flows on the warning window - // (e.g. a final download nudge). Best-effort; emit is fail-closed when the - // workflows flag is off, and deduped per (workflow, event) so the hourly - // sweep fires the trigger at most once per gallery. - try { - 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: recipientEmail, - adminEmail: event.admin_email || null, - galleryLink: shareUrl, - }, - }); - } catch (err) { - logger.warn('Failed to emit gallery.expiring workflow event', { eventId: event.id, error: err.message }); - } - 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) }); @@ -144,41 +210,8 @@ 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' - }); - } - - // Fire gallery.expired for the workflow engine (sibling to the - // event.expired webhook above). Best-effort / fail-closed; emitted BEFORE - // archiveEvent so flows see expired→archived in order. + // 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', @@ -189,7 +222,7 @@ async function handleExpiredEvent(event) { eventName: event.event_name, eventDate: event.event_date, expiresAt: event.expires_at, - customerEmail: recipientEmail, + customerEmail: event.customer_email || event.host_email || null, adminEmail: event.admin_email || null, }, }); @@ -197,7 +230,13 @@ async function handleExpiredEvent(event) { 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}`); @@ -206,4 +245,10 @@ async function handleExpiredEvent(event) { } } -module.exports = { startExpirationChecker }; +module.exports = { + startExpirationChecker, + // Reused by the workflow notify_gallery_* actions so the engine path sends the + // exact same emails as the legacy hourly checker. + queueExpirationWarning, + sendGalleryExpiredEmails, +}; diff --git a/backend/src/services/invoiceService.js b/backend/src/services/invoiceService.js index 6528698b..3847ca83 100644 --- a/backend/src/services/invoiceService.js +++ b/backend/src/services/invoiceService.js @@ -3447,16 +3447,14 @@ 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'); - // Mutual exclusion with the workflow engine: when the `workflows` flag is on - // AND the invoice_dunning built-in is enabled, the engine fires the same - // payment-check emails — running this hardcoded ladder too would double-send. + // Mutual exclusion with the workflow engine: once the invoice_dunning built-in + // is seeded (flag on), the engine OWNS dunning and is the single switch — it + // fires the payment-check emails when the flow is enabled, or nothing when the + // admin disabled it. Either way the hardcoded ladder stands down so the two + // never double-send. Fails closed → ladder stays on if the subsystem is down. let engineDrivesDunning = false; try { - const { isFeatureEnabled } = require('../middleware/requireFeatureFlag'); - if (await isFeatureEnabled('workflows')) { - const dunning = await db('workflows').where({ builtin_key: 'invoice_dunning', enabled: true }).first(); - engineDrivesDunning = !!dunning; - } + engineDrivesDunning = await require('./workflows').isBuiltinFlowPresent('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; diff --git a/backend/src/services/workflows/actions.js b/backend/src/services/workflows/actions.js index b205ae36..3beac482 100644 --- a/backend/src/services/workflows/actions.js +++ b/backend/src/services/workflows/actions.js @@ -135,6 +135,46 @@ registry.registerAction('escalate_to_collections', async (ctx) => { 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' }; + if (ctx.vars?.__dryRun) return { dryRun: true, would: 'notify_pre_event', eventId: id }; + const res = await require('../eventReminderService').sendReminderForEvent(id); + return res; +}); + // 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) { diff --git a/backend/src/services/workflows/engine.js b/backend/src/services/workflows/engine.js index 11d90dbe..2e2f76c5 100644 --- a/backend/src/services/workflows/engine.js +++ b/backend/src/services/workflows/engine.js @@ -381,17 +381,54 @@ async function recoverStaleRuns({ staleMs = RECOVERY_STALE_MS, limit = 50 } = {} } /** - * Emit `event.date_approaching` for events whose date is within the configured - * lead window of an enabled pre-event flow. Iterates per flow so each respects - * its own trigger_config.daysBefore; emitWorkflowEvent's per-(flow,entity) - * dedup_key guarantees a single run per event, so polling hourly never - * duplicates. Fails CLOSED when the workflows flag is off. Called from the - * scheduler tick. - * - * Caveat (v1): emitWorkflowEvent fans out to ALL enabled flows of this trigger, - * so with multiple pre-event flows the widest window wins for surfacing an - * event; a narrower flow may then fire earlier than its own daysBefore. The - * single-built-in case (the norm) is exact. + * 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; + } +} + +/** + * True when the workflows flag is on AND a built-in flow with this key EXISTS + * (enabled or not). The legacy automations use this to decide whether the engine + * OWNS the automation — once the built-in is seeded, the engine is the single + * switch: the legacy path stands down whether the flow is enabled (the flow + * sends) or disabled (the admin turned it off → nothing sends). Distinct from + * isBuiltinFlowActive, which asks whether the flow is currently firing. Fails + * CLOSED (false) so the legacy path keeps running if the subsystem is down. + */ +async function isBuiltinFlowPresent(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 }).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 { @@ -404,6 +441,9 @@ async function emitDueEventReminders(limit = 200) { 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; @@ -414,22 +454,40 @@ async function emitDueEventReminders(limit = 200) { } } catch (_) { /* best-effort */ } + const now = Date.now(); + const todayIso = new Date(now).toISOString().slice(0, 10); let emitted = 0; - const todayIso = new Date().toISOString().slice(0, 10); for (const wf of flows) { const cfg = parseJson(wf.trigger_config, {}); const daysBefore = Number(cfg.daysBefore) > 0 ? Number(cfg.daysBefore) : 3; - const windowEndIso = new Date(Date.now() + daysBefore * 86400000).toISOString().slice(0, 10); + // 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); - const events = await db('events') + let q = db('events') .where('is_active', true) .where('is_archived', false) .whereNotNull('event_date') .where('event_date', '>=', todayIso) - .where('event_date', '<=', windowEndIso) - .limit(limit); + .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, @@ -437,10 +495,11 @@ async function emitDueEventReminders(limit = 200) { 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, + daysBefore: offset, }, }); emitted += runIds.length; @@ -484,6 +543,8 @@ async function testRun(workflowId, { entityType = null, entityId = null, payload module.exports = { emitWorkflowEvent, + isBuiltinFlowActive, + isBuiltinFlowPresent, runDueWaits, emitDueEventReminders, recoverStaleRuns, diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 2bf9925d..39da90d2 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -4920,6 +4920,11 @@ "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" }, @@ -5023,6 +5028,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", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 4db1229e..4197e32b 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -4918,6 +4918,11 @@ "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" }, @@ -5021,6 +5026,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", diff --git a/frontend/src/pages/admin/settings/CrmSettingsPage.tsx b/frontend/src/pages/admin/settings/CrmSettingsPage.tsx index 1488fd89..15fbbd27 100644 --- a/frontend/src/pages/admin/settings/CrmSettingsPage.tsx +++ b/frontend/src/pages/admin/settings/CrmSettingsPage.tsx @@ -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'; @@ -83,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; @@ -244,7 +249,26 @@ export const CrmSettingsPage: React.FC = () => {

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

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

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

+

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

+
+
+ ) : ( + checkbox('crm_invoices_reminders_enabled', 'Send automatic reminders for overdue invoices') + )} + {checkbox('crm_invoices_late_fee_enabled', 'Add a late fee (Mahngebühr) on the 2nd and 3rd reminder')}

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

@@ -252,14 +276,18 @@ export const CrmSettingsPage: React.FC = () => {
{checkbox('crm_invoices_late_fee_vat_enabled', 'Charge VAT on late fees (Switzerland — leave off for DE/AT; no effect if your organisation has no VAT rate)')}
- setVal('crm_invoices_reminder_first_days', Number(e.target.value))} /> - setVal('crm_invoices_reminder_second_days', Number(e.target.value))} /> + {!workflowsLive && ( + <> + setVal('crm_invoices_reminder_first_days', Number(e.target.value))} /> + setVal('crm_invoices_reminder_second_days', Number(e.target.value))} /> + + )}
- {/* Global toggles strip */} + {/* Schedule (on/off + lead time): in Workflows when the engine is live, + else the legacy global controls. */} -

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

-

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

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

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

+

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

+
- -
+ ) : ( + <> +

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

+

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

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