Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesNotificationsNotifications — Behavior

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

No 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

InvariantEnforcement
Cron jobs only run in production / stagingcronsEnabled() env gate
Email failures don’t crash the crontry/catch around EmailService.send; Promise.allSettled per recipient
In-test mode, emails are diverted to in-memory log, not sentNODE_ENV === 'test' check in EmailService.send
from address is fixed at module initRESEND_FROM_ADDRESS env

Golden paths

Daily class reminder cron

  1. 08:00 UTC — @ScheduledJob(…, '0 8 * * *') handleClassReminders fires.
  2. cronsEnabled() check.
  3. runWithRetry('handleClassReminders', () => runClassReminders()).
  4. Query class_sessions starting between now+20h and now+28h, status published, with bookings + memberships + users + program/org joins.
  5. Promise.allSettled(...) over { session, booking } pairs → EmailService.send.
  6. Log “Class reminders: N sent, M failed across K sessions”.

Daily expiration warning cron

  1. 09:00 UTC — handleExpirationWarnings.
  2. Subscriptions with status='active' expiring in 6–7 days.
  3. Email per member.

Booking cancellation

  1. Member or staff cancels a booking via the bookings module.
  2. CancellationNotificationsService invoked from the booking service (event or direct call).
  3. Cancellation email sent.

Edge cases

ScenarioBehavior
Cron fires while DB is downThrows; runWithRetry retries; eventual failure logged
Resend API downPer-email try/catch logs; cron continues for remaining recipients
Member changed email mid-cronQuery result holds the email at fetch time; old address used
Multiple bookings for same member same classEach booking → one email (no dedup)
Subscription with no email userEmail skipped silently
Cron job scheduled while previous run still activenestjs/schedule doesn’t deduplicate; long-running jobs could overlap. Verify with QA
Failed cron runrunWithRetry retries; partial completion possible
LocalizationTemplates appear English-only; member with Hebrew locale receives English copy — bug worth filing

Side effects

ActionWritesSends
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).