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 — @Cron('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 reminderN emails via Resend
Expiration warningM emails
Cancellation1 email per cancellation

No DB writes. No event emissions.

Permissions

System-internal. No user-facing API endpoints. Cron-only.