Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesStaff NewsletterStaff Newsletter

Staff Newsletter

Status: Built. Unflagged — on for every org. Last reviewed: 2026-09-18

Naming. “Staff newsletter” is the internal name — the module and the table. Owners never see it: the settings tab is Updates (עדכונים), and the mail calls itself a daily or weekly update. Keep the two apart when editing copy.

Email structure

Every mail — a single event or a digest — follows one skeleton, so the two read as the same product:

PartSingle eventDigest
Subject<Action>: <details> — New lead: Dana CohenDaily update: Sep 18, 2026 / Weekly update: Sep 11 – 18, 2026
GreetingHi Erez, you asked to get notified about this.same
What happenedOne sentence — Dana Cohen left their details and wants to hear about joining.Here's everything that happened in the last day.
DetailsA label/value card; which rows appear is per event (FIELDS in the template)One section per event, a row per person
ButtonThe record — Open lead, Open member, Open inboxOpen your dashboard

Enum values (lead source, message channel, cancellation reason) are labelled from the dictionary in the recipient’s language. A value with no label drops its row rather than showing the raw enum. The member’s own cancellation words and the bank’s decline text are shown verbatim — they are data, not copy.

What

Per-org configuration for what a gym’s owners and admins are emailed about, and how often. Seven org events, four cadences each (off / immediately / daily / weekly), one row per organization.

Distinct from:

  • ../notifications/ — the transactional email stack this is built on (Resend client, branding, templates, i18n). Almost everything there is member-facing; this is the staff side.
  • ../push-notifications/ — notification_prefs is the per-user, member-facing preference store. Same idea, different audience, different table, no cadence dimension.
  • ../automations/ — the automations engine also has new_member / new_lead triggers and an email channel, but its subject is always a member or a lead. A staff recipient does not fit its model, which is why this is a separate feature rather than more triggers there.

Why

Before this, the only staff-facing mail in the product was one hardcoded daily retention digest: fixed sections, fixed cadence, fixed audience, no configuration. A gym could not ask to hear about a new lead, and could not ask to stop hearing about anything.

Cadence is per event, not one setting for the whole newsletter. A new lead is worth interrupting someone for; a week of failed payments is not. One global cadence forces those two to agree, and an owner resolves that by turning the whole thing off.

Personas

PersonaSurfaceCapabilities
Owner / adminSettings → Staff newsletterSets the cadence per event, for the whole org
Owner / adminInboxReceives the mail
Coach—Neither configures nor receives: every event in the catalog is commercial (a lead, a payment, a departure)
System (cron + listeners)n/aSends

There is no personal override. One org row decides what the whole roster gets.

The catalog

Owned in @taikan/shared (libs/shared/src/lib/schemas/staff-newsletter.ts) so the API and the settings screen iterate one list. Adding an event is a one-line change — the config column is JSONB, so no migration, no DTO field, no UI rewrite.

EventSourceImmediate?
newLeadlead.created✅
newMembermembership.activation_confirmed (role member only; see behavior)✅
newInboundMessageconversation.inbound_received✅
cancellationRequestsubscription.cancellation_requested✅
retentionCaseAttentionderived from state❌ digest only
paymentFailedpayment.charge_failed✅
newDebtsubscription.debt_opened✅

retentionCaseAttention is “this open case is past its SLA, or ends this week and nobody has called” — a condition that becomes true by the clock advancing, so there is no instant an immediate mail could fire on. cadencesFor() drops immediately for it and the API rejects it, because the UI must not offer a choice the sender cannot honor and a hand-written PATCH must not be able to store one.

Defaults, and the retention digest it replaced

DEFAULT_STAFF_NEWSLETTER_CONFIG is not all-on. It is cancellationRequest: 'daily' + retentionCaseAttention: 'daily' and nothing else — the same three sections the retired hardcoded retention digest sent, still daily at 06:00 UTC. An org that never opens the Updates tab receives the same amount of mail as before, in this feature’s wording and structure. Every other event is opt-in.

The retention digest (RetentionCaseService.runDailyDigest, retentionDigestForStaff) is deleted, not gated: there is one staff digest.

Capabilities

  • Two delivery paths. StaffNewsletterListener (@OnEvent) sends one mail per event at immediately. StaffNewsletterCronService sends one digest per window at daily (06:00 UTC) / weekly (Mondays 06:15 UTC).
  • Digests are queried, never buffered. There is no outbox table, so no row leaks when a send fails and a missed tick loses nothing it cannot re-derive. It is also what lets an owner move an event from immediately to weekly and have the next digest include everything since the window opened, not only what happened after they changed the setting.
  • Idempotent per (org, cadence, window). An audit claim (staff_newsletter.sent) is written before the send, so a retry or an overlapping run sees it even if the process dies between the two writes. Same shape as the retention escalation sweep’s claim, and the reason neither cron needs a table. The window key is the UTC calendar day for daily, the ISO week for weekly.
  • One advisory lock per cadence, so a manual trigger racing the scheduled tick no-ops rather than double-sending, and the weekly run is never blocked by the daily.
  • Empty digests are never sent — or claimed. Leaving the window unclaimed is deliberate: a later run the same day is free to report it once it fills up.
  • Listeners never throw. Producers emit synchronously from inside (or just after) their own writes, several on a money path. Every producer guards its own emit AND every handler catches — the second layer is the one that does not depend on a producer remembering.
  • Localized. Copy in en/he/ru under emails.staffNewsletter.* and settings.notifications.*. Money travels as minor units so the template formats it in each recipient’s locale — the item is built once per org and rendered once per recipient.

Capabilities (gaps)

  • No per-staff override. One org config, whole roster. A coach cannot be included and an admin cannot mute an event they do not care about.
  • No role selection. The audience is active owners + admins, hardcoded.
  • Email only. No push, no WhatsApp — the catalog would carry over, but notification_prefs’ channel axis does not exist here.
  • Digest windows are UTC, not the org’s timezone. A 06:00 UTC daily digest covers a UTC day, which is not a gym’s day in every zone.
  • newInboundMessage links to the inbox, not the thread. The messages screen has no per-conversation deep link today.
  • cancellationRequest is wider in a digest than immediately. Immediate fires on the member’s own in-app request; the digest reports every notice recorded in the window, staff-recorded ones included. That is deliberate — a notice taken over the phone is as real as one filed in the app — but the two are not the same set.

Rollout

Unflagged. On deploy every org switches from the retired retention digest to the newsletter’s default — the same sections on the same schedule, so no org receives more mail than it did. The staff_newsletter_configs migration must run with the deploy: until it does, config reads fail, the listeners log and send nothing, and the daily run logs a failure per org.

Source code

  • API: apps/api/src/staff-newsletter/
    • staff-newsletter-config.service.ts — config, defaults, recipients
    • staff-newsletter.controller.ts — GET/PATCH /organizations/:orgId/staff-newsletter
    • staff-newsletter.listener.ts — the immediately path
    • staff-newsletter-digest.service.ts — window queries
    • staff-newsletter-cron.service.ts — daily + weekly, claims, locks
    • staff-newsletter-sender.service.ts — render + deliver
  • Template: apps/api/src/notifications/templates/staff-newsletter.ts
  • Catalog: libs/shared/src/lib/schemas/staff-newsletter.ts
  • DB: libs/db/src/lib/schema/staff-newsletter.ts (staff_newsletter_configs)
  • Web: apps/web/src/app/[lang]/(protected)/dashboard/settings/(shell)/notifications/
  • Copy: libs/shared/src/lib/i18n/dictionaries/{en,he,ru}.json
  • Preview: pnpm email:preview → staff-newsletter-*