← automation
sherpa-api#460 Done · In review ⏱ 1h 15m
Timed/delayed emails for cooling-off periods
⎇ auto/api-460-timed-delayed-emails-for-cooling
Portal localhost:3013Embed localhost:3113API localhost:4013
🔍 iterative-review ✓ (main-pass) 🧪 test:api failed 234 passed · 5 failed · 0 flaky · 12 skipped · report 🧑‍🔬 QA ✅ PASS

Feedback

Pull requests

loading…

QA evaluation

✅ PASS

PASS. Independently verified in the real running portal (authenticated as E2E Test User, http://localhost:3013) — not from the implementer's screenshots. The cross-repo portal companion exists and works: Compliance > Cooling-off Rules lets you configure a 7-day delay on the offer/confirmation email, and Operate > Scheduled Notifications is a working audit ledger with a Cancel action. Driving a real draft creation through the widget-token flow, the immediate send is suppressed and the email is queued for exactly day 7 (scheduledFor = creation + 7.000 days, backed by a persisted BullMQ delayed job); cancelling in the portal UI flips the row to Cancelled and aborts the queued send. All three parts of the acceptance criterion are demonstrably met in the UI. The pre-run test suite's 5 failures are all pre-existing shared-DB count/ledger-pollution and known baseline specs (02-bulk renewals, 04 ledger counts, 05 verify-count, 06 policy-suspension, 06 product-translations) — none related to #460 (no test changes, no cooling-off specs added), so tests_ok. One real but narrowly-scoped bug found: delayDays=0 rules race (job fires before the ledger row is written) and silently never dispatch — must-fix before exposing delay=0 in prod, but it does NOT affect the 7-day regulatory flow this ticket targets.

3/3 acceptance criteria passed
  • DE PPI policy created today sends NO immediate confirmation email (send suppressed)Drove the real portal: created an active 7-day cooling-off rule for the 'Offer / confirmation email' (quote_email) on product 312 (Compliance > Cooling-off Rules), then created a real awaiting_acceptance draft via the same widget-token flow the embed offer funnel uses. API log emitted 'Cooling-off: suppressed immediate send, queued delayed dispatch' and NO acceptance email was sent. A ledger row appeared in the portal Operate > Scheduled Notifications screen with status 'Scheduled'. Screenshot eval-1 + eval-2.
  • The email is scheduled to dispatch exactly on day 7Portal Scheduled Notifications shows recipient [email protected], notification quote_email, 'Scheduled for 6/8/2026 10:10 AM' = exactly 7 days after creation (6/1). DB confirms scheduledFor - createdAt = 7.000 days and a BullMQ jobId is persisted (survives restart via Valkey). The dispatch handler is idempotent and was observed executing in testing. The literal day-7 fire was not waited out, but the schedule time is exact, the delayed job is enqueued/persisted, and the handler is correct. Screenshot eval-2.
  • Cancelling within the window (before day 7) prevents the sendClicked 'Cancel send' on the scheduled row in the real portal UI. Status flipped to 'Cancelled' with outcome 'cancelled_by_admin'; toast 'Scheduled send cancelled' shown. DB confirms status=cancelled. The service removes the queued BullMQ job and the handler also re-checks policy cancellation defensively before sending. Screenshot eval-3.
tests_ok: true · quality: BUG (correctness / silent email loss, edge case OUTSIDE the acceptance criterion): coolingOffService.scheduleCoolingOffNotification enqueues the BullMQ job BEFORE writing the ledger row (lines ~104 then ~110). With delayDays=0 (delayMs=0) the worker picks the job up ~1ms before the row commits -> handler logs 'ledger row not found', no-ops, and the row is left stuck in 'scheduled' forever -> the email is NEVER sent. Reproduced live (job ran 17:12:46.071, row written 17:12:46.072). The create form ALLOWS delay=0 (min=0), so this is reachable. The regulatory 7-day path is UNAFFECTED (the 7-day job is not touched for 7 days, long after the row exists). Recommended fix: persist the ledger row before enqueueing, or have the handler retry/tolerate a momentarily-missing row for short delays. Other notes: jurisdiction is per-PRODUCT (rule keyed to productId) rather than an explicit country/program/outlet selector — fine in practice since products are jurisdiction-specific, but narrower than the 'per country/program/outlet' wording; locale is hardcoded 'en' at schedule time (day-7 email may not match customer locale); notificationType hardcoded to 'quote_email' in the create path (only the offer email is regulated today — table is extensible). Good: forward-only migration uses the correct permissive RLS template; ledger has no FK to rules so audit survives rule deletion; cancel + withdrawal hook wired into cancelPolicy; clean config dialog (German-PPI hint, optional send window, active toggle) and an audit ledger with status badges — solid, best-in-class admin UX.