// THREAD-ID CONTRACT - must stay byte-identical to the server's // aws/sam/services/api/src/utils/encounterRoomThread.ts. The server is authoritative; this // module exists only to mirror it. A drifted id does not error: it routes a read to the WRONG ROSTER, // which is a cross-thread disclosure rather than a routing nuisance. // // A visit's messages live in a thread whose roster is the people actually admitted to the room. The id is // deterministic (`encounter#{encounterId}`) so the meeting can find its thread with no lookup, keyed by // ENCOUNTER not session: a visit that drops and reconnects is one room and one thread. // // The prefix is deliberately the SAME string the video context uses (`encounter#{encounterId}`, // videoContract.ts) - the identical dual use `collabteam#{teamId}` already has (collaborationTeamThread.ts). // Deliberate, not coincidence: one id for one room. export const ENCOUNTER_ROOM_THREAD_PREFIX = 'encounter#'; export const buildEncounterRoomThreadId = (encounterId: string): string => `${ENCOUNTER_ROOM_THREAD_PREFIX}${encounterId}`; /** * NOT a bare `startsWith`, and the extra condition is load-bearing. * * `encounter` is a VALID FHIR id (`[A-Za-z0-9\-\.]{1,64}`), and a direct thread id is * `patientId#organizationId#staffAccountId` (Communication/thread/post.ts, server side). So a direct * thread for a patient whose id is literally `encounter` serialises to `encounter#org#staff` - which a * prefix-only predicate would claim as a room thread, routing a two-party direct thread through the ROOM * roster. That is a cross-thread read, not a routing nuisance. * * A room thread id has EXACTLY ONE '#'; a direct thread has two. FHIR ids cannot contain '#', so the * suffix of a genuine room thread id never does either. This makes the kinds provably disjoint for every * possible patient id, rather than disjoint-in-practice-because-ids-happen-to-be-UUIDs. * * The sibling `collabteam#` / `collabaside#` predicates (collaborationTeamThread.ts) still use a bare * `startsWith` and carry the same latent hole. Not changed here - that is someone else's blast radius - * but worth closing. */ export const isEncounterRoomThreadId = (threadId: string | null | undefined): threadId is string => { if (typeof threadId !== 'string' || !threadId.startsWith(ENCOUNTER_ROOM_THREAD_PREFIX)) { return false; } const encounterId = threadId.slice(ENCOUNTER_ROOM_THREAD_PREFIX.length); return encounterId.length > 0 && !encounterId.includes('#'); }; /** Only meaningful for an id `isEncounterRoomThreadId` has already accepted. */ export const extractEncounterIdFromRoomThreadId = (threadId: string): string => threadId.slice(ENCOUNTER_ROOM_THREAD_PREFIX.length);