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:
@@ -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() {} });
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 …).",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user