Staff Newsletter — Behavior
Two paths, one catalog
┌─ cadence = immediately ─→ StaffNewsletterListener ─→ 1 mail / event
domain event ────────┤
└─ cadence = daily|weekly ─→ (nothing; not buffered)
cron tick ──→ for each org with staff ──→ digest queries the window ──→ 1 mail / recipientNothing is buffered. An event configured daily is dropped by the listener
and re-derived by the digest from the domain’s own tables.
Resolution order
cadenceFor(storedConfig, event):
- The org’s stored value for that event, if any — including an explicit
off. - Otherwise
DEFAULT_STAFF_NEWSLETTER_CONFIG[event]. - Otherwise
off.
The immediate path
Cheapest check first, so an org that wants an event daily costs one config read and no queries:
config.cadenceOf(orgId, event)— notimmediately→ return.- Resolve the org locale, build the item (the only DB reads), send to
recipientsFor(orgId, event)— the roster minus anyone who mutedevent.
Every handler is wrapped: a throw is logged and swallowed. Producers emit synchronously from inside or just after their own writes — several on a money path — so an escaping exception would surface on a member’s cancellation or a charge record.
The digest path
Per cadence, under its own advisory lock:
orgIdsWithStaff()— orgs with at least one active owner/admin.- Per org, isolated in a try/catch so one failure does not cost later orgs:
- Build sections for every event at this cadence.
- Every section empty → skip, without claiming the window.
- Already claimed for this (cadence, window) → skip.
- Write the audit claim.
- Send one mail per recipient, each carrying only the sections that recipient has not muted. A recipient left with no populated section gets no mail. The claim is per org, so a fully-muted roster still claims the window — nothing is owed to anyone for it.
Personal opt-out
Who receives event E = active owners/admins of the org minus those whose
staff_alert_prefs.muted_events contains E. Applied in one place,
StaffNewsletterConfigService.recipientsFor, which both send paths resolve
their audience through.
GET /organizations/:orgId/staff-newsletter/me→{ data: { events: { [event]: boolean /* I receive it */ }, orgCadence: { [event]: cadence } } }.orgCadencetravels with it so a client can tell “I muted it” from “the org sends it to nobody” without a second call.PATCH …/mewith{ events: { [event]: boolean } }— only the named events change; returns the same shape.- The settings screen saves each switch the moment it flips (optimistic, with
a toast; rolled back on error), apart from the org’s save bar. A switch is
disabled with “Off for the whole team” while the org’s saved cadence is
off, still showing the person’s own choice.
Unsubscribe link
Every staff mail ends with “You get this because you’re an owner or admin of {org}.” plus two links:
| Link | Target |
|---|---|
| Manage your alerts | {FRONTEND_URL}/{locale}/dashboard/settings/notifications |
| Stop “{event}” emails (immediate) / Stop all alert emails from {org} (digest) | {FRONTEND_URL}/{locale}/staff-alerts/unsubscribe?token=… |
The token is base64url("sa1.{userId}.{orgId}.{event|*}.{expiresAt}") . HMAC,
signed with STAFF_ALERT_UNSUBSCRIBE_SECRET (falls back to
CLERK_SECRET_KEY). The sa1 prefix means a lead-consent token can never
verify as one even under the same secret. Tokens expire after 90 days: the
token mutes a staff account, so a forwarded mail should not keep that power
forever, and a staff member can always mute from settings.
The web page mutes only on an explicit click, which POSTs
{ token } to the public POST /staff-newsletter/unsubscribe. The mail’s
List-Unsubscribe header points straight at that endpoint (token in the query)
with List-Unsubscribe-Post: List-Unsubscribe=One-Click (RFC 8058) when
API_BASE_URL is set; without it the header falls back to the web page. There
is no mutating GET, so a link scanner mutes nobody.
The endpoint always answers 200 — { status: 'unsubscribed' | 'invalid', scope: event | 'all' | null, organizationName }
— because one-click clients expect success; a forged, expired or orphaned
token is invalid and changes nothing. '*' mutes every event in today’s
catalog.
Invariants
| Invariant | Enforcement |
|---|---|
| An org that never saved a config still gets the daily retention sections | DEFAULT_STAFF_NEWSLETTER_CONFIG, applied by cadenceFor |
| One digest per (org, cadence, window) | Audit claim written before the send; wasSent() reads it back |
| An overlapping run sends nothing | withAdvisoryLock per cadence, returns null when held |
| An empty digest is never sent | Sections filtered in the cron and again in the sender |
| A consumer can never fail its producer | try/catch at both the emit site and the handler |
retentionCaseAttention can never be immediately | Dropped by cadencesFor, rejected by StaffNewsletterConfigSchema, absent from the select |
| Staff joining an org is not a new member | role === 'member' in both paths |
| A growing balance is not a new debt | All three producers emit only on debt_since NULL → timestamp |
| One recipient’s bad address does not cost the roster | Per-recipient try/catch in safeSend |
| Nobody is mailed an event they muted | recipientsFor(orgId, event) for immediate; per-recipient section filter for digests |
| A personal switch cannot add mail | Mutes only subtract from the org’s roster; nothing reads them to send |
Edge cases
| Scenario | Behavior |
|---|---|
| Org has no active owner/admin | Not in orgIdsWithStaff(); immediate path resolves zero recipients and sends nothing |
FRONTEND_URL unset | Rows render without a link; nothing throws |
| The row an immediate event names is gone | Item is null, nothing sent |
| The person behind a row cannot be resolved | personName is null and the template substitutes localized copy — never a hardcoded “Unknown” |
| Cron misses a tick | Next tick re-derives the same window; the claim stops a double-send only if the first actually sent |
| Process dies between claim and send | The window is claimed and that digest is lost — deliberate: a duplicate is worse than a miss for a digest |
| Two sends the same day, window filled in between | Second is skipped (claimed). An owner who needs it sooner changes the cadence |
Owner flips weekly → immediately mid-window | Events from before the flip are still in the weekly digest; the flip only affects events that land after it |
| Org spans timezones | Windows are UTC. Known gap — see the README |
| Recipient locales differ | Copy, dates and money all render per recipient; the item is built once per org |
| Admin mutes every event in today’s digest | No digest for them; the rest of the roster gets theirs |
| Unsubscribe link older than 90 days | Page shows “invalid or expired” with a link to settings; nothing changes |
| Staff member demoted to coach, then clicks an old link | The mute is stored and inert — they are not a recipient anyway |
Side effects
| Action | Writes | Sends |
|---|---|---|
| Immediate event | — | 1 email per active owner/admin who has not muted the event |
| Digest tick (per org) | 1 audit_events row | 1 email per active owner/admin with at least one unmuted populated section |
PATCH config | Upserts staff_newsletter_configs | — |
PATCH …/me, unsubscribe link | Upserts staff_alert_prefs | — |
Permissions
GET and PATCH on the org config both require organization:manage
(owner/admin — the same gate as Settings → General and Settings → Booking). A
coach gets 403.
GET/PATCH …/me require an active owner or admin membership — the
people who receive alerts — regardless of the permission matrix. A coach gets
403. POST /staff-newsletter/unsubscribe is @Public(); the signed token is
the credential, throttled at 30/min like the lead unsubscribe.
New member mail and unfinished join-link checkouts
A join-link signup activates its membership before any payment page is shown, and an unfinished checkout is released unpaid 45 minutes later. So “new member” is decided by the member activation gate (apps/api/src/member-activation-gate/), not by the raw membership.activated event:
MemberActivationGateServicelistens tomembership.activatedand emitsmembership.activation_confirmed. Both the staffnewMembermail and thenew_memberautomation listen to the confirmed event. Every other consumer (audit, analytics, onboarding forms, CRM conversion) still reads the raw event.- It confirms at once for a membership that did not come from a join link, for staff roles, for a join link without a plan, and when the member already owns a settled subscription (
scheduled,active,past_due,paused,debt). If any lookup fails before the hold is written, it also confirms at once. - Whether the joiner chose a plan comes from
joinPlanIdon the event whenacceptJoinLinkactivated the membership. When the Clerk invitation activated it (acceptPendingInvitations, which carries no plan), the gate reads the landing intent the join form recorded:shop_planmeans a plan,shop_browsemeans none. No intent means it cannot tell, and it confirms at once. - Otherwise it holds the activation: one row in
member_activation_holds(Postgres, the source of truth, carrying the event) plus one delayed job (member-activation-gate, the timer). The delay is derived from the release timing inpayments/services/join-link-release.ts: 45-minute window + 10-minute sweep interval + 15 minutes of slack = 70 minutes. At most one hold is open per membership; a duplicate activation folds into it. - A hold ends exactly once. Every path claims the row with
UPDATE … WHERE resolved_at IS NULL, and only the path that closes it emitsmembership.activation_confirmed(outcome+resolutionrecord how it ended):- Payment lands (
subscription.started, orsubscription.presale_soldwhen a presale checkout stores the card without charging): confirmed at once (paid), and the queued job is removed. This is the normal path for a member who pays. - The delayed decision (
MemberActivationGateProcessor→decideHold), from the subscriptions created since the hold (both timestamps are database time): a settled one confirms (settled), none confirms (no_purchase), only cancelled ones drop it (checkout_cancelled), a membership that is no longer an activememberdrops it (not_member), and a still-pending one is looked at again an hour later until 24 hours, then dropped (expired). If the decision keeps failing, the last retry confirms (undecided). - A later activation confirmed at once (a late payer reviving a released membership): that activation is confirmed by the gate, and the old hold is closed without a second emit (
superseded). - The reconcile sweep (
MemberActivationGateService.reconcile, every 15 minutes): a hold still open 30 minutes past its due time with no live decision job (Redis lost it, or it could not be queued) is decided right there; a still-pending one is handed back to the queue. Any such hold is logged as an error and reported to Sentry asmember activation decisions missing from Redis.
- Payment lands (
- A decision lives under one of two job ids per membership (
new-member-<id>,new-member-<id>-recheck); a re-check takes whichever id the running job does not hold, which is what lets the gate and the sweep find it.
A join that is released unpaid becomes a lead (stage “Awaiting payment” or “Incomplete signup”). That lead, and a lead reopened this way, carries recoveredSignup: true on its lead.created / lead.status_changed event. new_lead automations skip it. The staff newLead mail is unchanged.