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 —
@ScheduledJob(…, '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.
Managed members
Spec: managed-members/behavior.md §4.6. Routing is ungated: it applies whatever the flag says.
- Anything about a managed member goes to their confirmed adults with
canReceiveNotifications, never to the child (NotificationRecipientsService,apps/api/src/guardianships/notification-recipients.service.ts). Billing goes to the payer, card notices to the card’s owner, announcements once per family (apps/api/src/announcements/announcements.service.ts:372). - A missing email is a skipped send, never an error.
- Promotional email or push about a minor or managed member is blocked (
apps/api/src/notifications/email.service.ts:61,133;apps/api/src/common/message-purpose.ts).