feat(workflows): hard cutover of gallery-expiry + dunning + pre-event to flows

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).
This commit is contained in:
Luca
2026-06-23 15:58:19 +02:00
parent fa7b1bae95
commit 0b6c33e59a
11 changed files with 602 additions and 205 deletions
@@ -239,7 +239,7 @@ describe('workflow engine', () => {
expect(again.already).toBe(true); 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 { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot');
const noopLogger = { info() {}, warn() {} }; const noopLogger = { info() {}, warn() {} };
await seedBuiltinWorkflowsAtBoot(db, noopLogger); await seedBuiltinWorkflowsAtBoot(db, noopLogger);
@@ -247,8 +247,8 @@ describe('workflow engine', () => {
const wf = await db('workflows').where({ builtin_key: DUNNING_KEY }).first(); const wf = await db('workflows').where({ builtin_key: DUNNING_KEY }).first();
expect(wf).toBeTruthy(); expect(wf).toBeTruthy();
expect(!!wf.is_builtin).toBe(true); expect(!!wf.is_builtin).toBe(true);
expect(!!wf.enabled).toBe(false); expect(!!wf.enabled).toBe(true); // cutover: dunning ships live
expect(JSON.parse(wf.trigger_config).seedVersion).toBe(4); expect(JSON.parse(wf.trigger_config).seedVersion).toBe(5);
const nodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: wf.version }); const nodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: wf.version });
expect(nodes.filter((n) => n.type === 'trigger')).toHaveLength(1); expect(nodes.filter((n) => n.type === 'trigger')).toHaveLength(1);
@@ -273,7 +273,8 @@ describe('workflow engine', () => {
await seedBuiltinWorkflowsAtBoot(db, noopLogger); await seedBuiltinWorkflowsAtBoot(db, noopLogger);
const reseeded = await db('workflows').where({ id: wf.id }).first(); const reseeded = await db('workflows').where({ id: wf.id }).first();
expect(reseeded.version).toBe(wf.version + 1); // bumped 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 }); 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 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 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'); const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot');
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} }); 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(); const bookingFull = await db('workflows').where({ builtin_key: 'booking_full' }).first();
expect(bookingFull).toBeTruthy(); expect(bookingFull).toBeTruthy();
expect(!!bookingFull.enabled).toBe(false); expect(!!bookingFull.enabled).toBe(false); // illustrative/stub — stays disabled
expect(bookingFull.trigger_type).toBe('quote.accepted'); expect(bookingFull.trigger_type).toBe('quote.accepted');
const fullNodes = await db('workflow_nodes').where({ workflow_id: bookingFull.id, version: bookingFull.version }); 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); 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(); const preEvent = await db('workflows').where({ builtin_key: 'pre_event_email' }).first();
expect(preEvent).toBeTruthy(); expect(preEvent).toBeTruthy();
expect(!!preEvent.enabled).toBe(true); // cutover: pre-event reminder ships live
expect(preEvent.trigger_type).toBe('event.date_approaching'); 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 }); 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 () => { 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); 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 () => { test('recoverStaleRuns resumes a run orphaned mid-flow (crash recovery)', async () => {
const wfId = await makeWorkflow({ const wfId = await makeWorkflow({
trigger: 'recover.event', trigger: 'recover.event',
+117 -38
View File
@@ -127,33 +127,69 @@ function buildBookingSimpleGraph() {
return { nodes, edges }; return { nodes, edges };
} }
// Pre-event email — fired by the scheduler `daysBefore` the event date (see // Pre-event reminder — fired by the scheduler at event_date daysBefore (see
// emitDueEventReminders in the engine). Sends a customer reminder, then a heads- // emitDueEventReminders). The notify_pre_event action DELEGATES to
// up to the admin. Unlike the booking flows this uses the already-wired // eventReminderService.sendReminderForEvent, so the email is byte-identical to
// send_email action, so it is functional once enabled (the customer template // the legacy pass (per-type template, per-event override, sent_at idempotency).
// `pre_event_reminder` should exist / be authored). // This is the live replacement for that pass (mutual-exclusion guard there).
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: 'emailCustomer', type: 'action', config: { action: 'send_email', recipientClass: 'customer', emailType: 'pre_event_reminder' }, pos_x: 240, pos_y: 110 }, { node_key: 'notify', type: 'action', config: { action: 'notify_pre_event' }, 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: 220 },
{ node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 240, pos_y: 330 },
]; ];
const edges = [ const edges = [
{ from_node: 't', to_node: 'emailCustomer' }, { from_node: 't', to_node: 'notify' },
{ from_node: 'emailCustomer', to_node: 'emailAdmin' }, { from_node: 'notify', to_node: 'done' },
{ from_node: 'emailAdmin', 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 }; return { nodes, edges };
} }
// Built-in registry. `version` is the SEED_VERSION — bump when a graph changes // Built-in registry. `version` is the SEED_VERSION — bump when a graph changes
// so a disabled, never-activated copy is re-seeded on boot. // (or to re-assert the default `enabled` state) so a never-admin-touched copy is
// invoice_dunning v4 = collections handoff after the loop exhausts. // 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 = [ const BUILTINS = [
{ {
key: DUNNING_KEY, key: DUNNING_KEY,
version: 4, version: 5,
enabled: true,
name: 'Invoice dunning (built-in)', name: 'Invoice dunning (built-in)',
trigger_type: 'invoice.sent', trigger_type: 'invoice.sent',
trigger_config: {}, trigger_config: {},
@@ -161,9 +197,9 @@ const BUILTINS = [
'Drives overdue dunning through the engine: wait to the due date, then up to ' '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 ' + '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 ' + '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 ' + '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 () => { build: async () => {
const firstDays = Number(await getAppSetting('crm_invoices_reminder_first_days')) || 14; const firstDays = Number(await getAppSetting('crm_invoices_reminder_first_days')) || 14;
const secondDays = Number(await getAppSetting('crm_invoices_reminder_second_days')) || 30; const secondDays = Number(await getAppSetting('crm_invoices_reminder_second_days')) || 30;
@@ -171,9 +207,59 @@ const BUILTINS = [
return buildDunningGraph({ firstDays, gapDays, maxReminders: 3 }); 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', key: 'booking_full',
version: 2, version: 2,
enabled: false,
name: 'Booking — quote → contract → event → invoice (built-in)', name: 'Booking — quote → contract → event → invoice (built-in)',
trigger_type: 'quote.accepted', trigger_type: 'quote.accepted',
trigger_config: {}, trigger_config: {},
@@ -189,6 +275,7 @@ const BUILTINS = [
{ {
key: 'booking_simple', key: 'booking_simple',
version: 2, version: 2,
enabled: false,
name: 'Booking — quote → event → invoice (built-in)', name: 'Booking — quote → event → invoice (built-in)',
trigger_type: 'quote.accepted', trigger_type: 'quote.accepted',
trigger_config: {}, trigger_config: {},
@@ -198,21 +285,6 @@ const BUILTINS = [
+ 'review-before-send rule and stub caveat as the full booking flow; disabled by default.', + 'review-before-send rule and stub caveat as the full booking flow; disabled by default.',
build: async () => buildBookingSimpleGraph(), 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; let booted = false;
@@ -240,14 +312,20 @@ async function writeGraph(trx, workflowId, version, nodes, edges) {
async function seedOneBuiltin(db, logger, def) { async function seedOneBuiltin(db, logger, def) {
const { nodes, edges } = await def.build(); 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(); const existing = await db('workflows').where({ builtin_key: def.key }).first();
if (existing) { if (existing) {
// Re-seed the graph only when (a) it has never been activated and (b) our // Re-seed only a never-admin-activated copy whose SEED_VERSION moved on. An
// seed version moved on. Once the admin enables it, it's their live flow — // already-ENABLED built-in is the admin's live (possibly customised) flow —
// never overwrite it. // 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 storedVersion = Number(parseSeedConfig(existing.trigger_config).seedVersion) || 0;
const isEnabled = existing.enabled === true || existing.enabled === 1; const isEnabled = existing.enabled === true || existing.enabled === 1;
if (isEnabled || storedVersion >= def.version) return; if (isEnabled || storedVersion >= def.version) return;
@@ -259,12 +337,13 @@ async function seedOneBuiltin(db, logger, def) {
description: def.description, description: def.description,
trigger_type: def.trigger_type, trigger_type: def.trigger_type,
trigger_config: JSON.stringify(triggerConfig), trigger_config: JSON.stringify(triggerConfig),
enabled: defEnabled,
version: newVersion, version: newVersion,
updated_at: trx.fn.now(), updated_at: trx.fn.now(),
}); });
await writeGraph(trx, existing.id, newVersion, nodes, edges); 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; return;
} }
@@ -272,7 +351,7 @@ async function seedOneBuiltin(db, logger, def) {
const ins = await trx('workflows').insert({ const ins = await trx('workflows').insert({
name: def.name, name: def.name,
description: def.description, description: def.description,
enabled: false, enabled: defEnabled,
version: 1, version: 1,
trigger_type: def.trigger_type, trigger_type: def.trigger_type,
trigger_config: JSON.stringify(triggerConfig), trigger_config: JSON.stringify(triggerConfig),
@@ -285,7 +364,7 @@ async function seedOneBuiltin(db, logger, def) {
const workflowId = ins[0]?.id ?? ins[0]; const workflowId = ins[0]?.id ?? ins[0];
await writeGraph(trx, workflowId, 1, nodes, edges); 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) { async function seedBuiltinWorkflowsAtBoot(db, logger) {
@@ -128,6 +128,17 @@ async function runEventReminderPass() {
return { scanned: 0, sent: 0, skipped: 0, disabled: true }; 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 // Column-existence guards — pre-migration installs return early
// instead of throwing. // instead of throwing.
const hasCols = await hasColumnCached('events', 'event_reminder_sent_at'); const hasCols = await hasColumnCached('events', 'event_reminder_sent_at');
@@ -254,8 +265,76 @@ async function runEventReminderPass() {
return { scanned: rows.length, sent, skipped }; 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 = { module.exports = {
runEventReminderPass, runEventReminderPass,
sendReminderForEvent,
// exported for tests // exported for tests
_internal: { _internal: {
resolveTemplateKey, resolveTemplateKey,
+117 -72
View File
@@ -20,6 +20,19 @@ async function checkExpirations() {
const now = new Date(); const now = new Date();
const warningDate = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days from now 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 // Check for events needing warning emails
// Skip events with null expires_at (they never expire) // Skip events with null expires_at (they never expire)
const eventsNeedingWarning = await db('events') const eventsNeedingWarning = await db('events')
@@ -30,14 +43,9 @@ async function checkExpirations() {
.where('expires_at', '>', now); .where('expires_at', '>', now);
for (const event of eventsNeedingWarning) { for (const event of eventsNeedingWarning) {
// Check if warning email already sent await emitGalleryExpiring(event); // always — for the built-in + any custom flows
const existingWarning = await db('email_queue') if (!warningFlowOwns) {
.where('event_id', event.id) await queueExpirationWarning(event); // legacy email (self-dedupes)
.where('email_type', 'expiration_warning')
.first();
if (!existingWarning) {
await queueExpirationWarning(event);
} }
} }
@@ -50,7 +58,7 @@ async function checkExpirations() {
.where('expires_at', '<=', now); .where('expires_at', '<=', now);
for (const event of expiredEvents) { for (const event of expiredEvents) {
await handleExpiredEvent(event); await handleExpiredEvent(event, { sendLegacyEmails: !expiredFlowOwns });
} }
} catch (error) { } catch (error) {
@@ -58,7 +66,47 @@ async function checkExpirations() {
} }
} }
/**
* 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) { 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 daysRemaining = Math.ceil((new Date(event.expires_at) - new Date()) / (1000 * 60 * 60 * 24));
const recipientEmail = event.customer_email || event.host_email; 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). // Relationship mail — hold to business hours (no-op unless configured).
}, { respectBusinessHours: true }); }, { 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}`); 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 { try {
// Mark as inactive // Mark as inactive
await db('events').where('id', event.id).update({ is_active: formatBoolean(false) }); await db('events').where('id', event.id).update({ is_active: formatBoolean(false) });
@@ -144,41 +210,8 @@ async function handleExpiredEvent(event) {
}); });
} catch (e) { /* non-fatal */ } } catch (e) { /* non-fatal */ }
// Queue expiration emails. The shipped templates (EN/DE in legacy 028, // Emit gallery.expired for the workflow engine (sibling to the event.expired
// NL/PT/RU in core 075) reference {{host_name}}, {{event_date}}, // webhook). Always emitted; deduped per (workflow, event).
// {{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.
try { try {
await require('./workflows').emitWorkflowEvent('gallery.expired', { await require('./workflows').emitWorkflowEvent('gallery.expired', {
entityType: 'event', entityType: 'event',
@@ -189,7 +222,7 @@ async function handleExpiredEvent(event) {
eventName: event.event_name, eventName: event.event_name,
eventDate: event.event_date, eventDate: event.event_date,
expiresAt: event.expires_at, expiresAt: event.expires_at,
customerEmail: recipientEmail, customerEmail: event.customer_email || event.host_email || null,
adminEmail: event.admin_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 }); 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); await archiveEvent(event);
logger.info(`Handled expiration for event ${event.slug}`); 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,
};
+6 -8
View File
@@ -3447,16 +3447,14 @@ async function runScheduledTasks() {
// Throttled to one email per 24h per invoice via // Throttled to one email per 24h per invoice via
// invoices.last_payment_check_at. // invoices.last_payment_check_at.
const remindersEnabled = await getAppSetting('crm_invoices_reminders_enabled'); const remindersEnabled = await getAppSetting('crm_invoices_reminders_enabled');
// Mutual exclusion with the workflow engine: when the `workflows` flag is on // Mutual exclusion with the workflow engine: once the invoice_dunning built-in
// AND the invoice_dunning built-in is enabled, the engine fires the same // is seeded (flag on), the engine OWNS dunning and is the single switch — it
// payment-check emails — running this hardcoded ladder too would double-send. // 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; let engineDrivesDunning = false;
try { try {
const { isFeatureEnabled } = require('../middleware/requireFeatureFlag'); engineDrivesDunning = await require('./workflows').isBuiltinFlowPresent('invoice_dunning');
if (await isFeatureEnabled('workflows')) {
const dunning = await db('workflows').where({ builtin_key: 'invoice_dunning', enabled: true }).first();
engineDrivesDunning = !!dunning;
}
} catch (_) { /* workflows tables absent / flag system down → ladder stays on */ } } catch (_) { /* workflows tables absent / flag system down → ladder stays on */ }
if (remindersEnabled !== false && !engineDrivesDunning) { if (remindersEnabled !== false && !engineDrivesDunning) {
const firstDays = ensureInt(await getAppSetting('crm_invoices_reminder_first_days')) || 14; const firstDays = ensureInt(await getAppSetting('crm_invoices_reminder_first_days')) || 14;
+40
View File
@@ -135,6 +135,46 @@ registry.registerAction('escalate_to_collections', async (ctx) => {
return { collections_handoff_to: adminEmail, outstanding }; 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 // Create/prepare-document actions — registered so flows referencing them are
// valid; service wiring is a follow-up. Records a skipped step (observable). // valid; service wiring is a follow-up. Records a skipped step (observable).
for (const key of DOCUMENT_ACTIONS) { for (const key of DOCUMENT_ACTIONS) {
+78 -17
View File
@@ -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 * True when the workflows flag is on AND a built-in flow with this key is
* lead window of an enabled pre-event flow. Iterates per flow so each respects * enabled. The hardcoded automations (reminder ladder, expiry emails, pre-event
* its own trigger_config.daysBefore; emitWorkflowEvent's per-(flow,entity) * reminders) call this to STAND DOWN when their engine flow is live so the
* dedup_key guarantees a single run per event, so polling hourly never * engine and the legacy path never double-fire. Fails CLOSED (returns false) on
* duplicates. Fails CLOSED when the workflows flag is off. Called from the * any error so the legacy path keeps running if the workflow subsystem is down.
* scheduler tick. */
* async function isBuiltinFlowActive(builtinKey) {
* Caveat (v1): emitWorkflowEvent fans out to ALL enabled flows of this trigger, try {
* so with multiple pre-event flows the widest window wins for surfacing an const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag');
* event; a narrower flow may then fire earlier than its own daysBefore. The if (!(await isFeatureEnabled('workflows'))) return false;
* single-built-in case (the norm) is exact. 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) { async function emitDueEventReminders(limit = 200) {
try { try {
@@ -404,6 +441,9 @@ async function emitDueEventReminders(limit = 200) {
const flows = await db('workflows').where({ enabled: true, trigger_type: 'event.date_approaching' }); const flows = await db('workflows').where({ enabled: true, trigger_type: 'event.date_approaching' });
if (!flows.length) return 0; 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 // 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). // don't carry one, so source it from the business profile (best-effort).
let adminEmail = null; let adminEmail = null;
@@ -414,22 +454,40 @@ async function emitDueEventReminders(limit = 200) {
} }
} catch (_) { /* best-effort */ } } catch (_) { /* best-effort */ }
const now = Date.now();
const todayIso = new Date(now).toISOString().slice(0, 10);
let emitted = 0; let emitted = 0;
const todayIso = new Date().toISOString().slice(0, 10);
for (const wf of flows) { for (const wf of flows) {
const cfg = parseJson(wf.trigger_config, {}); const cfg = parseJson(wf.trigger_config, {});
const daysBefore = Number(cfg.daysBefore) > 0 ? Number(cfg.daysBefore) : 3; 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_active', true)
.where('is_archived', false) .where('is_archived', false)
.whereNotNull('event_date') .whereNotNull('event_date')
.where('event_date', '>=', todayIso) .where('event_date', '>=', todayIso)
.where('event_date', '<=', windowEndIso) .where('event_date', '<=', windowEndIso);
.limit(limit); // 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) { 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', { const runIds = await emitWorkflowEvent('event.date_approaching', {
entityType: 'event', entityType: 'event',
entityId: ev.id, entityId: ev.id,
@@ -437,10 +495,11 @@ async function emitDueEventReminders(limit = 200) {
eventId: ev.id, eventId: ev.id,
eventName: ev.event_name || null, eventName: ev.event_name || null,
eventDate: ev.event_date, eventDate: ev.event_date,
eventType: ev.event_type || null,
hostName: ev.host_name || null, hostName: ev.host_name || null,
customerEmail: ev.customer_email || ev.host_email || null, customerEmail: ev.customer_email || ev.host_email || null,
adminEmail, adminEmail,
daysBefore, daysBefore: offset,
}, },
}); });
emitted += runIds.length; emitted += runIds.length;
@@ -484,6 +543,8 @@ async function testRun(workflowId, { entityType = null, entityId = null, payload
module.exports = { module.exports = {
emitWorkflowEvent, emitWorkflowEvent,
isBuiltinFlowActive,
isBuiltinFlowPresent,
runDueWaits, runDueWaits,
emitDueEventReminders, emitDueEventReminders,
recoverStaleRuns, recoverStaleRuns,
+10
View File
@@ -4920,6 +4920,11 @@
"title": "Mahngebühren müssen in den AGB stehen", "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." "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": { "crm_invoices_late_fee_label": {
"label": "Bezeichnung Mahngebühr" "label": "Bezeichnung Mahngebühr"
}, },
@@ -5023,6 +5028,11 @@
}, },
"reminderTemplates": { "reminderTemplates": {
"title": "Erinnerungs-E-Mails vor dem Anlass", "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", "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.", "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", "enableLabel": "Erinnerungs-E-Mails vor dem Anlass senden",
+10
View File
@@ -4918,6 +4918,11 @@
"title": "Late fees must be itemised in your terms (AGB)", "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." "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": { "crm_invoices_late_fee_label": {
"label": "Late fee label" "label": "Late fee label"
}, },
@@ -5021,6 +5026,11 @@
}, },
"reminderTemplates": { "reminderTemplates": {
"title": "Pre-event reminder emails", "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 events detail page.",
"link": "Open Workflows"
},
"globalSection": "Global behaviour", "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.", "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", "enableLabel": "Send pre-event reminder emails",
@@ -8,8 +8,9 @@
*/ */
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; 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 { Button, Card, Loading, Input } from '../../../components/common';
import { settingsService } from '../../../services/settings.service'; import { settingsService } from '../../../services/settings.service';
import { quotesService } from '../../../services/quotes.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). // invoice-pipeline + revenue tiles, all of which are CRM-money).
const showQuotes = !!flags.quotes; const showQuotes = !!flags.quotes;
const showInvoices = !!flags.bills; 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 showContracts = !!flags.contracts;
const showDashboardOverview = !!(flags.quotes || flags.bills); const showDashboardOverview = !!(flags.quotes || flags.bills);
const anySection = showQuotes || showInvoices || showContracts || showDashboardOverview; const anySection = showQuotes || showInvoices || showContracts || showDashboardOverview;
@@ -244,7 +249,26 @@ export const CrmSettingsPage: React.FC = () => {
<Card> <Card>
<h3 className="font-semibold mb-3">{t('crmSettings.section.invoices', 'Invoices')}</h3> <h3 className="font-semibold mb-3">{t('crmSettings.section.invoices', 'Invoices')}</h3>
{checkbox('crm_invoices_qr_enabled', 'Render payment QR on invoice PDFs')} {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 ? (
<div className="mt-2 rounded-lg border border-blue-300 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/20 p-3 text-sm text-blue-800 dark:text-blue-200 flex items-start gap-2">
<WorkflowIcon className="w-4 h-4 mt-0.5 shrink-0" />
<div>
<p className="font-medium">{t('crmSettings.dunningMoved.title', 'Reminder schedule is now in Workflows')}</p>
<p className="mt-1">
{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.')}{' '}
<Link to="/admin/workflows" className="underline font-medium">{t('crmSettings.dunningMoved.link', 'Open Workflows')}</Link>
</p>
</div>
</div>
) : (
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')} {checkbox('crm_invoices_late_fee_enabled', 'Add a late fee (Mahngebühr) on the 2nd and 3rd reminder')}
<div className="mt-2 rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-3 text-sm text-amber-800 dark:text-amber-200"> <div className="mt-2 rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-3 text-sm text-amber-800 dark:text-amber-200">
<p className="font-medium">{t('crmSettings.lateFeeAgb.title', 'Late fees must be itemised in your terms (AGB)')}</p> <p className="font-medium">{t('crmSettings.lateFeeAgb.title', 'Late fees must be itemised in your terms (AGB)')}</p>
@@ -252,14 +276,18 @@ export const CrmSettingsPage: React.FC = () => {
</div> </div>
{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)')} {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)')}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<Input type="number" min={1} max={365} {!workflowsLive && (
label={t('crmSettings.crm_invoices_reminder_first_days.label', 'First reminder after (days past due)') as string} <>
value={values.crm_invoices_reminder_first_days ?? 14} <Input type="number" min={1} max={365}
onChange={(e) => setVal('crm_invoices_reminder_first_days', Number(e.target.value))} /> label={t('crmSettings.crm_invoices_reminder_first_days.label', 'First reminder after (days past due)') as string}
<Input type="number" min={1} max={365} value={values.crm_invoices_reminder_first_days ?? 14}
label={t('crmSettings.crm_invoices_reminder_second_days.label', 'Second reminder after (days past due)') as string} onChange={(e) => setVal('crm_invoices_reminder_first_days', Number(e.target.value))} />
value={values.crm_invoices_reminder_second_days ?? 30} <Input type="number" min={1} max={365}
onChange={(e) => setVal('crm_invoices_reminder_second_days', Number(e.target.value))} /> label={t('crmSettings.crm_invoices_reminder_second_days.label', 'Second reminder after (days past due)') as string}
value={values.crm_invoices_reminder_second_days ?? 30}
onChange={(e) => setVal('crm_invoices_reminder_second_days', Number(e.target.value))} />
</>
)}
<div> <div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1"> <label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('crmSettings.crm_invoices_late_fee_type.label', 'Late fee type')} {t('crmSettings.crm_invoices_late_fee_type.label', 'Late fee type')}
@@ -33,13 +33,14 @@ import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { ArrowLeft, Save, AlertTriangle } from 'lucide-react'; import { ArrowLeft, Save, AlertTriangle, Workflow as WorkflowIcon } from 'lucide-react';
import { Button, Card, Loading, Input } from '../../../components/common'; import { Button, Card, Loading, Input } from '../../../components/common';
import { SUPPORTED_LANGUAGES } from '../../../components/common/LanguageSelector'; import { SUPPORTED_LANGUAGES } from '../../../components/common/LanguageSelector';
import { EmailTemplateEditor } from '../../../components/admin/EmailTemplateEditor'; import { EmailTemplateEditor } from '../../../components/admin/EmailTemplateEditor';
import { eventTypesService } from '../../../services/eventTypes.service'; import { eventTypesService } from '../../../services/eventTypes.service';
import { emailService, type EmailTemplateTranslation } from '../../../services/email.service'; import { emailService, type EmailTemplateTranslation } from '../../../services/email.service';
import { settingsService } from '../../../services/settings.service'; import { settingsService } from '../../../services/settings.service';
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
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_';
@@ -61,16 +62,20 @@ export const ReminderTemplatesPage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// ---- Global toggles --------------------------------------------------- // Global on/off + lead time: owned by the "Pre-event reminder" workflow when
// Only two keys are read on this page — fetch just those instead of // the engine is live; otherwise the legacy crm_event_reminders_* settings drive
// the full ~100-row app_settings dict. Saves transferring + parsing // the hourly pass, so we keep their controls. Per-event override (disable /
// every unrelated setting. // offset / custom body) always lives on the event detail page.
const { flags } = useFeatureFlags();
const workflowsLive = !!flags.workflows;
const { data: settings } = useQuery({ const { data: settings } = useQuery({
queryKey: ['reminder-settings'], queryKey: ['reminder-settings'],
queryFn: () => settingsService.getSettings([ queryFn: () => settingsService.getSettings([
'crm_event_reminders_enabled', 'crm_event_reminders_enabled',
'crm_event_reminders_days_before', 'crm_event_reminders_days_before',
]), ]),
enabled: !workflowsLive,
}); });
const [enabled, setEnabled] = useState<boolean>(false); const [enabled, setEnabled] = useState<boolean>(false);
const [daysBefore, setDaysBefore] = useState<number>(2); const [daysBefore, setDaysBefore] = useState<number>(2);
@@ -259,49 +264,51 @@ export const ReminderTemplatesPage: React.FC = () => {
</h1> </h1>
</div> </div>
{/* Global toggles strip */} {/* Schedule (on/off + lead time): in Workflows when the engine is live,
else the legacy global controls. */}
<Card className="mb-4"> <Card className="mb-4">
<h3 className="font-semibold text-sm mb-2"> {workflowsLive ? (
{t('reminderTemplates.globalSection', 'Global behaviour')} <div className="flex items-start gap-2 text-sm text-blue-800 dark:text-blue-200">
</h3> <WorkflowIcon className="w-4 h-4 mt-0.5 shrink-0" />
<p className="text-xs text-muted-theme mb-3"> <div>
{t('reminderTemplates.globalHelp', <p className="font-medium">{t('reminderTemplates.scheduleMoved.title', 'The reminder schedule is now in Workflows')}</p>
'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.')} <p className="mt-1 text-muted-theme">
</p> {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 events detail page.')}{' '}
<div className="flex items-center gap-6 flex-wrap"> <Link to="/admin/workflows" className="underline font-medium">{t('reminderTemplates.scheduleMoved.link', 'Open Workflows')}</Link>
<label className="inline-flex items-center gap-2 text-sm cursor-pointer"> </p>
<input </div>
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
{t('reminderTemplates.enableLabel', 'Send pre-event reminder emails')}
</label>
<div className="flex items-center gap-2">
<label htmlFor="reminder-days-before" className="text-sm">
{t('reminderTemplates.daysBeforeLabel', 'Days before the event')}
</label>
<Input
id="reminder-days-before"
type="number"
min={0}
max={365}
value={daysBefore}
onChange={(e) => setDaysBefore(Number(e.target.value))}
className="w-24"
/>
</div> </div>
<Button ) : (
variant="outline" <>
size="sm" <h3 className="font-semibold text-sm mb-2">
onClick={() => saveSettingsMutation.mutate()} {t('reminderTemplates.globalSection', 'Global behaviour')}
isLoading={saveSettingsMutation.isPending} </h3>
disabled={saveSettingsMutation.isPending} <p className="text-xs text-muted-theme mb-3">
leftIcon={<Save className="w-4 h-4" />} {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.')}
{t('reminderTemplates.saveSettings', 'Save global settings')} </p>
</Button> <div className="flex items-center gap-6 flex-wrap">
</div> <label className="inline-flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
{t('reminderTemplates.enableLabel', 'Send pre-event reminder emails')}
</label>
<div className="flex items-center gap-2">
<label htmlFor="reminder-days-before" className="text-sm">
{t('reminderTemplates.daysBeforeLabel', 'Days before the event')}
</label>
<Input id="reminder-days-before" type="number" min={0} max={365}
value={daysBefore} onChange={(e) => setDaysBefore(Number(e.target.value))} className="w-24" />
</div>
<Button variant="outline" size="sm"
onClick={() => saveSettingsMutation.mutate()}
isLoading={saveSettingsMutation.isPending}
disabled={saveSettingsMutation.isPending}
leftIcon={<Save className="w-4 h-4" />}>
{t('reminderTemplates.saveSettings', 'Save global settings')}
</Button>
</div>
</>
)}
</Card> </Card>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">