feat(workflows): pre-event reminder picks the template GROUP on the block, type stays automatic

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:
  <group>_<eventType> if authored  →  else  <group>_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.
This commit is contained in:
Luca
2026-06-24 11:26:02 +02:00
parent 5fbe514db6
commit 10d091b55e
8 changed files with 53 additions and 13 deletions
@@ -402,6 +402,19 @@ describe('workflow engine', () => {
expect(again.reason).toBe('already_sent'); 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 () => { test('isBuiltinFlowActive reflects the built-in ENABLED state (enabled-based mutex)', async () => {
const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot'); const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot');
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} }); await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} });
+2 -2
View File
@@ -161,7 +161,7 @@ function buildBookingInvoiceOnlyGraph() {
function buildPreEventEmailGraph() { function buildPreEventEmailGraph() {
const nodes = [ const nodes = [
{ node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 }, { 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 }, { node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 240, pos_y: 220 },
]; ];
const edges = [ const edges = [
@@ -267,7 +267,7 @@ const BUILTINS = [
}, },
{ {
key: 'pre_event_email', key: 'pre_event_email',
version: 3, version: 4,
enabled: false, enabled: false,
name: 'Pre-event reminder (built-in)', name: 'Pre-event reminder (built-in)',
trigger_type: 'event.date_approaching', trigger_type: 'event.date_approaching',
+15 -9
View File
@@ -61,6 +61,7 @@ const logger = require('../utils/logger');
const { ensureEventReminderTemplatesSeeded } = require('./eventReminderTemplates'); const { ensureEventReminderTemplatesSeeded } = require('./eventReminderTemplates');
const DEFAULT_DAYS_BEFORE = 2; const DEFAULT_DAYS_BEFORE = 2;
const DEFAULT_TEMPLATE_GROUP = 'event_reminder';
const TEMPLATE_KEY_DEFAULT = 'event_reminder_default'; const TEMPLATE_KEY_DEFAULT = 'event_reminder_default';
const TEMPLATE_KEY_PREFIX = 'event_reminder_'; const TEMPLATE_KEY_PREFIX = 'event_reminder_';
@@ -71,20 +72,23 @@ const TEMPLATE_KEY_PREFIX = 'event_reminder_';
let schemaWarnLogged = false; let schemaWarnLogged = false;
/** /**
* Lookup the most specific available template for an event_type slug. * Resolve the reminder template within a GROUP (template-key prefix). The group
* Returns the template_key string. The email_processor handles missing * is chosen on the flow block (defaults to `event_reminder`); within it the pick
* template rows by failing the send; we don't fetch the row body here * is automatic and per-event-type:
* because emailProcessor.queueEmail does that lookup itself. * `<group>_<eventType>` if a template exists → else `<group>_default`
* So an exact wedding/birthday/… template wins; otherwise the group's catch-all.
* emailProcessor handles a missing template row itself, so we only return a key.
*/ */
async function resolveTemplateKey(eventType) { async function resolveTemplateKey(eventType, group = DEFAULT_TEMPLATE_GROUP) {
const g = String(group || DEFAULT_TEMPLATE_GROUP).replace(/_+$/, ''); // tolerate a trailing "_"
if (eventType) { if (eventType) {
const perType = `${TEMPLATE_KEY_PREFIX}${eventType}`; const perType = `${g}_${eventType}`;
const exists = await db('email_templates') const exists = await db('email_templates')
.where({ template_key: perType }) .where({ template_key: perType })
.first('id'); .first('id');
if (exists) return perType; 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 * disabled, already sent, no template-eligible recipient); only DB/queue errors
* propagate so the caller can surface them. * 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'); const hasCols = await hasColumnCached('events', 'event_reminder_sent_at');
if (!hasCols) return { sent: 0, skipped: 1, reason: 'schema_not_migrated' }; 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 profile = await db('business_profile').where({ id: 1 }).first('company_name');
const businessName = profile?.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 }); const payload = composePayload({ event: row, recipientEmail, daysBefore: offsetDays, businessName });
if (row.event_reminder_body_override) payload.body_override = row.event_reminder_body_override; if (row.event_reminder_body_override) payload.body_override = row.event_reminder_body_override;
+6 -2
View File
@@ -170,8 +170,12 @@ registry.registerAction('notify_gallery_expired', async (ctx) => {
registry.registerAction('notify_pre_event', async (ctx) => { registry.registerAction('notify_pre_event', async (ctx) => {
const id = ctx.run.entity_id; const id = ctx.run.entity_id;
if (!id) return { skipped: true, reason: 'no event entity' }; if (!id) return { skipped: true, reason: 'no event entity' };
if (ctx.vars?.__dryRun) return { dryRun: true, would: 'notify_pre_event', eventId: id }; // The template GROUP is chosen on THIS block (config.templateGroup, e.g.
const res = await require('../eventReminderService').sendReminderForEvent(id); // '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; return res;
}); });
+2
View File
@@ -255,6 +255,8 @@
"saveFailed": "Speichern fehlgeschlagen", "saveFailed": "Speichern fehlgeschlagen",
"badJson": "Konfiguration ist kein gültiges JSON", "badJson": "Konfiguration ist kein gültiges JSON",
"daysBefore": "Tage vor dem Anlass", "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)", "showAdvanced": "Erweitert (JSON)",
"hideAdvanced": "Erweitert ausblenden (JSON)", "hideAdvanced": "Erweitert ausblenden (JSON)",
"triggerHint": "Der Auslöser wird oben in der Leiste gesetzt (Wenn …).", "triggerHint": "Der Auslöser wird oben in der Leiste gesetzt (Wenn …).",
+2
View File
@@ -255,6 +255,8 @@
"saveFailed": "Could not save", "saveFailed": "Could not save",
"badJson": "Config is not valid JSON", "badJson": "Config is not valid JSON",
"daysBefore": "days before event", "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)", "showAdvanced": "Advanced (JSON)",
"hideAdvanced": "Hide advanced (JSON)", "hideAdvanced": "Hide advanced (JSON)",
"triggerHint": "The trigger is set in the toolbar above (When …).", "triggerHint": "The trigger is set in the toolbar above (When …).",
@@ -22,6 +22,9 @@ const ACTIONS = [
['queue_payment_check', 'Send payment-check email (dunning gate)'], ['queue_payment_check', 'Send payment-check email (dunning gate)'],
['escalate_to_collections', 'Hand off to collections (email admin)'], ['escalate_to_collections', 'Hand off to collections (email admin)'],
['send_email', 'Send email'], ['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'], ['reserve_date', 'Reserve the event date'],
['prepare_quote', 'Prepare a quote (draft)'], ['prepare_quote', 'Prepare a quote (draft)'],
['prepare_contract', 'Prepare a contract (draft)'], ['prepare_contract', 'Prepare a contract (draft)'],
@@ -96,6 +99,15 @@ export const NodeConfigPanel: React.FC<Props> = ({ nodeType, config, onChange })
</> </>
)} )}
{nodeType === 'action' && config.action === 'notify_pre_event' && (
<Row label={t('workflows.editor.templateGroup', 'Reminder template group')}>
<input className={field} value={config.templateGroup || ''} onChange={(e) => set({ templateGroup: e.target.value })} placeholder="event_reminder" />
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('workflows.editor.templateGroupHint', 'The exact template is auto-picked per event type within this group: «group»_«eventType» if you authored one, else «group»_default. Blank = event_reminder.')}
</p>
</Row>
)}
{(nodeType === 'action' || nodeType === 'webhook') && (config.action === 'webhook' || nodeType === 'webhook') && ( {(nodeType === 'action' || nodeType === 'webhook') && (config.action === 'webhook' || nodeType === 'webhook') && (
<Row label={t('workflows.editor.webhookUrl', 'Webhook URL')}> <Row label={t('workflows.editor.webhookUrl', 'Webhook URL')}>
<input className={field} value={config.url || ''} onChange={(e) => set({ url: e.target.value })} placeholder="https://…" /> <input className={field} value={config.url || ''} onChange={(e) => set({ url: e.target.value })} placeholder="https://…" />
@@ -45,6 +45,7 @@ const SOURCE_HANDLES: Record<string, string[]> = {
const WAIT_ANCHOR_LABEL: Record<string, string> = { dueDate: 'due date', issueDate: 'invoice date', eventDate: 'event date' }; const WAIT_ANCHOR_LABEL: Record<string, string> = { dueDate: 'due date', issueDate: 'invoice date', eventDate: 'event date' };
const ACTION_LABEL: Record<string, string> = { const ACTION_LABEL: Record<string, string> = {
queue_payment_check: 'Send payment-check email', escalate_to_collections: 'Collections handoff', send_email: 'Send email', reserve_date: 'Reserve the date', 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_quote: 'Prepare quote', prepare_contract: 'Prepare contract', prepare_invoice: 'Prepare invoice',
prepare_event: 'Create event', prepare_gallery: 'Create gallery', send_document: 'Send document', prepare_event: 'Create event', prepare_gallery: 'Create gallery', send_document: 'Send document',
webhook: 'Call webhook', noop: 'Do nothing', set_context: 'Set value', webhook: 'Call webhook', noop: 'Do nothing', set_context: 'Set value',