Announcements — Behavior
State machine
create (announcements row, deleted_at = null)
├── broadcast (WS event) — synchronous on create
├── push fan-out — fire-and-forget
├── track event — fire-and-forget PostHog
│
├── per-recipient: unread → markRead → read (announcement_reads row inserted)
│
└── (planned) archive — sets deleted_at; list filters outInvariants
| Invariant | Enforcement |
|---|---|
| Only staff can create | requireStaffMembership |
announcement_reads is unique per (announcement_id, user_id) | DB unique |
| Author is excluded from push fan-out | recipients.filter(id !== authorUserId) |
Push category is announcement — respects per-user pref | PushNotificationsService.notifyUsers calls filterByPreferences |
| Read receipts are append-only — re-marking a read announcement is a no-op | Insert with ON CONFLICT DO NOTHING (verify implementation) |
Golden paths
Coach broadcasts “Holiday hours”
- Coach opens the create-announcement dialog, types title + content, optionally toggles priority.
POST /announcements.- Service:
- Inserts row.
- Fetches author name, role, and
getActiveMemberCount. gateway.broadcastAnnouncement(orgId, response)→ WS eventannouncement:newtoorg:{orgId}room.fanOutAnnouncementPush(orgId, authorUserId, response)queries every active membership, drops the author, callspush.notifyUsers(recipientIds, payload)with categoryannouncement.EventTrackingService.track('announcement_published', {...}).
- Returns the full
AnnouncementResponse.
Member reads the announcement
- Mobile/web fetches
GET /announcements/unread-countfor the bell badge. - User opens the bell →
GET /announcements?limit=20returns paginated list with per-memberreadAt. - User taps an item →
PUT /announcements/:id/readupserts the read row. - Bell badge re-fetches.
Staff sees read receipts
- Staff opens the announcement detail.
GET /announcements/:idreturns title/content + read receipts list (member ID, read time).
Edge cases
| Scenario | Behavior |
|---|---|
Author opts out of announcement pref | They wouldn’t push anyway (excluded from recipients). Pref unrelated to their own creation. |
| Brand-new member joins after announcement was created | They see the announcement in their list — announcement_reads row absent → unread. Push not retroactively sent (only fans out at create time). |
| Mark read for an announcement not in the user’s org | Today: no org check on the per-id read endpoint (verify); could allow cross-org receipt forgery if not gated |
| WS gateway down at create time | broadcastAnnouncement swallows / logs; DB row still created |
| Push queue backlog | Recipients receive push eventually; create response returns synchronously |
| Two-thousand member org | Push enqueue is one BullMQ job per chunk; processor handles chunking |
| Soft-delete | List filters deleted_at IS NULL; existing read rows persist |
Side effects
| Action | Writes | Emits | Pushes | Tracks |
|---|---|---|---|---|
| Create | announcements insert | WS announcement:new to org:{orgId} | Push to all active members except author (category announcement) | PostHog announcement_published |
| Read | announcement_reads insert (idempotent) | — | — | — |
| List / detail / count | — | — | — | — |
Permissions
| Action | Auth |
|---|---|
| Create | Staff role |
| Detail (with read receipts) | Staff role |
| List / mark read / unread count | Any active member |