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:
| Part | Single event | Digest |
|---|---|---|
| Subject | <Action>: <details> — New lead: Dana Cohen | Daily update: Sep 18, 2026 / Weekly update: Sep 11 – 18, 2026 |
| Greeting | Hi Erez, you asked to get notified about this. | same |
| What happened | One sentence — Dana Cohen left their details and wants to hear about joining. | Here's everything that happened in the last day. |
| Details | A label/value card; which rows appear is per event (FIELDS in the template) | One section per event, a row per person |
| Button | The record — Open lead, Open member, Open inbox | Open 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_prefsis the per-user, member-facing preference store. Same idea, different audience, different table, no cadence dimension.../automations/— the automations engine also hasnew_member/new_leadtriggers 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
| Persona | Surface | Capabilities |
|---|---|---|
| Owner / admin | Settings → Staff newsletter | Sets the cadence per event, for the whole org |
| Owner / admin | Inbox | Receives the mail |
| Coach | — | Neither configures nor receives: every event in the catalog is commercial (a lead, a payment, a departure) |
| System (cron + listeners) | n/a | Sends |
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.
| Event | Source | Immediate? |
|---|---|---|
newLead | lead.created | ✅ |
newMember | membership.activation_confirmed (role member only; see behavior) | ✅ |
newInboundMessage | conversation.inbound_received | ✅ |
cancellationRequest | subscription.cancellation_requested | ✅ |
retentionCaseAttention | derived from state | ❌ digest only |
paymentFailed | payment.charge_failed | ✅ |
newDebt | subscription.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 atimmediately.StaffNewsletterCronServicesends one digest per window atdaily(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
immediatelytoweeklyand 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/ruunderemails.staffNewsletter.*andsettings.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.
newInboundMessagelinks to the inbox, not the thread. The messages screen has no per-conversation deep link today.cancellationRequestis 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.
Related
../notifications/— the email stack../subscriptions-plans/— retention cases../leads-crm/—lead.created, the inbox../payments/— charge failures, debt../audit-logging/— the send claim
Source code
- API:
apps/api/src/staff-newsletter/staff-newsletter-config.service.ts— config, defaults, recipientsstaff-newsletter.controller.ts—GET/PATCH /organizations/:orgId/staff-newsletterstaff-newsletter.listener.ts— theimmediatelypathstaff-newsletter-digest.service.ts— window queriesstaff-newsletter-cron.service.ts— daily + weekly, claims, locksstaff-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-*