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 ├── email fan-out — only when the author chose an audience; one queued job per recipient ├── 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
Email goes only to active members; the author and members with announcement email switched off are skippedresolveEmailAudience (MembershipsService.resolveAudience + PushNotificationsService.filterByChannel)
An explicit recipient list is intersected with the org’s active rosterA foreign membership id never becomes a recipient
An audience over ANNOUNCEMENT_EMAIL_MAX_RECIPIENTS (2000) refuses the whole send before anything is publishedassertWithinEmailLimit, run before the insert
An email-only announcement has no announcements rowcreateAnnouncement skips the insert, broadcast and push when inApp is false
Attachment: one file, 5 MB, allowlisted types, email onlyPresign policy, prepareAttachment (re-checked on the real object), 400 if sent without an email audience
The AI agent cannot request emailcreateAnnouncementSchema (the tool input) has no emailAudience; only the dashboard request schema does
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.
  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.

Coach chooses how to send

The dialog has two independent switches: App (on by default) and Email. At least one must be on; the API refuses a request with neither (400).

AppEmailResult
onoffThe normal announcement: stored, listed in the app, realtime event and push.
ononThe normal announcement, plus an email to the chosen audience.
offonEmail only. Nothing is stored, listed, broadcast or pushed. There are no read receipts. The announcement.emailed audit row (with inApp: false) is the record. Use it for members who have not installed the app.

Coach attaches a file to the email

  1. With Email on, the dialog offers one optional file. Allowed: PDF, images (jpg, png, webp, gif), Office files (doc, docx, xls, xlsx, ppt, pptx) and plain text. Limit: 5 MB (ANNOUNCEMENT_ATTACHMENT_MAX_BYTES).
  2. The browser checks size and type first. A file over the limit is refused at once with “Add a link to the file in the message instead”, and nothing is uploaded. Send stays disabled until the file is removed.
  3. A valid file uploads straight to R2 through POST /announcements/attachment/presign (staff only, same staged-upload flow as other uploads).
  4. On send, the server checks the upload is the sender’s, is an announcement attachment, and is within the limit against the real object size, not the declared one. The attachment name always takes its extension from the verified type, so a file cannot go out as report.pdf.exe.
  5. The server makes one download link (valid 24 hours) and puts it in every queued job. Resend downloads the file from that link when it sends. Our workers never read or hold the file, so a large send stays fast.
  6. The file goes only with the email. It is not shown in the app, even when App is also on.

Coach emails the announcement

  1. In the create dialog the coach switches on “Email”. The audience picker appears.
  2. The audience is either everyone the filters match (search, role, member state, payment, account), minus any people the coach unticks, or picked people (exactly the ticked ones).
  3. POST /announcements/email-preview (debounced, on every change) returns matched, eligible, optedOut and overLimit. The count comes from the server, so it is right however many rows the list has loaded. The list below the filters loads 50 members per page from GET /members with the same filters.
  4. POST /announcements carries emailAudience. The service resolves the audience again (it never trusts the preview), publishes, then queues one announcement-email job per recipient.
  5. AnnouncementEmailProcessor sends each email through EmailService with the org’s branding and the recipient’s locale. The idempotency key announcement:{id}:{userId} stops a retry from mailing someone twice.
  6. The response carries emailQueued and emailFailed. A queue outage never fails the request: the announcement stays published and emailFailed is true.
  7. One announcement.emailed audit row records the counts. It holds no addresses.

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
Member switched off announcement emailSkipped, and counted in optedOut. They still get the in-app announcement and, unless switched off, the push.
Member has no app account yet (imported, never invited)Still emailed — the filter accountStatus narrows this if the coach wants only activated members.
Redis is down when the email is queuedAnnouncement is published, emailFailed: true, error goes to Sentry. Retrying the create would publish it twice, so the UI tells the coach instead.
Announcement deleted before a queued email sendsThe job carries the title and content, so the email still goes out.
Attachment over 5 MBRefused in the dialog before any upload; the API also refuses it. The coach links the file in the message.
Retries after the 24-hour link expiresNot possible: jobs retry 3 times within minutes. A queue stuck for more than a day would send without the file and fail the send.
Attachment object after the sendStays in R2 under announcement-attachments/ until the bucket lifecycle rule “Announcement attachments 30 days” deletes it (set on vibrafit-exports and fitkit-dev).
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
Create with emailAudienceaudit row announcement.emailed—One email per eligible recipient, via the announcement-email queue—
Readannouncement_reads insert (idempotent)———
List / detail / count————

Permissions

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