Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesAnnouncementsAnnouncements — Behavior

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 out

Invariants

InvariantEnforcement
Only staff can createrequireStaffMembership
announcement_reads is unique per (announcement_id, user_id)DB unique
Author is excluded from push fan-outrecipients.filter(id !== authorUserId)
Push category is announcement — respects per-user prefPushNotificationsService.notifyUsers calls filterByPreferences
Read receipts are append-only — re-marking a read announcement is a no-opInsert with ON CONFLICT DO NOTHING (verify implementation)

Golden paths

Coach broadcasts “Holiday hours”

  1. Coach opens the create-announcement dialog, types title + content, optionally toggles priority.
  2. POST /announcements.
  3. Service:
    • Inserts row.
    • Fetches author name, role, and getActiveMemberCount.
    • gateway.broadcastAnnouncement(orgId, response) → WS event announcement:new to org:{orgId} room.
    • fanOutAnnouncementPush(orgId, authorUserId, response) queries every active membership, drops the author, calls push.notifyUsers(recipientIds, payload) with category announcement.
    • EventTrackingService.track('announcement_published', {...}).
  4. Returns the full AnnouncementResponse.

Member reads the announcement

  1. Mobile/web fetches GET /announcements/unread-count for the bell badge.
  2. User opens the bell → GET /announcements?limit=20 returns paginated list with per-member readAt.
  3. User taps an item → PUT /announcements/:id/read upserts the read row.
  4. Bell badge re-fetches.

Staff sees read receipts

  1. Staff opens the announcement detail.
  2. GET /announcements/:id returns title/content + read receipts list (member ID, read time).

Edge cases

ScenarioBehavior
Author opts out of announcement prefThey wouldn’t push anyway (excluded from recipients). Pref unrelated to their own creation.
Brand-new member joins after announcement was createdThey 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 orgToday: no org check on the per-id read endpoint (verify); could allow cross-org receipt forgery if not gated
WS gateway down at create timebroadcastAnnouncement swallows / logs; DB row still created
Push queue backlogRecipients receive push eventually; create response returns synchronously
Two-thousand member orgPush enqueue is one BullMQ job per chunk; processor handles chunking
Soft-deleteList filters deleted_at IS NULL; existing read rows persist

Side effects

ActionWritesEmitsPushesTracks
Createannouncements insertWS announcement:new to org:{orgId}Push to all active members except author (category announcement)PostHog announcement_published
Readannouncement_reads insert (idempotent)
List / detail / count

Permissions

ActionAuth
CreateStaff role
Detail (with read receipts)Staff role
List / mark read / unread countAny active member