feat(messages): agency to caregiver messaging inside the platform - #192
Merged
Conversation
Coordinators and caregivers were talking by personal text message, which put work conversation, and sometimes client detail, on personal phones outside any retention, audit, or BAA the agency holds. One thread per (agency, caregiver), not per topic. A caregiver has one conversation with their office the way a text thread works, and making somebody pick a category on a phone would just push them back to SMS. The unique constraint makes opening a thread idempotent, so two concurrent opens cannot split a conversation in half. Threads are agency-scoped, so a caregiver working at two agencies has two separate conversations and neither agency can observe the other. Staff address a caregiver by id, but the thread is always resolved inside the caller's own agency, so an id belonging to another tenant reaches a local thread that will simply never have messages rather than the other agency's conversation. Message bodies are PHI: people will discuss clients here. Bodies never enter a notification payload or an audit payload. The push says only that a message arrived, and the caregiver opens the app to read it. Unread counts are computed as aggregates rather than a conditional join. The join has to express "newer than the read mark, or all of them when it is null", and a null comparison in SQL quietly yields no rows, which would report zero unread on a thread nobody has ever opened, the exact opposite of the truth. Posting a message and stamping the thread's activity time happen in one transaction, so a thread can never show a message it does not list or list a message without appearing active. The unread-count endpoint answers zero rather than failing, because a badge is not worth breaking a screen over.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Coordinators and caregivers were talking by personal text message, which put work conversation, and sometimes client detail, on personal phones outside any retention, audit, or BAA the agency holds.
One thread per caregiver, not per topic
A caregiver has one conversation with their office, the way a text thread works. Making somebody pick a category on a phone would just push them back to SMS. The
(agency_id, caregiver_id)unique constraint makes opening a thread idempotent, so two concurrent opens cannot split a conversation in half.Cross-agency isolation
Threads are agency-scoped, so a caregiver working at two agencies has two separate conversations and neither agency can observe the other. Staff address a caregiver by id, but the thread is always resolved inside the caller's own agency, so an id belonging to another tenant reaches a local thread that will simply never have messages, rather than the other agency's conversation.
messages.agency_idis denormalized specifically so every message read is tenant-filtered without depending on a join.Bodies are PHI
People will discuss clients here. Bodies never enter a notification payload or an audit payload. The push says only that a message arrived; the caregiver opens the app to read it.
The unread count is not a clever join
Counts are computed as aggregates. The join version has to express "newer than the read mark, or all of them when it is null", and a null comparison in SQL quietly yields no rows, which would report zero unread on a thread nobody has ever opened, the exact opposite of the truth. I wrote it as a join first and it was wrong in exactly that way.
Consistency
Posting a message and stamping the thread's activity time happen in one transaction, so a thread can never show a message it does not list or list a message without appearing active. Read state is a per-side high-water mark on the thread rather than per-message receipts, which would be a lot of write traffic for a feature nobody asked for.
The unread-count endpoint answers
{count: 0}rather than failing: a badge is not worth breaking a screen over.Migration
2026-08-04-add-messaging, two new tables. Needs applying to prod. Until then both surfaces load and every call 500s.Tests
13 route tests: session-derived thread resolution, caregiver-vs-staff dispatch, caregiver cannot post as staff, staff cannot post as caregiver, empty/whitespace rejection, trimming, unread count degradation. All four suites pass, plus typecheck, lint,
sql:scan,security:scan.The repo's design-system guard caught a hardcoded hex I had used as a CSS fallback; it now uses the existing
--color-surface-softtoken.Not included
No attachments, no read receipts, no typing indicators, and no message editing or deletion. Messages are append-only, which is the right default for a record an agency may later need to produce.