Staff Newsletter — Code Map
API
| File | Role |
|---|---|
apps/api/src/staff-newsletter/staff-newsletter.module.ts | Wiring |
.../staff-newsletter-config.service.ts | Read/merge/upsert, recipient + org-id lookups; personal mutes (recipientsFor(orgId, event?), myAlerts, updateMyAlerts, mute) |
.../staff-newsletter.controller.ts | GET/PATCH /organizations/:orgId/staff-newsletter and …/me |
.../dto/update-staff-newsletter.dto.ts | Per-event cadence patch; a type-level assertion fails the build if the catalog grows a field the DTO lacks |
.../dto/update-my-staff-alerts.dto.ts | { events: { [event]: boolean } }, same catalog assertion |
.../staff-alert-unsubscribe.controller.ts | @Public() POST /staff-newsletter/unsubscribe (body or query token) |
.../staff-alert-unsubscribe.service.ts | Footer links + List-Unsubscribe headers per recipient; verify-and-mute |
.../staff-alert.tokens.ts | HMAC sign/verify, 90-day expiry, sa1 domain prefix |
.../staff-newsletter.listener.ts | @OnEvent × 6 — the immediately path |
.../staff-newsletter-digest.service.ts | Window queries, one per enabled event |
.../staff-newsletter-cron.service.ts | @ScheduledJob daily + weekly, advisory locks, audit claims, isoWeekKey |
.../staff-newsletter-sender.service.ts | Render + deliver per recipient (muted sections filtered per digest); dashboard URL builders |
apps/api/src/notifications/templates/staff-newsletter.ts | Both shapes: staffNewsletterDigestEmail, staffNewsletterSingleEmail; shared alerts footer |
apps/api/src/admin/services/admin-users.service.ts | Platform erasure deletes the user’s staff_alert_prefs |
Producers touched
New domain events, each declared next to its producer:
| Event | Contract file | Emitted from |
|---|---|---|
subscription.cancellation_requested | apps/api/src/subscriptions/retention-events.ts | RetentionCaseService.openMemberCase (kind request only) |
subscription.debt_opened | apps/api/src/payments/subscription-events.ts | RecurringChargeService.handleChargeFailure, WebhookProcessingService, CancellationTailSettlementService.applyOutcome — all three reach the debt_since NULL→timestamp transition |
payment.charge_failed | apps/api/src/payments/subscription-events.ts | PaymentTransactionService.recordChargeOutcome — the one place every charge path reaches a terminal status |
Existing events reused unchanged: lead.created,
conversation.inbound_received. newMember listens to
membership.activation_confirmed, emitted by the member activation gate
(apps/api/src/member-activation-gate/) from membership.activated.
Modified
| File | Change |
|---|---|
apps/api/src/subscriptions/retention-case.service.ts | Emits the cancellation event; the old runDailyDigest is deleted |
apps/api/src/subscriptions/cancellation-cron.service.ts | The old handleRetentionDigest job is deleted; its stale schedule is removed at boot by the scheduled-jobs processor |
apps/api/src/notifications/cancellation-notifications.service.ts | retentionDigestForStaff and its template are deleted |
apps/api/src/payments/services/recurring-charge.service.ts | Selects s.debt_since so a new debt can be told from a growing one; emits |
apps/api/src/payments/services/webhook-processing.service.ts | debtEntry carries the event; emitted after commit |
apps/api/src/payments/services/cancellation-tail-settlement.service.ts | Captures inside the transaction, emits after |
apps/api/src/payments/services/payment-transaction.service.ts | Emits at the choke point; the prior-status read now also guards the bus |
apps/api/src/audit/audit-actions.ts | STAFF_NEWSLETTER_SENT |
Shared
| File | Role |
|---|---|
libs/shared/src/lib/schemas/staff-newsletter.ts | Catalog, defaults, cadenceFor / cadencesFor / eventsAtCadence / mergeStaffNewsletterConfig, zod schemas; personal opt-out (isStaffEventMuted, staffAlertReceiving, applyStaffAlertPatch, UpdateMyStaffAlertsSchema, MyStaffAlerts, StaffAlertUnsubscribeResult) |
libs/db/src/lib/schema/staff-newsletter.ts | staff_newsletter_configs |
libs/db/src/lib/schema/staff-alert-prefs.ts | staff_alert_prefs |
libs/shared/src/lib/i18n/dictionaries/{en,he,ru}.json | emails.staffNewsletter.* (incl. footer), settings.notifications.* (incl. personal, unsubscribePage) |
Web
| File | Role |
|---|---|
apps/web/src/app/[lang]/(protected)/dashboard/settings/(shell)/notifications/page.tsx | The screen: one cadence select per event, plus an “Email me” switch (staff-alert-mine-<event>) saved on its own |
apps/web/src/app/[lang]/staff-alerts/unsubscribe/page.tsx | Public confirmation page for the footer’s stop link |
apps/web/src/hooks/use-staff-newsletter.ts | GET + PATCH org config; useMyStaffAlerts / useSaveMyStaffAlerts (optimistic) for /me |
apps/web/src/hooks/use-settings-routes.ts | Adds the notifications route (the Updates tab), gated on organization:manage |
Tests
| File | Covers |
|---|---|
libs/shared/src/lib/schemas/staff-newsletter.unit.spec.ts | Defaults are the retention sections only; digest-only rejection; merge semantics; opt-out patching |
apps/api/src/staff-newsletter/staff-newsletter.listener.unit.spec.ts | The gate order, never-throwing, item shapes |
apps/api/src/staff-newsletter/staff-newsletter-cron.service.unit.spec.ts | Claim-before-send, ISO-week keys across year boundaries, empty/locked/per-org isolation |
apps/api/src/staff-newsletter/staff-newsletter-sender.service.unit.spec.ts | Muted recipients skipped, digest sections filtered per recipient, fully-muted recipient gets nothing, one-click headers |
apps/api/src/staff-newsletter/staff-alert.tokens.unit.spec.ts | Round trip, wrong secret, forged payload, expiry, unknown event, lead-token isolation |
apps/api/src/staff-newsletter/staff-alert-prefs.int.spec.ts | /me read/write, coach 403, per-org isolation, public unsubscribe (body + one-click query), forged token |
apps/api/src/notifications/templates/staff-newsletter.unit.spec.ts | Section dropping, retention variants, per-recipient money, RTL, subject escaping |
apps/web/.../notifications/settings-notifications-page.int.spec.tsx | Partial PATCH, digest-only options hidden, coach bounced, admin allowed; personal switch saves alone, rolls back, disabled when the org sends nothing |
apps/web/src/app/[lang]/staff-alerts/unsubscribe/page.int.spec.tsx | Mutes only on click, names what was muted, invalid token |
Config
| Env | Purpose |
|---|---|
FRONTEND_URL | Dashboard links and the footer’s links; absent, they render without a link rather than throwing |
API_BASE_URL | One-click List-Unsubscribe target; absent, the header points at the web page and drops List-Unsubscribe-Post |
STAFF_ALERT_UNSUBSCRIBE_SECRET | Token HMAC secret; optional, falls back to CLERK_SECRET_KEY |
CRONS_ENABLED | Whether this process runs the two digest jobs |