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