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
+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,