// Shared string contract for collaboration-team message threads (C2). A collab-team thread has no // stored thread record - its id is derived from the C1a teamId, using the same `#` delimiter direct // threads already use so it round-trips the existing ?threadId= -> Thread/{id} based-on search path. export const COLLAB_TEAM_THREAD_PREFIX = 'collabteam#'; export const buildCollabTeamThreadId = (teamId: string): string => `${COLLAB_TEAM_THREAD_PREFIX}${teamId}`; export const isCollabTeamThreadId = (threadId: string | null | undefined): threadId is string => typeof threadId === 'string' && threadId.startsWith(COLLAB_TEAM_THREAD_PREFIX); export const extractTeamIdFromThreadId = (threadId: string): string => threadId.slice(COLLAB_TEAM_THREAD_PREFIX.length); // Practitioner-only "aside" thread (Slice 3 / C3) - a SECOND thread per collaboration team. Distinct prefix // (`collabaside#` diverges from `collabteam#` at char 6, so the two isXThreadId predicates are disjoint). export const COLLAB_TEAM_ASIDE_THREAD_PREFIX = 'collabaside#'; export const buildCollabTeamAsideThreadId = (teamId: string): string => `${COLLAB_TEAM_ASIDE_THREAD_PREFIX}${teamId}`; export const isCollabTeamAsideThreadId = (threadId: string | null | undefined): threadId is string => typeof threadId === 'string' && threadId.startsWith(COLLAB_TEAM_ASIDE_THREAD_PREFIX); export const extractTeamIdFromAsideThreadId = (threadId: string): string => threadId.slice(COLLAB_TEAM_ASIDE_THREAD_PREFIX.length); // Private 1:1 sub-thread inside a collaboration team - any two members, not just practitioners. // // Spencer, 2026-09-10: *"A patient may want to confer with a single practitioner about something // during a collab chat or perhaps a patient and a related person may want to message each other // privately about the collab discussion. If we don't add that 1:1 private option it will feel // unfinished to users."* It is new scope - the original design had only the practitioner-only aside, // and cross-org-collaboration-teams.md carries a correction saying so. That correction described // what had been built; this supersedes it deliberately rather than by accident. // // A SUB-THREAD OF THE TEAM, NOT A THREAD BESIDE IT. *"Private and aside messages should be self // contained within the collab team and within the data basically be a sub-thread of the main team // thread."* The aside is already built this way and is the precedent. What it buys beyond tidiness: // team membership already answers who may be here, so there is no second grant model to keep in // step; a dissolved team takes its private threads with it instead of orphaning them; and a member // removed from the team loses the private threads that only existed because of it, with nothing // having to remember to revoke them. // // The prefix diverges from both siblings at char 6 (`collabt` / `collaba` / `collabp`), so all three // isXThreadId predicates stay disjoint. export const COLLAB_TEAM_PRIVATE_THREAD_PREFIX = 'collabprivate#'; // THE PAIR IS SORTED, AND THIS IS THE WHOLE TRAP. // // If the two references go into the id in whatever order the opener happens to hold them, the same // two people get two different threads depending on who started it. Each one then sees half a // conversation, and both halves look perfectly correct - there is no error, no empty state and // nothing to notice. Sorting makes the id the same whoever opens it. // // It is not enough to sort on one side. Both the opener and anything resolving an incoming id must // build it through here, which is why this is a function and not a template literal at the call site. export const buildCollabTeamPrivateThreadId = ( teamId: string, memberReferenceA: string, memberReferenceB: string, ): string => { const pair = [memberReferenceA, memberReferenceB].sort(); return `${COLLAB_TEAM_PRIVATE_THREAD_PREFIX}${teamId}#${pair[0]}#${pair[1]}`; }; export const isCollabTeamPrivateThreadId = (threadId: string | null | undefined): threadId is string => typeof threadId === 'string' && threadId.startsWith(COLLAB_TEAM_PRIVATE_THREAD_PREFIX); // The team a private sub-thread belongs to. A reference contains no `#`, so the team id is the // first segment and splitting on `#` is safe - but it is done here rather than at four call sites, // because that assumption is exactly the kind that stops being true quietly. export const extractTeamIdFromPrivateThreadId = (threadId: string): string => threadId.slice(COLLAB_TEAM_PRIVATE_THREAD_PREFIX.length).split('#')[0]; // The two members, in the sorted order the id carries. Returns null for anything that is not a // private sub-thread id or does not carry both halves, so a malformed id cannot be mistaken for a // thread between one person and themselves. export const extractPairFromPrivateThreadId = ( threadId: string, ): [string, string] | null => { if (!isCollabTeamPrivateThreadId(threadId)) return null; const parts = threadId.slice(COLLAB_TEAM_PRIVATE_THREAD_PREFIX.length).split('#'); if (parts.length !== 3) return null; if (!parts[1] || !parts[2]) return null; return [parts[1], parts[2]]; };