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.
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.
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 |
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 |
Side effects
| Action | Writes | Sends |
|---|---|---|
| Immediate event | — | 1 email per active owner/admin |
| Digest tick (per org) | 1 audit_events row | 1 email per active owner/admin |
PATCH config | Upserts 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.
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): 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.