Notifications — Behavior
State machine
Per email: stateless fire-and-forget.
cron fires ──> query candidates ──> for each: render HTML ──> EmailService.send ──> log result
│
├── Resend success → counted
└── Resend error → logged, swallowedNo retry table, no idempotency token, no dedup. If a cron runs twice in the same window, recipients receive duplicate emails. Mitigated by the time-window query (e.g. class reminders target sessions starting in 20–28h — a re-run picks the same set, hence dup).
Invariants
| Invariant | Enforcement |
|---|---|
| Cron jobs only run in production / staging | cronsEnabled() env gate |
| Email failures don’t crash the cron | try/catch around EmailService.send; Promise.allSettled per recipient |
| In-test mode, emails are diverted to in-memory log, not sent | NODE_ENV === 'test' check in EmailService.send |
from address is fixed at module init | RESEND_FROM_ADDRESS env |
Golden paths
Daily class reminder cron
- 08:00 UTC —
@Cron('0 8 * * *') handleClassRemindersfires. cronsEnabled()check.runWithRetry('handleClassReminders', () => runClassReminders()).- Query class_sessions starting between now+20h and now+28h, status
published, with bookings + memberships + users + program/org joins. Promise.allSettled(...)over{ session, booking }pairs →EmailService.send.- Log “Class reminders: N sent, M failed across K sessions”.
Daily expiration warning cron
- 09:00 UTC —
handleExpirationWarnings. - Subscriptions with
status='active'expiring in 6–7 days. - Email per member.
Booking cancellation
- Member or staff cancels a booking via the bookings module.
CancellationNotificationsServiceinvoked from the booking service (event or direct call).- Cancellation email sent.
Edge cases
| Scenario | Behavior |
|---|---|
| Cron fires while DB is down | Throws; runWithRetry retries; eventual failure logged |
| Resend API down | Per-email try/catch logs; cron continues for remaining recipients |
| Member changed email mid-cron | Query result holds the email at fetch time; old address used |
| Multiple bookings for same member same class | Each booking → one email (no dedup) |
| Subscription with no email user | Email skipped silently |
| Cron job scheduled while previous run still active | nestjs/schedule doesn’t deduplicate; long-running jobs could overlap. Verify with QA |
| Failed cron run | runWithRetry retries; partial completion possible |
| Localization | Templates appear English-only; member with Hebrew locale receives English copy — bug worth filing |
Side effects
| Action | Writes | Sends |
|---|---|---|
| Class reminder | — | N emails via Resend |
| Expiration warning | — | M emails |
| Cancellation | — | 1 email per cancellation |
No DB writes. No event emissions.
Permissions
System-internal. No user-facing API endpoints. Cron-only.