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);
});
test('seeds the invoice-dunning built-in as the delegation graph (v2, disabled)', async () => {
test('seeds the invoice-dunning built-in as the delegation graph (v5, enabled cutover)', async () => {
const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot');
const noopLogger = { info() {}, warn() {} };
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
@@ -247,8 +247,8 @@ describe('workflow engine', () => {
const wf = await db('workflows').where({ builtin_key: DUNNING_KEY }).first();
expect(wf).toBeTruthy();
expect(!!wf.is_builtin).toBe(true);
expect(!!wf.enabled).toBe(false);
expect(JSON.parse(wf.trigger_config).seedVersion).toBe(4);
expect(!!wf.enabled).toBe(true); // cutover: dunning ships live
expect(JSON.parse(wf.trigger_config).seedVersion).toBe(5);
const nodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: wf.version });
expect(nodes.filter((n) => n.type === 'trigger')).toHaveLength(1);
@@ -273,7 +273,8 @@ describe('workflow engine', () => {
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
const reseeded = await db('workflows').where({ id: wf.id }).first();
expect(reseeded.version).toBe(wf.version + 1); // bumped
expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(4);
expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(5);
expect(!!reseeded.enabled).toBe(true); // cutover default applied on re-seed
const newNodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: reseeded.version });
expect(newNodes.some((n) => n.type === 'gate')).toBe(false); // legacy graph replaced
@@ -285,13 +286,28 @@ describe('workflow engine', () => {
expect(after.version).toBe(before.version); // unchanged
});
test('seeds the booking + pre-event built-ins (disabled, correct triggers)', async () => {
test('seeds the gallery, pre-event (enabled cutover) + booking (disabled) built-ins', async () => {
const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot');
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} });
// Cutover flows ship ENABLED, delegating to the proven send functions.
const expiring = await db('workflows').where({ builtin_key: 'gallery_expiring' }).first();
expect(expiring).toBeTruthy();
expect(!!expiring.enabled).toBe(true);
expect(expiring.trigger_type).toBe('gallery.expiring');
const expiringNodes = await db('workflow_nodes').where({ workflow_id: expiring.id, version: expiring.version });
expect(expiringNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_gallery_expiring')).toBe(true);
const expired = await db('workflows').where({ builtin_key: 'gallery_expired' }).first();
expect(expired).toBeTruthy();
expect(!!expired.enabled).toBe(true);
expect(expired.trigger_type).toBe('gallery.expired');
const expiredNodes = await db('workflow_nodes').where({ workflow_id: expired.id, version: expired.version });
expect(expiredNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_gallery_expired')).toBe(true);
const bookingFull = await db('workflows').where({ builtin_key: 'booking_full' }).first();
expect(bookingFull).toBeTruthy();
expect(!!bookingFull.enabled).toBe(false);
expect(!!bookingFull.enabled).toBe(false); // illustrative/stub — stays disabled
expect(bookingFull.trigger_type).toBe('quote.accepted');
const fullNodes = await db('workflow_nodes').where({ workflow_id: bookingFull.id, version: bookingFull.version });
expect(fullNodes.some((n) => JSON.parse(n.config || '{}').action === 'prepare_contract')).toBe(true);
@@ -311,10 +327,11 @@ describe('workflow engine', () => {
const preEvent = await db('workflows').where({ builtin_key: 'pre_event_email' }).first();
expect(preEvent).toBeTruthy();
expect(!!preEvent.enabled).toBe(true); // cutover: pre-event reminder ships live
expect(preEvent.trigger_type).toBe('event.date_approaching');
expect(JSON.parse(preEvent.trigger_config).daysBefore).toBe(3);
expect(JSON.parse(preEvent.trigger_config).daysBefore).toBe(2); // default when global setting unset
const preNodes = await db('workflow_nodes').where({ workflow_id: preEvent.id, version: preEvent.version });
expect(preNodes.some((n) => JSON.parse(n.config || '{}').action === 'send_email')).toBe(true);
expect(preNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_pre_event')).toBe(true);
});
test('emitDueEventReminders starts a run for an event inside the lead window', async () => {
@@ -346,6 +363,29 @@ describe('workflow engine', () => {
expect(runs2.length).toBe(1);
});
test('isBuiltinFlowActive reflects the built-in enabled state (cutover guard)', async () => {
const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot');
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} });
// Cutover built-ins ship enabled; booking stays disabled; unknown key → false.
expect(await engine.isBuiltinFlowActive('gallery_expiring')).toBe(true);
expect(await engine.isBuiltinFlowActive('pre_event_email')).toBe(true);
expect(await engine.isBuiltinFlowActive('booking_full')).toBe(false);
expect(await engine.isBuiltinFlowActive('does_not_exist')).toBe(false);
});
test('legacy event-reminder pass stands down when the pre_event_email flow is active', async () => {
const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot');
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} }); // pre_event_email enabled
// Reach the mutual-exclusion guard: the pass returns early on the global
// enable check unless the setting is on.
await db('app_settings')
.insert({ setting_key: 'crm_event_reminders_enabled', setting_value: JSON.stringify(true), setting_type: 'boolean' })
.onConflict('setting_key').merge();
const res = await require('../../src/services/eventReminderService').runEventReminderPass();
expect(res.byWorkflow).toBe(true);
expect(res.sent).toBe(0);
});
test('recoverStaleRuns resumes a run orphaned mid-flow (crash recovery)', async () => {
const wfId = await makeWorkflow({
trigger: 'recover.event',
+117 -38
View File
@@ -127,33 +127,69 @@ function buildBookingSimpleGraph() {
return { nodes, edges };
}
// Pre-event email — fired by the scheduler `daysBefore` the event date (see
// emitDueEventReminders in the engine). Sends a customer reminder, then a heads-
// up to the admin. Unlike the booking flows this uses the already-wired
// send_email action, so it is functional once enabled (the customer template
// `pre_event_reminder` should exist / be authored).
// Pre-event reminder — fired by the scheduler at event_date daysBefore (see
// emitDueEventReminders). The notify_pre_event action DELEGATES to
// eventReminderService.sendReminderForEvent, so the email is byte-identical to
// the legacy pass (per-type template, per-event override, sent_at idempotency).
// This is the live replacement for that pass (mutual-exclusion guard there).
function buildPreEventEmailGraph() {
const nodes = [
{ node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 },
{ node_key: 'emailCustomer', type: 'action', config: { action: 'send_email', recipientClass: 'customer', emailType: 'pre_event_reminder' }, pos_x: 240, pos_y: 110 },
{ node_key: 'emailAdmin', type: 'action', config: { action: 'send_email', recipientClass: 'admin', emailType: 'pre_event_internal' }, pos_x: 240, pos_y: 220 },
{ node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 240, pos_y: 330 },
{ node_key: 'notify', type: 'action', config: { action: 'notify_pre_event' }, pos_x: 240, pos_y: 110 },
{ node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 240, pos_y: 220 },
];
const edges = [
{ from_node: 't', to_node: 'emailCustomer' },
{ from_node: 'emailCustomer', to_node: 'emailAdmin' },
{ from_node: 'emailAdmin', to_node: 'done' },
{ from_node: 't', to_node: 'notify' },
{ from_node: 'notify', to_node: 'done' },
];
return { nodes, edges };
}
// Gallery expiring — fired by the expiration checker `daysBefore` expiry. The
// notify_gallery_expiring action delegates to the checker's queueExpirationWarning
// so the warning email is identical. Live replacement for the legacy warning
// email (mutual-exclusion guard in the checker).
function buildGalleryExpiringGraph() {
const nodes = [
{ node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 },
{ node_key: 'notify', type: 'action', config: { action: 'notify_gallery_expiring' }, pos_x: 240, pos_y: 110 },
{ node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 240, pos_y: 220 },
];
const edges = [
{ from_node: 't', to_node: 'notify' },
{ from_node: 'notify', to_node: 'done' },
];
return { nodes, edges };
}
// Gallery expired — fired when a gallery passes its expiry. The
// notify_gallery_expired action delegates to the checker's sendGalleryExpiredEmails.
// Live replacement for the legacy expired email (mutual-exclusion guard in the checker).
function buildGalleryExpiredGraph() {
const nodes = [
{ node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 },
{ node_key: 'notify', type: 'action', config: { action: 'notify_gallery_expired' }, pos_x: 240, pos_y: 110 },
{ node_key: 'done', type: 'action', config: { action: 'noop' }, pos_x: 240, pos_y: 220 },
];
const edges = [
{ from_node: 't', to_node: 'notify' },
{ from_node: 'notify', to_node: 'done' },
];
return { nodes, edges };
}
// Built-in registry. `version` is the SEED_VERSION — bump when a graph changes
// so a disabled, never-activated copy is re-seeded on boot.
// invoice_dunning v4 = collections handoff after the loop exhausts.
// (or to re-assert the default `enabled` state) so a never-admin-touched copy is
// re-seeded on boot. `enabled` is the cutover default: the live automations
// (dunning, gallery expiry, pre-event) ship ENABLED and their legacy hardcoded
// paths stand down (isBuiltinFlowActive guards), so behaviour is preserved with
// zero double-send. Illustrative/stub flows (booking) ship disabled.
// invoice_dunning v5 = enabled-by-default cutover (was v4: collections handoff).
const BUILTINS = [
{
key: DUNNING_KEY,
version: 4,
version: 5,
enabled: true,
name: 'Invoice dunning (built-in)',
trigger_type: 'invoice.sent',
trigger_config: {},
@@ -161,9 +197,9 @@ const BUILTINS = [
'Drives overdue dunning through the engine: wait to the due date, then up to '
+ 'three payment-check cycles. Each cycle fires the existing admin confirm-payment '
+ 'email (the gate), which applies reminders + Mahngebühr via the proven payment-check '
+ 'flow; after the cycles exhaust it hands the case to collections. Disabled by default; '
+ 'flow; after the cycles exhaust it hands the case to collections. ENABLED by default; '
+ 'while it is enabled the hardcoded reminder ladder is skipped automatically, so the two '
+ 'never double-send.',
+ 'never double-send. Reminder timing is now edited here (no longer in Settings → CRM).',
build: async () => {
const firstDays = Number(await getAppSetting('crm_invoices_reminder_first_days')) || 14;
const secondDays = Number(await getAppSetting('crm_invoices_reminder_second_days')) || 30;
@@ -171,9 +207,59 @@ const BUILTINS = [
return buildDunningGraph({ firstDays, gapDays, maxReminders: 3 });
},
},
{
key: 'gallery_expiring',
version: 1,
enabled: true,
name: 'Gallery expiring (built-in)',
trigger_type: 'gallery.expiring',
trigger_config: {},
description:
'When a gallery is approaching its expiry date, email the customer the expiration warning. '
+ 'ENABLED by default; it delegates to the same email the hourly expiration checker used to '
+ 'send, and that legacy email stands down while this flow is on (no double-send). Edit or '
+ 'extend it here (e.g. add a final-download nudge).',
build: async () => buildGalleryExpiringGraph(),
},
{
key: 'gallery_expired',
version: 1,
enabled: true,
name: 'Gallery expired (built-in)',
trigger_type: 'gallery.expired',
trigger_config: {},
description:
'When a gallery passes its expiry, email the customer (and admin) that it has expired. '
+ 'ENABLED by default; delegates to the same email the expiration checker used to send, '
+ 'and that legacy email stands down while this flow is on. The gallery is still archived '
+ 'automatically regardless of this flow.',
build: async () => buildGalleryExpiredGraph(),
},
{
key: 'pre_event_email',
version: 2,
enabled: true,
name: 'Pre-event reminder (built-in)',
trigger_type: 'event.date_approaching',
// daysBefore seeds the scheduler emitter from the current global setting so
// upgrades preserve timing; per-event offset overrides still win. This flow
// is now the source of truth for the lead time (was Settings → Reminder emails).
trigger_config: async () => {
const d = Number(await getAppSetting('crm_event_reminders_days_before'));
return { daysBefore: Number.isFinite(d) && d >= 0 ? d : 2 };
},
description:
'A few days before the event date, send the customer the pre-event reminder. ENABLED by '
+ 'default; the notify_pre_event action delegates to the proven reminder logic (per-type '
+ 'template, per-event override, send-once), and the legacy reminder pass stands down while '
+ 'this flow is on. Lead time = daysBefore in the trigger config (seeded from your old '
+ 'global setting); per-event overrides on the event page still apply.',
build: async () => buildPreEventEmailGraph(),
},
{
key: 'booking_full',
version: 2,
enabled: false,
name: 'Booking — quote → contract → event → invoice (built-in)',
trigger_type: 'quote.accepted',
trigger_config: {},
@@ -189,6 +275,7 @@ const BUILTINS = [
{
key: 'booking_simple',
version: 2,
enabled: false,
name: 'Booking — quote → event → invoice (built-in)',
trigger_type: 'quote.accepted',
trigger_config: {},
@@ -198,21 +285,6 @@ const BUILTINS = [
+ 'review-before-send rule and stub caveat as the full booking flow; disabled by default.',
build: async () => buildBookingSimpleGraph(),
},
{
key: 'pre_event_email',
version: 1,
name: 'Pre-event email (built-in)',
trigger_type: 'event.date_approaching',
// daysBefore drives the scheduler emitter — how many days before the event
// date the reminder fires.
trigger_config: { daysBefore: 3 },
description:
'A few days before the event date, send the customer a reminder and the admin a heads-up. '
+ 'Fired by the scheduler from the event date (daysBefore in the trigger config). Uses the '
+ 'wired send_email action, so it works once enabled and the pre_event_reminder template '
+ 'exists. Disabled by default.',
build: async () => buildPreEventEmailGraph(),
},
];
let booted = false;
@@ -240,14 +312,20 @@ async function writeGraph(trx, workflowId, version, nodes, edges) {
async function seedOneBuiltin(db, logger, def) {
const { nodes, edges } = await def.build();
const triggerConfig = { ...(def.trigger_config || {}), seedVersion: def.version };
const baseCfg = typeof def.trigger_config === 'function'
? (await def.trigger_config()) || {}
: (def.trigger_config || {});
const triggerConfig = { ...baseCfg, seedVersion: def.version };
const defEnabled = def.enabled === true;
const existing = await db('workflows').where({ builtin_key: def.key }).first();
if (existing) {
// Re-seed the graph only when (a) it has never been activated and (b) our
// seed version moved on. Once the admin enables it, it's their live flow —
// never overwrite it.
// Re-seed only a never-admin-activated copy whose SEED_VERSION moved on. An
// already-ENABLED built-in is the admin's live (possibly customised) flow —
// never overwrite it. The version bump carries the cutover default (incl.
// flipping a still-disabled flow to enabled); the cutover targets flows that
// shipped disabled and were never touched, so this leaves admin choices alone.
const storedVersion = Number(parseSeedConfig(existing.trigger_config).seedVersion) || 0;
const isEnabled = existing.enabled === true || existing.enabled === 1;
if (isEnabled || storedVersion >= def.version) return;
@@ -259,12 +337,13 @@ async function seedOneBuiltin(db, logger, def) {
description: def.description,
trigger_type: def.trigger_type,
trigger_config: JSON.stringify(triggerConfig),
enabled: defEnabled,
version: newVersion,
updated_at: trx.fn.now(),
});
await writeGraph(trx, existing.id, newVersion, nodes, edges);
});
logger?.info?.(`Re-seeded built-in workflow: ${def.key} (v${def.version})`);
logger?.info?.(`Re-seeded built-in workflow: ${def.key} (v${def.version}, enabled=${defEnabled})`);
return;
}
@@ -272,7 +351,7 @@ async function seedOneBuiltin(db, logger, def) {
const ins = await trx('workflows').insert({
name: def.name,
description: def.description,
enabled: false,
enabled: defEnabled,
version: 1,
trigger_type: def.trigger_type,
trigger_config: JSON.stringify(triggerConfig),
@@ -285,7 +364,7 @@ async function seedOneBuiltin(db, logger, def) {
const workflowId = ins[0]?.id ?? ins[0];
await writeGraph(trx, workflowId, 1, nodes, edges);
});
logger?.info?.(`Seeded built-in workflow: ${def.key} (disabled)`);
logger?.info?.(`Seeded built-in workflow: ${def.key} (enabled=${defEnabled})`);
}
async function seedBuiltinWorkflowsAtBoot(db, logger) {
@@ -128,6 +128,17 @@ async function runEventReminderPass() {
return { scanned: 0, sent: 0, skipped: 0, disabled: true };
}
// Mutual exclusion with the workflow engine: once the pre_event_email built-in
// is seeded (flag on), the engine OWNS the reminder — it sends via the
// notify_pre_event action when the flow is enabled, or nothing when the admin
// disabled it. Either way the legacy pass stands down so the two never
// double-send. Fails closed → legacy pass keeps running if the subsystem is down.
try {
if (await require('./workflows').isBuiltinFlowPresent('pre_event_email')) {
return { scanned: 0, sent: 0, skipped: 0, byWorkflow: true };
}
} catch (_) { /* workflow subsystem down → keep the legacy pass running */ }
// Column-existence guards — pre-migration installs return early
// instead of throwing.
const hasCols = await hasColumnCached('events', 'event_reminder_sent_at');
@@ -254,8 +265,76 @@ async function runEventReminderPass() {
return { scanned: rows.length, sent, skipped };
}
/**
* Send the pre-event reminder for ONE event — the per-event body of
* runEventReminderPass, reused by the workflow `notify_pre_event` action so the
* engine path is byte-identical to the legacy pass (same template resolution,
* per-event body override, recipient rule and `event_reminder_sent_at` idempotency).
*
* Returns { sent, skipped, reason? }. Never throws on a business skip (no email,
* disabled, already sent, no template-eligible recipient); only DB/queue errors
* propagate so the caller can surface them.
*/
async function sendReminderForEvent(eventId) {
const hasCols = await hasColumnCached('events', 'event_reminder_sent_at');
if (!hasCols) return { sent: 0, skipped: 1, reason: 'schema_not_migrated' };
// Self-heal templates (idempotent, process-cached) — same as the pass.
try { await ensureEventReminderTemplatesSeeded(db, logger); } catch (err) {
logger.error('Event reminder template self-heal failed', { message: err.message });
}
const row = await db('events')
.leftJoin('customer_accounts', 'customer_accounts.id', 'events.customer_account_id')
.where('events.id', eventId)
.select(
'events.id', 'events.event_name', 'events.event_type', 'events.event_date',
'events.is_active', 'events.is_archived',
'events.event_reminder_disabled', 'events.event_reminder_offset_days',
'events.event_reminder_body_override', 'events.event_reminder_sent_at',
'events.customer_account_id',
'customer_accounts.email as customer_email',
'customer_accounts.first_name as customer_first_name',
'customer_accounts.last_name as customer_last_name',
'customer_accounts.display_name as customer_display_name',
'customer_accounts.company_name as customer_company_name',
)
.first();
if (!row) return { sent: 0, skipped: 1, reason: 'not_found' };
if (row.event_reminder_disabled) return { sent: 0, skipped: 1, reason: 'disabled' };
if (row.event_reminder_sent_at) return { sent: 0, skipped: 1, reason: 'already_sent' };
if (!row.customer_email) return { sent: 0, skipped: 1, reason: 'no_recipient' };
const globalDaysBefore = Number(await getAppSetting('crm_event_reminders_days_before'));
const daysBeforeDefault = Number.isFinite(globalDaysBefore) && globalDaysBefore >= 0
? globalDaysBefore : DEFAULT_DAYS_BEFORE;
const rawOffset = row.event_reminder_offset_days;
const offsetDays = (rawOffset != null && rawOffset !== '' && Number.isFinite(Number(rawOffset)))
? Number(rawOffset) : daysBeforeDefault;
const profile = await db('business_profile').where({ id: 1 }).first('company_name');
const businessName = profile?.company_name || '';
const templateKey = await resolveTemplateKey(row.event_type);
const customer = {
email: row.customer_email,
first_name: row.customer_first_name,
last_name: row.customer_last_name,
display_name: row.customer_display_name,
company_name: row.customer_company_name,
};
const payload = composePayload({ event: row, customer, daysBefore: offsetDays, businessName });
if (row.event_reminder_body_override) payload.body_override = row.event_reminder_body_override;
await emailProcessor.queueEmail(row.id, customer.email, templateKey, payload);
await db('events').where({ id: row.id }).update({ event_reminder_sent_at: new Date() });
return { sent: 1, skipped: 0 };
}
module.exports = {
runEventReminderPass,
sendReminderForEvent,
// exported for tests
_internal: {
resolveTemplateKey,
+117 -72
View File
@@ -20,6 +20,19 @@ async function checkExpirations() {
const now = new Date();
const warningDate = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days from now
// Mutual exclusion with the workflow engine: when the matching built-in flow
// is enabled, the engine sends the email (via notify_gallery_* actions). We
// still EMIT the trigger every pass (for the built-in AND any custom flows),
// but skip the LEGACY email so the two never double-send. State transitions
// (is_active=false, archive) always run regardless — they're the expiry
// mechanic, not the notification.
// Existence-based: once the built-in is seeded the engine OWNS the email, so
// the legacy send stands down whether the flow is enabled (it sends) or
// disabled (admin turned it off). The trigger is still emitted regardless.
const { isBuiltinFlowPresent } = require('./workflows');
const warningFlowOwns = await isBuiltinFlowPresent('gallery_expiring');
const expiredFlowOwns = await isBuiltinFlowPresent('gallery_expired');
// Check for events needing warning emails
// Skip events with null expires_at (they never expire)
const eventsNeedingWarning = await db('events')
@@ -30,14 +43,9 @@ async function checkExpirations() {
.where('expires_at', '>', now);
for (const event of eventsNeedingWarning) {
// Check if warning email already sent
const existingWarning = await db('email_queue')
.where('event_id', event.id)
.where('email_type', 'expiration_warning')
.first();
if (!existingWarning) {
await queueExpirationWarning(event);
await emitGalleryExpiring(event); // always — for the built-in + any custom flows
if (!warningFlowOwns) {
await queueExpirationWarning(event); // legacy email (self-dedupes)
}
}
@@ -50,7 +58,7 @@ async function checkExpirations() {
.where('expires_at', '<=', now);
for (const event of expiredEvents) {
await handleExpiredEvent(event);
await handleExpiredEvent(event, { sendLegacyEmails: !expiredFlowOwns });
}
} 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) {
const existingWarning = await db('email_queue')
.where('event_id', event.id)
.where('email_type', 'expiration_warning')
.first();
if (existingWarning) return;
const daysRemaining = Math.ceil((new Date(event.expires_at) - new Date()) / (1000 * 60 * 60 * 24));
const recipientEmail = event.customer_email || event.host_email;
@@ -88,34 +136,52 @@ async function queueExpirationWarning(event) {
// Relationship mail — hold to business hours (no-op unless configured).
}, { respectBusinessHours: true });
// Fire gallery.expiring so admins can build flows on the warning window
// (e.g. a final download nudge). Best-effort; emit is fail-closed when the
// workflows flag is off, and deduped per (workflow, event) so the hourly
// sweep fires the trigger at most once per gallery.
try {
await require('./workflows').emitWorkflowEvent('gallery.expiring', {
entityType: 'event',
entityId: event.id,
payload: {
eventId: event.id,
slug: event.slug,
eventName: event.event_name,
eventDate: event.event_date,
expiresAt: event.expires_at,
daysRemaining,
customerEmail: recipientEmail,
adminEmail: event.admin_email || null,
galleryLink: shareUrl,
},
});
} catch (err) {
logger.warn('Failed to emit gallery.expiring workflow event', { eventId: event.id, error: err.message });
}
logger.info(`Queued expiration warning for event ${event.slug}`);
}
async function handleExpiredEvent(event) {
/**
* Queue the gallery_expired emails (customer + optional admin). Self-dedupes on
* the (event_id, 'gallery_expired') email_queue row, so both the legacy expiry
* handler and the workflow `notify_gallery_expired` action are safe to call it.
*/
async function sendGalleryExpiredEmails(event) {
const existing = await db('email_queue')
.where('event_id', event.id)
.where('email_type', 'gallery_expired')
.first();
if (existing) return;
// The shipped templates (EN/DE in legacy 028, NL/PT/RU in core 075) reference
// {{host_name}}, {{event_date}}, {{expiry_date}} and {{support_email}} — fill
// them all here.
const recipientEmail = event.customer_email || event.host_email;
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
const supportEmail = await getSupportEmail();
const customerVars = {
customer_name: recipientName,
customer_email: recipientEmail,
host_name: recipientName,
event_name: event.event_name,
event_date: event.event_date,
expiry_date: event.expires_at,
admin_email: event.admin_email,
support_email: supportEmail
};
if (recipientEmail) {
await queueEmail(event.id, recipientEmail, 'gallery_expired', customerVars);
}
// Also notify admin (when configured).
if (event.admin_email && event.admin_email !== recipientEmail) {
await queueEmail(event.id, event.admin_email, 'gallery_expired', {
...customerVars,
host_name: 'Admin'
});
}
}
async function handleExpiredEvent(event, { sendLegacyEmails = true } = {}) {
try {
// Mark as inactive
await db('events').where('id', event.id).update({ is_active: formatBoolean(false) });
@@ -144,41 +210,8 @@ async function handleExpiredEvent(event) {
});
} catch (e) { /* non-fatal */ }
// Queue expiration emails. The shipped templates (EN/DE in legacy 028,
// NL/PT/RU in core 075) reference {{host_name}}, {{event_date}},
// {{expiry_date}} and {{support_email}}. Without these, customers used
// to literally see "Hello {{host_name}}, your gallery expired on
// {{expiry_date}}…" — fill them all here.
const recipientEmail = event.customer_email || event.host_email;
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
const supportEmail = await getSupportEmail();
const customerVars = {
customer_name: recipientName,
customer_email: recipientEmail,
host_name: recipientName,
event_name: event.event_name,
event_date: event.event_date,
expiry_date: event.expires_at,
admin_email: event.admin_email,
support_email: supportEmail
};
if (recipientEmail) {
await queueEmail(event.id, recipientEmail, 'gallery_expired', customerVars);
}
// Also notify admin (when configured).
if (event.admin_email && event.admin_email !== recipientEmail) {
await queueEmail(event.id, event.admin_email, 'gallery_expired', {
...customerVars,
host_name: 'Admin'
});
}
// Fire gallery.expired for the workflow engine (sibling to the
// event.expired webhook above). Best-effort / fail-closed; emitted BEFORE
// archiveEvent so flows see expired→archived in order.
// Emit gallery.expired for the workflow engine (sibling to the event.expired
// webhook). Always emitted; deduped per (workflow, event).
try {
await require('./workflows').emitWorkflowEvent('gallery.expired', {
entityType: 'event',
@@ -189,7 +222,7 @@ async function handleExpiredEvent(event) {
eventName: event.event_name,
eventDate: event.event_date,
expiresAt: event.expires_at,
customerEmail: recipientEmail,
customerEmail: event.customer_email || event.host_email || null,
adminEmail: event.admin_email || null,
},
});
@@ -197,7 +230,13 @@ async function handleExpiredEvent(event) {
logger.warn('Failed to emit gallery.expired workflow event', { eventId: event.id, error: err.message });
}
// Start archiving process
// Legacy notification — skipped when the gallery_expired built-in flow drives
// it (the flow's notify_gallery_expired action sends the same emails).
if (sendLegacyEmails) {
await sendGalleryExpiredEmails(event);
}
// Start archiving process (always — the expiry mechanic, not the email).
await archiveEvent(event);
logger.info(`Handled expiration for event ${event.slug}`);
@@ -206,4 +245,10 @@ async function handleExpiredEvent(event) {
}
}
module.exports = { startExpirationChecker };
module.exports = {
startExpirationChecker,
// Reused by the workflow notify_gallery_* actions so the engine path sends the
// exact same emails as the legacy hourly checker.
queueExpirationWarning,
sendGalleryExpiredEmails,
};
+6 -8
View File
@@ -3447,16 +3447,14 @@ async function runScheduledTasks() {
// Throttled to one email per 24h per invoice via
// invoices.last_payment_check_at.
const remindersEnabled = await getAppSetting('crm_invoices_reminders_enabled');
// Mutual exclusion with the workflow engine: when the `workflows` flag is on
// AND the invoice_dunning built-in is enabled, the engine fires the same
// payment-check emails — running this hardcoded ladder too would double-send.
// Mutual exclusion with the workflow engine: once the invoice_dunning built-in
// is seeded (flag on), the engine OWNS dunning and is the single switch — it
// fires the payment-check emails when the flow is enabled, or nothing when the
// admin disabled it. Either way the hardcoded ladder stands down so the two
// never double-send. Fails closed → ladder stays on if the subsystem is down.
let engineDrivesDunning = false;
try {
const { isFeatureEnabled } = require('../middleware/requireFeatureFlag');
if (await isFeatureEnabled('workflows')) {
const dunning = await db('workflows').where({ builtin_key: 'invoice_dunning', enabled: true }).first();
engineDrivesDunning = !!dunning;
}
engineDrivesDunning = await require('./workflows').isBuiltinFlowPresent('invoice_dunning');
} catch (_) { /* workflows tables absent / flag system down → ladder stays on */ }
if (remindersEnabled !== false && !engineDrivesDunning) {
const firstDays = ensureInt(await getAppSetting('crm_invoices_reminder_first_days')) || 14;
+40
View File
@@ -135,6 +135,46 @@ registry.registerAction('escalate_to_collections', async (ctx) => {
return { collections_handoff_to: adminEmail, outstanding };
});
// --- Gallery / pre-event notification actions (cutover) ---
//
// These DELEGATE to the existing service send functions, so the engine path is
// byte-identical to the legacy hourly checker/pass it replaces (same templates,
// recipients, variables, dedup). The legacy path stands down when the matching
// built-in flow is enabled (isBuiltinFlowActive guard), so exactly one email
// goes out.
// Send the gallery expiration-warning email for the run's event entity.
registry.registerAction('notify_gallery_expiring', async (ctx) => {
const id = ctx.run.entity_id;
if (!id) return { skipped: true, reason: 'no event entity' };
if (ctx.vars?.__dryRun) return { dryRun: true, would: 'notify_gallery_expiring', eventId: id };
const event = await ctx.db('events').where({ id }).first();
if (!event) return { skipped: true, reason: 'event not found' };
await require('../expirationChecker').queueExpirationWarning(event);
return { warning_queued: id };
});
// Send the gallery_expired email(s) for the run's event entity.
registry.registerAction('notify_gallery_expired', async (ctx) => {
const id = ctx.run.entity_id;
if (!id) return { skipped: true, reason: 'no event entity' };
if (ctx.vars?.__dryRun) return { dryRun: true, would: 'notify_gallery_expired', eventId: id };
const event = await ctx.db('events').where({ id }).first();
if (!event) return { skipped: true, reason: 'event not found' };
await require('../expirationChecker').sendGalleryExpiredEmails(event);
return { expired_email_queued: id };
});
// Send the pre-event customer reminder for the run's event entity. Delegates to
// eventReminderService so per-event overrides + sent_at idempotency are honoured.
registry.registerAction('notify_pre_event', async (ctx) => {
const id = ctx.run.entity_id;
if (!id) return { skipped: true, reason: 'no event entity' };
if (ctx.vars?.__dryRun) return { dryRun: true, would: 'notify_pre_event', eventId: id };
const res = await require('../eventReminderService').sendReminderForEvent(id);
return res;
});
// Create/prepare-document actions — registered so flows referencing them are
// valid; service wiring is a follow-up. Records a skipped step (observable).
for (const key of DOCUMENT_ACTIONS) {
+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
* lead window of an enabled pre-event flow. Iterates per flow so each respects
* its own trigger_config.daysBefore; emitWorkflowEvent's per-(flow,entity)
* dedup_key guarantees a single run per event, so polling hourly never
* duplicates. Fails CLOSED when the workflows flag is off. Called from the
* scheduler tick.
*
* Caveat (v1): emitWorkflowEvent fans out to ALL enabled flows of this trigger,
* so with multiple pre-event flows the widest window wins for surfacing an
* event; a narrower flow may then fire earlier than its own daysBefore. The
* single-built-in case (the norm) is exact.
* True when the workflows flag is on AND a built-in flow with this key is
* enabled. The hardcoded automations (reminder ladder, expiry emails, pre-event
* reminders) call this to STAND DOWN when their engine flow is live so the
* engine and the legacy path never double-fire. Fails CLOSED (returns false) on
* any error so the legacy path keeps running if the workflow subsystem is down.
*/
async function isBuiltinFlowActive(builtinKey) {
try {
const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag');
if (!(await isFeatureEnabled('workflows'))) return false;
if (!(await db.schema.hasTable('workflows'))) return false;
const wf = await db('workflows').where({ builtin_key: builtinKey, enabled: true }).first();
return !!wf;
} catch (e) {
return false;
}
}
/**
* True when the workflows flag is on AND a built-in flow with this key EXISTS
* (enabled or not). The legacy automations use this to decide whether the engine
* OWNS the automation once the built-in is seeded, the engine is the single
* switch: the legacy path stands down whether the flow is enabled (the flow
* sends) or disabled (the admin turned it off nothing sends). Distinct from
* isBuiltinFlowActive, which asks whether the flow is currently firing. Fails
* CLOSED (false) so the legacy path keeps running if the subsystem is down.
*/
async function isBuiltinFlowPresent(builtinKey) {
try {
const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag');
if (!(await isFeatureEnabled('workflows'))) return false;
if (!(await db.schema.hasTable('workflows'))) return false;
const wf = await db('workflows').where({ builtin_key: builtinKey }).first();
return !!wf;
} catch (e) {
return false;
}
}
/**
* Emit `event.date_approaching` for events entering an enabled flow's lead
* window. This is the trigger source for the pre-event reminder built-in, so it
* faithfully honours the same per-event controls the legacy eventReminderService
* pass uses (migration 143): skips `event_reminder_disabled` events, skips ones
* already sent (`event_reminder_sent_at`), and fires at `event_date offset`
* where offset = the event's `event_reminder_offset_days` override else the
* flow's `daysBefore`. emitWorkflowEvent's per-(flow,entity) dedup_key keeps the
* hourly sweep to a single run per event. Fails CLOSED when the flag is off.
*/
async function emitDueEventReminders(limit = 200) {
try {
@@ -404,6 +441,9 @@ async function emitDueEventReminders(limit = 200) {
const flows = await db('workflows').where({ enabled: true, trigger_type: 'event.date_approaching' });
if (!flows.length) return 0;
const { hasColumnCached } = require('../../utils/schemaCache');
const hasReminderCols = await hasColumnCached('events', 'event_reminder_sent_at');
// The admin heads-up resolves its recipient from ctx.vars.adminEmail; events
// don't carry one, so source it from the business profile (best-effort).
let adminEmail = null;
@@ -414,22 +454,40 @@ async function emitDueEventReminders(limit = 200) {
}
} catch (_) { /* best-effort */ }
const now = Date.now();
const todayIso = new Date(now).toISOString().slice(0, 10);
let emitted = 0;
const todayIso = new Date().toISOString().slice(0, 10);
for (const wf of flows) {
const cfg = parseJson(wf.trigger_config, {});
const daysBefore = Number(cfg.daysBefore) > 0 ? Number(cfg.daysBefore) : 3;
const windowEndIso = new Date(Date.now() + daysBefore * 86400000).toISOString().slice(0, 10);
// Surface every still-upcoming event up to the widest the offset could be;
// the per-event triggerAt check below decides if it's actually due.
const maxOffset = Math.max(daysBefore, 60);
const windowEndIso = new Date(now + maxOffset * 86400000).toISOString().slice(0, 10);
const events = await db('events')
let q = db('events')
.where('is_active', true)
.where('is_archived', false)
.whereNotNull('event_date')
.where('event_date', '>=', todayIso)
.where('event_date', '<=', windowEndIso)
.limit(limit);
.where('event_date', '<=', windowEndIso);
// Faithful to the legacy pass: never remind a disabled or already-sent event.
if (hasReminderCols) {
q = q.where('event_reminder_disabled', false).whereNull('event_reminder_sent_at');
}
const events = await q.limit(limit);
for (const ev of events) {
// A null/blank per-event offset means "use the flow's daysBefore" — guard
// against Number(null)===0 silently making the reminder fire on the event day.
const rawOffset = hasReminderCols ? ev.event_reminder_offset_days : null;
const offset = (rawOffset != null && rawOffset !== '' && Number.isFinite(Number(rawOffset)))
? Number(rawOffset)
: daysBefore;
const ed = ev.event_date instanceof Date ? ev.event_date : new Date(ev.event_date);
const triggerAt = ed.getTime() - offset * 86400000;
if (now < triggerAt) continue; // not yet inside this event's lead window
const runIds = await emitWorkflowEvent('event.date_approaching', {
entityType: 'event',
entityId: ev.id,
@@ -437,10 +495,11 @@ async function emitDueEventReminders(limit = 200) {
eventId: ev.id,
eventName: ev.event_name || null,
eventDate: ev.event_date,
eventType: ev.event_type || null,
hostName: ev.host_name || null,
customerEmail: ev.customer_email || ev.host_email || null,
adminEmail,
daysBefore,
daysBefore: offset,
},
});
emitted += runIds.length;
@@ -484,6 +543,8 @@ async function testRun(workflowId, { entityType = null, entityId = null, payload
module.exports = {
emitWorkflowEvent,
isBuiltinFlowActive,
isBuiltinFlowPresent,
runDueWaits,
emitDueEventReminders,
recoverStaleRuns,
+10
View File
@@ -4920,6 +4920,11 @@
"title": "Mahngebühren müssen in den AGB stehen",
"body": "Vertragliche Pflicht: Sätze wie „Es werden Mahnspesen erhoben“ reichen nicht aus. In den AGB muss die konkrete Gebühr klar beziffert sein (z.B. „CHF 20 ab der 2. Mahnung“). Mit dem Treuhänder prüfen."
},
"dunningMoved": {
"title": "Der Mahnrhythmus liegt jetzt in den Workflows",
"body": "Wann und wie oft Zahlungserinnerungen für überfällige Rechnungen verschickt werden, wird im Workflow „Rechnungsmahnung“ festgelegt. Die Mahngebühren unten gelten weiterhin.",
"link": "Workflows öffnen"
},
"crm_invoices_late_fee_label": {
"label": "Bezeichnung Mahngebühr"
},
@@ -5023,6 +5028,11 @@
},
"reminderTemplates": {
"title": "Erinnerungs-E-Mails vor dem Anlass",
"scheduleMoved": {
"title": "Der Versandzeitpunkt liegt jetzt in den Workflows",
"body": "Ob Erinnerungen vor dem Anlass verschickt werden und wie viele Tage vorher, wird im Workflow „Erinnerung vor dem Anlass“ festgelegt. Auf dieser Seite werden die E-Mail-Vorlagen bearbeitet; anlassspezifische Überschreibungen bleiben auf der jeweiligen Anlass-Detailseite.",
"link": "Workflows öffnen"
},
"globalSection": "Globales Verhalten",
"globalHelp": "Standardmässig aus — aktiviere, um Erinnerungen zu senden. Der unten gesetzte Offset ist der Standard; jeder Anlass kann ihn auf der Detailseite überschreiben.",
"enableLabel": "Erinnerungs-E-Mails vor dem Anlass senden",
+10
View File
@@ -4918,6 +4918,11 @@
"title": "Late fees must be itemised in your terms (AGB)",
"body": "A contractual duty: phrases like “late fees apply” aren't enough. Your terms must state the concrete fee (e.g. “CHF 20 from the 2nd reminder”). Verify with your Treuhänder."
},
"dunningMoved": {
"title": "Reminder schedule is now in Workflows",
"body": "When and how often overdue reminders go out is configured in the “Invoice dunning” workflow. The late-fee amounts below still apply.",
"link": "Open Workflows"
},
"crm_invoices_late_fee_label": {
"label": "Late fee label"
},
@@ -5021,6 +5026,11 @@
},
"reminderTemplates": {
"title": "Pre-event reminder emails",
"scheduleMoved": {
"title": "The reminder schedule is now in Workflows",
"body": "Whether pre-event reminders are sent, and how many days before the event, is configured in the “Pre-event reminder” workflow. This page edits the email templates; per-event overrides stay on each events detail page.",
"link": "Open Workflows"
},
"globalSection": "Global behaviour",
"globalHelp": "Off by default — turn on to start sending pre-event reminders. The offset below is the default; each event can override on its detail page.",
"enableLabel": "Send pre-event reminder emails",
@@ -8,8 +8,9 @@
*/
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Save as SaveIcon } from 'lucide-react';
import { Save as SaveIcon, Workflow as WorkflowIcon } from 'lucide-react';
import { Button, Card, Loading, Input } from '../../../components/common';
import { settingsService } from '../../../services/settings.service';
import { quotesService } from '../../../services/quotes.service';
@@ -83,6 +84,10 @@ export const CrmSettingsPage: React.FC = () => {
// invoice-pipeline + revenue tiles, all of which are CRM-money).
const showQuotes = !!flags.quotes;
const showInvoices = !!flags.bills;
// When the workflow engine is live, reminder TIMING is owned by the Invoice
// dunning flow — show a pointer instead of the legacy schedule controls. When
// it's off, the legacy reminder ladder still runs, so keep its controls.
const workflowsLive = !!flags.workflows;
const showContracts = !!flags.contracts;
const showDashboardOverview = !!(flags.quotes || flags.bills);
const anySection = showQuotes || showInvoices || showContracts || showDashboardOverview;
@@ -244,7 +249,26 @@ export const CrmSettingsPage: React.FC = () => {
<Card>
<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_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')}
<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>
@@ -252,6 +276,8 @@ export const CrmSettingsPage: React.FC = () => {
</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)')}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
{!workflowsLive && (
<>
<Input type="number" min={1} max={365}
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}
@@ -260,6 +286,8 @@ export const CrmSettingsPage: React.FC = () => {
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>
<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')}
@@ -33,13 +33,14 @@ import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
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 { SUPPORTED_LANGUAGES } from '../../../components/common/LanguageSelector';
import { EmailTemplateEditor } from '../../../components/admin/EmailTemplateEditor';
import { eventTypesService } from '../../../services/eventTypes.service';
import { emailService, type EmailTemplateTranslation } from '../../../services/email.service';
import { settingsService } from '../../../services/settings.service';
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
const TEMPLATE_KEY_DEFAULT = 'event_reminder_default';
const TEMPLATE_KEY_PREFIX = 'event_reminder_';
@@ -61,16 +62,20 @@ export const ReminderTemplatesPage: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
// ---- Global toggles ---------------------------------------------------
// Only two keys are read on this page — fetch just those instead of
// the full ~100-row app_settings dict. Saves transferring + parsing
// every unrelated setting.
// Global on/off + lead time: owned by the "Pre-event reminder" workflow when
// the engine is live; otherwise the legacy crm_event_reminders_* settings drive
// the hourly pass, so we keep their controls. Per-event override (disable /
// offset / custom body) always lives on the event detail page.
const { flags } = useFeatureFlags();
const workflowsLive = !!flags.workflows;
const { data: settings } = useQuery({
queryKey: ['reminder-settings'],
queryFn: () => settingsService.getSettings([
'crm_event_reminders_enabled',
'crm_event_reminders_days_before',
]),
enabled: !workflowsLive,
});
const [enabled, setEnabled] = useState<boolean>(false);
const [daysBefore, setDaysBefore] = useState<number>(2);
@@ -259,8 +264,22 @@ export const ReminderTemplatesPage: React.FC = () => {
</h1>
</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">
{workflowsLive ? (
<div className="flex items-start gap-2 text-sm text-blue-800 dark:text-blue-200">
<WorkflowIcon className="w-4 h-4 mt-0.5 shrink-0" />
<div>
<p className="font-medium">{t('reminderTemplates.scheduleMoved.title', 'The reminder schedule is now in Workflows')}</p>
<p className="mt-1 text-muted-theme">
{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.')}{' '}
<Link to="/admin/workflows" className="underline font-medium">{t('reminderTemplates.scheduleMoved.link', 'Open Workflows')}</Link>
</p>
</div>
</div>
) : (
<>
<h3 className="font-semibold text-sm mb-2">
{t('reminderTemplates.globalSection', 'Global behaviour')}
</h3>
@@ -270,38 +289,26 @@ export const ReminderTemplatesPage: React.FC = () => {
</p>
<div className="flex items-center gap-6 flex-wrap">
<label className="inline-flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
<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"
/>
<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"
<Button variant="outline" size="sm"
onClick={() => saveSettingsMutation.mutate()}
isLoading={saveSettingsMutation.isPending}
disabled={saveSettingsMutation.isPending}
leftIcon={<Save className="w-4 h-4" />}
>
leftIcon={<Save className="w-4 h-4" />}>
{t('reminderTemplates.saveSettings', 'Save global settings')}
</Button>
</div>
</>
)}
</Card>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">