// Pure helpers behind the patient-owned collaboration-team client service (C1a plan, Task 7) - kept free // of environment/HttpService imports so they're importable under the plain-node vitest environment (the // same split as relatedPersonAuthority.ts / chargeItemDefinitionSearch.ts). // // Byte-identical to the server contract (Tasks 1-6) - do not add/rename/reorder states without a // matching server-side change. export type TeamStatus = 'active' | 'dormant' | 'dissolved'; export type MembershipState = | 'invited' | 'pending-patient-approval' | 'active' | 'declined' | 'left' | 'removed' | 'denied'; /** * S1b adds `external-practitioner`: an invitee who is NOT on Akashic at all, keyed by * `external:` because they have no `Practitioner/{id}` yet. The row is a placeholder * that grants nothing; accepting the emailed token replaces it with a real Practitioner row. * * The server has been returning these rows already - this type was simply behind. Anything that * switches on `memberClass` must handle it explicitly: the memberRef is NOT a resolvable resource * reference, so treating it like one (the old `practitioner ?...: relatedPerson` fallthrough) tries * to fetch a RelatedPerson whose id is a hash. */ export type MemberClass = 'practitioner' | 'related-person' | 'external-practitioner'; export interface TeamMembership { teamId: string; memberRef: string; memberClass: MemberClass; role: string | null; state: MembershipState; invitedByRef: string; consentId: string | null; createdAt: string; updatedAt: string; /** * - the member's name, resolved BY THE SERVER, present only where the patient is being * asked to decide about them. * * The client cannot resolve this for itself and that is the whole reason it is here: a patient may * only read practitioners inside their own organization, and an external collaborator is by * definition outside it - so the one member they must be told about is the one the client can never * look up. On the team the server already knows the caller is on, membership IS the authorization * to know who is on it. * * Absent means UNRESOLVED, never "no name" - the consent dialog refuses rather than degrading. */ displayName?: string; /** * Whether approving this member still needs the patient's agreement - the SERVER's answer. * * > Spencer, 2026-09-06: the three agreements are with the ORGANIZATION, so inviting a * > same-organization practitioner to a collaboration team asks the patient for no new * > agreement; the build asking for one is a regression. * * ABSENT MEANS ASK. The server only ever sends `false`, the interesting answer, and says nothing * otherwise - so a capped load, an unreadable practitioner and an api that predates the field all * land on the safe side by omission rather than by anybody remembering to handle them. * * NEVER COMPUTED HERE. Whether the patient holds an authorization with somebody's organization * is an authorization fact, a client gate is not a gate, and a patient's own token cannot read * the practitioner roles of anybody outside their organization to work it out even if it should. */ requiresPatientAgreement?: boolean; /** True for an active clinician who cannot read the record until the patient gives access; the server decides. */ awaitingPatientAuthorization?: boolean; /** * Which practice or practices this member is from - the SERVER's answer, and only ever the * server's. * * The client cannot work it out, for the reason `displayName` above already gives: a patient may * only read practitioners and roles inside their own organization, and a collaboration team is * deliberately cross-org, so the member whose practice most needs saying is the one their own * token can never look up. * * THREE STATES, AND A SURFACE MUST READ ALL THREE, because two of them are the same blank line: * * names present the practices, in the order the member's live roles came back * EMPTY ARRAY answered, and they belong to no organization - an outside practitioner, * who is a first-class member of a team rather than a failed lookup * ABSENT not answered: the read failed, or the server's lookup cap was reached * * An empty array is a POSITIVE answer and must never be collapsed into absence. On a screen whose * every row carries a Remove button, "they have no practice" and "we could not tell you" are * different things to say. See `MemberAffiliation`. */ organizationDisplays?: string[]; /** * What this member practises - the SERVER's answer, off the same role read as the practice above. * * ABSENT AND EMPTY MEAN THE SAME THING HERE, which is the one asymmetry with * `organizationDisplays` and is deliberate. A practitioner with no specialty coded is ordinary and * says nothing about them, so a reader has nothing to tell apart; a practitioner with no PRACTICE * is a distinct fact somebody acts on, which is why that one must still be distinguishable from a * read that failed. */ specialtyDisplays?: string[]; // --- S1b: present ONLY on a pending `external-practitioner` invite --- // The server deliberately returns these (the owner needs to see who they invited and when it // lapses; an NPI is public registry data) while stripping the invite CREDENTIAL - there is no // token or token hash here, and there must never be. /** The invited address. The memberRef is a hash OF this; this is the human-readable copy. */ inviteEmail?: string; inviteNpi?: string; /** ISO expiry. After this the emailed link stops working and the invite must be re-sent. */ inviteExpiresAt?: string; /** The name NPPES holds for that NPI - evidence for a human to sanity-check, never identity proof. */ nppesName?: string; /** Names one outside invitation; a resend or a fresh invite to the same address is another. */ invitationGeneration?: string; inviteOrigin?: 'external' | 'native' | 'share'; /** Server-owned: replaced on every write of the row. */ rowRevision?: string; } /** What a patient or witness was shown when approving an outside invitation; the server checks it fresh. */ export interface ShownIdentity { practitionerRef: string; npi: string; nppesName: string; accountName: string; invitationGeneration: string; } /** A member who came from an outside invitation, whose approval must name the identity shown. */ export const cameFromOutsideInvite = (member: TeamMembership): boolean => member.inviteOrigin === 'external' || Boolean(member.nppesName); /** * One decision's subject: the member as shown, down to the revision and the identity on screen. A preview, an assent * or a send made for one key belongs to nothing once the live row has another. */ export const decisionKeyOf = (teamId: string, member: TeamMembership): string => [ teamId, member.memberRef, member.state, member.rowRevision ?? member.updatedAt, member.invitationGeneration ?? '', member.inviteNpi ?? '', member.nppesName ?? '', member.displayName ?? '', ].join('|'); export const shownIdentityOf = (member: TeamMembership, accountName: string | null | undefined): ShownIdentity => ({ practitionerRef: member.memberRef, npi: member.inviteNpi ?? '', nppesName: member.nppesName ?? '', accountName: accountName ?? '', invitationGeneration: member.invitationGeneration ?? '', }); export interface CollaborationTeam { teamId: string; patientId: string; /** * - whose care this team is for, resolved by the server. * * PRESENT ONLY FOR A CALLER WHO IS AN ACTIVE MEMBER, which is the same line the server already * draws for the roster. Its absence is a real state - an invited-but-not-approved member - and not * a value still loading, so a surface must degrade rather than wait for it. * And the client must NOT resolve it itself from `patientId`: a patient outside the caller's own * organization cannot be read by them, which is exactly the case a collaboration team exists for. */ patientDisplay?: string; name: string; status: TeamStatus; lifecycleRevision?: number; activationGeneration?: number; lifecycleCleanupOwed?: boolean; lastDecision?: { id: string; action: 'rename' | 'withdraw' | 'rejoin' | 'dissolve' | 'reactivate'; basis: number; name?: string; cleanup: 'complete' | 'owed' }; createdByOrganizationId?: string | null; createdAt: string; updatedAt: string; lastMessageAt?: string; members?: TeamMembership[]; /** On the owner's read only: members the team removed, when, and by whom (null when not recorded). */ removedMembers?: RemovedTeamMember[]; } export interface RemovedTeamMember { memberRef: string; memberClass: MemberClass; removedAt: string; removedByRef: string | null; /** The name the server resolved, when it could; absent means unresolved. */ displayName?: string; } export const STATE_LABELS: Record = { invited: 'Invited', 'pending-patient-approval': 'Pending patient approval', active: 'Active', declined: 'Declined', left: 'Left', removed: 'Removed', denied: 'Denied', }; export const STATUS_LABELS: Record = { active: 'Active', dormant: 'Dormant', dissolved: 'Closed', }; /** The row-status tone each team state is drawn in, shared by the patient and staff Teams lists. */ export const TEAM_STATUS_TONE: Record = { active: 'plain', dormant: 'attention', dissolved: 'ended', }; export interface InviteMemberRequest { memberRef: string; memberClass: MemberClass; role: string | null; } // Pure request-body builder - the unit-testable seam, kept free of hook/HTTP concerns. export const buildInviteRequest = ( memberRef: string, memberClass: MemberClass, role?: string, ): InviteMemberRequest => ({ memberRef, memberClass, role: role ?? null });