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 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 |
Email goes only to active members; the author and members with announcement email switched off are skipped | resolveEmailAudience (MembershipsService.resolveAudience + PushNotificationsService.filterByChannel) |
| An explicit recipient list is intersected with the org’s active roster | A foreign membership id never becomes a recipient |
An audience over ANNOUNCEMENT_EMAIL_MAX_RECIPIENTS (2000) refuses the whole send before anything is published | assertWithinEmailLimit, run before the insert |
An email-only announcement has no announcements row | createAnnouncement skips the insert, broadcast and push when inApp is false |
| Attachment: one file, 5 MB, allowlisted types, email only | Presign policy, prepareAttachment (re-checked on the real object), 400 if sent without an email audience |
| The AI agent cannot request email | createAnnouncementSchema (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-op | Insert with ON CONFLICT DO NOTHING (verify implementation) |
Golden paths
Coach broadcasts “Holiday hours”
- Coach opens the create-announcement dialog, types title + content.
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.
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).
| App | Result | |
|---|---|---|
| on | off | The normal announcement: stored, listed in the app, realtime event and push. |
| on | on | The normal announcement, plus an email to the chosen audience. |
| off | on | Email 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
- 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). - 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.
- A valid file uploads straight to R2 through
POST /announcements/attachment/presign(staff only, same staged-upload flow as other uploads). - 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. - 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.
- The file goes only with the email. It is not shown in the app, even when App is also on.
Coach emails the announcement
- In the create dialog the coach switches on “Email”. The audience picker appears.
- 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).
POST /announcements/email-preview(debounced, on every change) returnsmatched,eligible,optedOutandoverLimit. 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 fromGET /memberswith the same filters.POST /announcementscarriesemailAudience. The service resolves the audience again (it never trusts the preview), publishes, then queues oneannouncement-emailjob per recipient.AnnouncementEmailProcessorsends each email throughEmailServicewith the org’s branding and the recipient’s locale. The idempotency keyannouncement:{id}:{userId}stops a retry from mailing someone twice.- The response carries
emailQueuedandemailFailed. A queue outage never fails the request: the announcement stays published andemailFailedistrue. - One
announcement.emailedaudit row records the counts. It holds no addresses.
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 |
| Member switched off announcement email | Skipped, 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 queued | Announcement 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 sends | The job carries the title and content, so the email still goes out. |
| Attachment over 5 MB | Refused 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 expires | Not 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 send | Stays in R2 under announcement-attachments/ until the bucket lifecycle rule “Announcement attachments 30 days” deletes it (set on vibrafit-exports and fitkit-dev). |
| 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 |
Create with emailAudience | audit row announcement.emailed | — | One email per eligible recipient, via the announcement-email queue | — |
| Read | announcement_reads insert (idempotent) | — | — | — |
| List / detail / count | — | — | — | — |
Permissions
| Action | Auth |
|---|---|
| Create | Staff role |
| Email preview | Staff role |
| Detail (with read receipts) | Staff role |
| List / mark read / unread count | Any active member |