From 10d091b55e0c44738b4001a71def6416a8f0aeb0 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:26:02 +0200 Subject: [PATCH] feat(workflows): pre-event reminder picks the template GROUP on the block, type stays automatic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reminder template family (prefix) is now chosen on the notify_pre_event block via config.templateGroup (default 'event_reminder'); within that group the exact template is still auto-resolved per event type: _ if authored → else _default So an admin can point a flow at a different reminder family, while wedding/ birthday/… routing and the catch-all fallback stay automatic. resolveTemplateKey now takes (eventType, group) and tolerates a trailing "_" on the group. Editor: notify_pre_event (+ the gallery notify actions) added to the action dropdown, with a "Reminder template group" field and hint. Seed sets templateGroup='event_reminder' on the built-in (v4). EN/DE strings. Tests cover the per-type / group-default resolution. --- .../integration/workflowEngine.test.js | 13 ++++++++++ backend/src/services/_workflowSeedBoot.js | 4 ++-- backend/src/services/eventReminderService.js | 24 ++++++++++++------- backend/src/services/workflows/actions.js | 8 +++++-- frontend/src/i18n/locales/de.json | 2 ++ frontend/src/i18n/locales/en.json | 2 ++ .../pages/admin/workflows/NodeConfigPanel.tsx | 12 ++++++++++ .../admin/workflows/WorkflowEditorPage.tsx | 1 + 8 files changed, 53 insertions(+), 13 deletions(-) diff --git a/backend/__tests__/integration/workflowEngine.test.js b/backend/__tests__/integration/workflowEngine.test.js index 7db42d6b..b2885b09 100644 --- a/backend/__tests__/integration/workflowEngine.test.js +++ b/backend/__tests__/integration/workflowEngine.test.js @@ -402,6 +402,19 @@ describe('workflow engine', () => { 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('isBuiltinFlowActive reflects the built-in ENABLED state (enabled-based mutex)', async () => { const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot'); await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} }); diff --git a/backend/src/services/_workflowSeedBoot.js b/backend/src/services/_workflowSeedBoot.js index 1732168f..1f0f1d98 100644 --- a/backend/src/services/_workflowSeedBoot.js +++ b/backend/src/services/_workflowSeedBoot.js @@ -161,7 +161,7 @@ function buildBookingInvoiceOnlyGraph() { 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' }, pos_x: 240, pos_y: 110 }, + { 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 = [ @@ -267,7 +267,7 @@ const BUILTINS = [ }, { key: 'pre_event_email', - version: 3, + version: 4, enabled: false, name: 'Pre-event reminder (built-in)', trigger_type: 'event.date_approaching', diff --git a/backend/src/services/eventReminderService.js b/backend/src/services/eventReminderService.js index a0800306..c22c3f62 100644 --- a/backend/src/services/eventReminderService.js +++ b/backend/src/services/eventReminderService.js @@ -61,6 +61,7 @@ const logger = require('../utils/logger'); const { ensureEventReminderTemplatesSeeded } = require('./eventReminderTemplates'); const DEFAULT_DAYS_BEFORE = 2; +const DEFAULT_TEMPLATE_GROUP = 'event_reminder'; const TEMPLATE_KEY_DEFAULT = 'event_reminder_default'; const TEMPLATE_KEY_PREFIX = 'event_reminder_'; @@ -71,20 +72,23 @@ const TEMPLATE_KEY_PREFIX = 'event_reminder_'; let schemaWarnLogged = false; /** - * Lookup the most specific available template for an event_type slug. - * Returns the template_key string. The email_processor handles missing - * template rows by failing the send; we don't fetch the row body here - * because emailProcessor.queueEmail does that lookup itself. + * Resolve the reminder template within a GROUP (template-key prefix). The group + * is chosen on the flow block (defaults to `event_reminder`); within it the pick + * is automatic and per-event-type: + * `_` if a template exists → else `_default` + * So an exact wedding/birthday/… template wins; otherwise the group's catch-all. + * emailProcessor handles a missing template row itself, so we only return a key. */ -async function resolveTemplateKey(eventType) { +async function resolveTemplateKey(eventType, group = DEFAULT_TEMPLATE_GROUP) { + const g = String(group || DEFAULT_TEMPLATE_GROUP).replace(/_+$/, ''); // tolerate a trailing "_" if (eventType) { - const perType = `${TEMPLATE_KEY_PREFIX}${eventType}`; + const perType = `${g}_${eventType}`; const exists = await db('email_templates') .where({ template_key: perType }) .first('id'); if (exists) return perType; } - return TEMPLATE_KEY_DEFAULT; + return `${g}_default`; } /** @@ -259,7 +263,7 @@ async function runEventReminderPass() { * disabled, already sent, no template-eligible recipient); only DB/queue errors * propagate so the caller can surface them. */ -async function sendReminderForEvent(eventId) { +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' }; @@ -292,7 +296,9 @@ async function sendReminderForEvent(eventId) { const profile = await db('business_profile').where({ id: 1 }).first('company_name'); const businessName = profile?.company_name || ''; - const templateKey = await resolveTemplateKey(row.event_type); + // 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; diff --git a/backend/src/services/workflows/actions.js b/backend/src/services/workflows/actions.js index 3beac482..865a7261 100644 --- a/backend/src/services/workflows/actions.js +++ b/backend/src/services/workflows/actions.js @@ -170,8 +170,12 @@ registry.registerAction('notify_gallery_expired', async (ctx) => { 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); + // 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; }); diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index e3b683ed..0fa46632 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -255,6 +255,8 @@ "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 …).", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index e0327ace..edad21c7 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -255,6 +255,8 @@ "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 …).", diff --git a/frontend/src/pages/admin/workflows/NodeConfigPanel.tsx b/frontend/src/pages/admin/workflows/NodeConfigPanel.tsx index 2dc7a183..f3e7cf34 100644 --- a/frontend/src/pages/admin/workflows/NodeConfigPanel.tsx +++ b/frontend/src/pages/admin/workflows/NodeConfigPanel.tsx @@ -22,6 +22,9 @@ 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)'], @@ -96,6 +99,15 @@ export const NodeConfigPanel: React.FC = ({ nodeType, config, onChange }) )} + {nodeType === 'action' && config.action === 'notify_pre_event' && ( + + set({ templateGroup: e.target.value })} placeholder="event_reminder" /> +

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

+
+ )} + {(nodeType === 'action' || nodeType === 'webhook') && (config.action === 'webhook' || nodeType === 'webhook') && ( set({ url: e.target.value })} placeholder="https://…" /> diff --git a/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx b/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx index 6b6fee4a..ab8b0d79 100644 --- a/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx +++ b/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx @@ -45,6 +45,7 @@ const SOURCE_HANDLES: Record = { const WAIT_ANCHOR_LABEL: Record = { dueDate: 'due date', issueDate: 'invoice date', eventDate: 'event date' }; const ACTION_LABEL: Record = { 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',