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

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 / recipient

Nothing 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):

  1. The org’s stored value for that event, if any — including an explicit off.
  2. Otherwise DEFAULT_STAFF_NEWSLETTER_CONFIG[event].
  3. Otherwise off.

The immediate path

Cheapest check first, so an org that wants an event daily costs one config read and no queries:

  1. config.cadenceOf(orgId, event) — not immediately → return.
  2. Resolve the org locale, build the item (the only DB reads), send.

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:

  1. orgIdsWithStaff() — orgs with at least one active owner/admin.
  2. Per org, isolated in a try/catch so one failure does not cost later orgs:
    1. Build sections for every event at this cadence.
    2. Every section empty → skip, without claiming the window.
    3. Already claimed for this (cadence, window) → skip.
    4. Write the audit claim.
    5. Send one mail per recipient.

Invariants

InvariantEnforcement
An org that never saved a config still gets the daily retention sectionsDEFAULT_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 nothingwithAdvisoryLock per cadence, returns null when held
An empty digest is never sentSections filtered in the cron and again in the sender
A consumer can never fail its producertry/catch at both the emit site and the handler
retentionCaseAttention can never be immediatelyDropped by cadencesFor, rejected by StaffNewsletterConfigSchema, absent from the select
Staff joining an org is not a new memberrole === 'member' in both paths
A growing balance is not a new debtAll three producers emit only on debt_since NULL → timestamp
One recipient’s bad address does not cost the rosterPer-recipient try/catch in safeSend

Edge cases

ScenarioBehavior
Org has no active owner/adminNot in orgIdsWithStaff(); immediate path resolves zero recipients and sends nothing
FRONTEND_URL unsetRows render without a link; nothing throws
The row an immediate event names is goneItem is null, nothing sent
The person behind a row cannot be resolvedpersonName is null and the template substitutes localized copy — never a hardcoded “Unknown”
Cron misses a tickNext tick re-derives the same window; the claim stops a double-send only if the first actually sent
Process dies between claim and sendThe 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 betweenSecond is skipped (claimed). An owner who needs it sooner changes the cadence
Owner flips weekly → immediately mid-windowEvents from before the flip are still in the weekly digest; the flip only affects events that land after it
Org spans timezonesWindows are UTC. Known gap — see the README
Recipient locales differCopy, dates and money all render per recipient; the item is built once per org

Side effects

ActionWritesSends
Immediate event—1 email per active owner/admin
Digest tick (per org)1 audit_events row1 email per active owner/admin
PATCH configUpserts staff_newsletter_configs—

Permissions

GET and PATCH both require organization:manage (owner/admin — the same gate as Settings → General and Settings → Booking). A coach gets 403.

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:

  • MemberActivationGateService listens to membership.activated and emits membership.activation_confirmed. Both the staff newMember mail and the new_member automation 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 joinPlanId on the event when acceptJoinLink activated the membership. When the Clerk invitation activated it (acceptPendingInvitations, which carries no plan), the gate reads the landing intent the join form recorded: shop_plan means a plan, shop_browse means 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 in payments/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 emits membership.activation_confirmed (outcome + resolution record how it ended):
    • Payment lands (subscription.started): 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 active member drops 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 as member activation decisions missing from Redis.
  • 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.