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:
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -11,7 +11,7 @@ function startExpirationChecker() {
|
||||
cron.schedule('0 * * * *', async () => {
|
||||
await checkExpirations();
|
||||
});
|
||||
|
||||
|
||||
logger.info('Expiration checker started');
|
||||
}
|
||||
|
||||
@@ -19,7 +19,20 @@ async function checkExpirations() {
|
||||
try {
|
||||
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')
|
||||
@@ -28,19 +41,14 @@ async function checkExpirations() {
|
||||
.whereNotNull('expires_at')
|
||||
.where('expires_at', '<=', warningDate)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Check for expired events
|
||||
// Skip events with null expires_at (they never expire)
|
||||
const expiredEvents = await db('events')
|
||||
@@ -48,17 +56,57 @@ async function checkExpirations() {
|
||||
.where('is_archived', formatBoolean(false))
|
||||
.whereNotNull('expires_at')
|
||||
.where('expires_at', '<=', now);
|
||||
|
||||
|
||||
for (const event of expiredEvents) {
|
||||
await handleExpiredEvent(event);
|
||||
await handleExpiredEvent(event, { sendLegacyEmails: !expiredFlowOwns });
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error checking expirations:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user