// src/utils/portalIdentity.ts // // - WHICH RECORD THIS SESSION IS, chosen ONCE. // // THE DEFECT THIS EXISTS TO END. `fetchUserData` picked the record's ID and the record's TYPE in two // separate statements twelve lines apart: // // const id = practitionerIds[0] ?? patientIds[0] ?? relatedPersonId; <-- practitioner FIRST, always //... // if (staff mount && practitionerIds) type = Practitioner // else if (patient mount && patientIds && loggedInAs PATIENT) type = Patient // // The id never looked at which portal it was on; the type never looked at anything else. For anybody // with a SINGLE identity the two agree by accident, which is why this survived. For somebody holding BOTH, // on the patient portal, the type was `Patient` and the id was **their practitioner's**. // // Observed on staging 2026-08-26, not inferred: `GET /R4/Patient/6dc2942d...` 403, then the app signed // itself out. The same account on the staff mount produced *"Practitioner 6dc2942d... is not authorized"* // from the server - two independent sources agreeing that the id was a practitioner's. // // AND IT IS THE FOUNDING MODEL, NOT AN EDGE CASE. One human = one account whose home record is a // Patient; the practitioner hat is the situational part. Staff at a small practice are usually patients at // that practice - the receptionist, the practice manager and the physician all get their care where they // work. Every one of them was locked out of their own patient portal. // // PURE, and extracted for the same reason `crossPortalConflict.ts` next door was: the DECISION is the // part that was wrong, and a decision needing a network, a mount and a signed-in browser to exercise is a // decision nobody checks. // // IT KNOWS NO ROLE STRINGS, DELIBERATELY. The caller owns the vocabulary (`USER_ROLES` lives in // `AuthService`) and passes plain booleans in and a `kind` back out. Duplicating the literals here would // create a second enumeration of the same concept - which is exactly how a Marketing hire once ended up // with a role no branch compared against. /** Which of the three identities a mount resolved to. Mapped to FHIR resource types by the caller. */ export type PortalIdentityKind = 'practitioner' | 'patient' | 'relatedPerson'; export type PortalIdentity = { kind: PortalIdentityKind; /** Never chosen separately from the kind above. That separation IS the defect. */ id: string; }; /** * The identity this mount should resolve, or `undefined` when none of them fits. * * `undefined` is a real and expected answer, not a failure: a staff session restored on the patient * mount holds a perfectly valid account that came through the wrong door. The caller distinguishes that * recoverable case from a genuinely broken session - see `crossPortalHolding`. Do not make this * function guess on their behalf; guessing is what it was written to stop. */ export const resolvePortalIdentity = (input: { isStaffMount: boolean; isPatientMount: boolean; /** Which identity the person chose at sign-in. Only ever true on the patient portal. */ loggedInAsPatient: boolean; loggedInAsRelatedPerson: boolean; practitionerIds: string[]; patientIds: string[]; relatedPersonId: string | undefined; }): PortalIdentity | undefined => { const { isStaffMount, isPatientMount, loggedInAsPatient, loggedInAsRelatedPerson, practitionerIds, patientIds, relatedPersonId, } = input; // EACH BRANCH YIELDS ITS OWN ID. That is the whole fix: the three branches already knew which // identity they meant, and the id was being chosen beside them rather than by them. // THE STAFF BRANCH ASKS WHO SIGNED IN, EXACTLY AS THE PATIENT BRANCHES BELOW DO. That symmetry is // the fix, and its absence is why a staff portal opened on a patient session spun forever. // // The two portals are separate doors: only one can be open on a device, and each needs its own // emailed code. `CrossPortalGate` exists to say precisely that - but it only renders when NOTHING // resolves. Without this guard a person who signed in as a PATIENT and then opened the staff portal // resolved a practitioner here, so the gate was skipped, the app believed it had a staff session, // and the workspace layer beneath it waited forever for a state that sign-in never established. // Measured 2026-08-27: every request 200, the practitioner fetched fine, and the page showed a // spinner and nothing else. NOTHING FAILED, which is why no log ever pointed at it. // // It is expressed as "not signed in on the patient side" rather than as a positive staff flag // because there is no staff flag to test: `loggedInAs` is only ever `Patient` or `RelatedPerson`, // and a staff sign-in leaves it unset. So a staff-only account is unaffected - both booleans are // false and this branch matches exactly as before. // IT REQUIRES THE RECORD, NOT JUST THE FLAG, AND THAT IS THE SAFE DIRECTION. Refusing the staff // branch on the flag alone would send an account with a stray `loggedInAs` and no patient record // through to `logout()` - and signing somebody out costs an emailed code, so the failure mode of // guessing wrong here is expensive and lands on staff mid-shift. Requiring a real patient-side // identity means this can only ever fire for somebody who genuinely holds both. const signedInOnThePatientSide = (loggedInAsPatient && patientIds.length !== 0) || (loggedInAsRelatedPerson && Boolean(relatedPersonId)); if (isStaffMount && !signedInOnThePatientSide && practitionerIds.length !== 0) { return { kind: 'practitioner', id: practitionerIds[0] }; } if (isPatientMount && loggedInAsPatient && patientIds.length !== 0) { return { kind: 'patient', id: patientIds[0] }; } if (isPatientMount && loggedInAsRelatedPerson && relatedPersonId) { return { kind: 'relatedPerson', id: relatedPersonId }; } return undefined; };