// adolescent confidentiality - the CLIENT half of the 18+ gate (Spencer, 2026-07-31). // // Messaging and collaboration teams are 18+. The SERVER is the boundary and already denies these // routes (`RouteConfig.denyAdolescentPatient`, enforced in the authorizer). This is therefore UX, not // security - but without it a 12-17 patient sees entry points that simply error. Spencer's decision: // the surfaces are **ABSENT**, not disabled and not an empty state, because read-but-not-act buys no // confidentiality and only creates a confusing dead end. // // MIRRORS THE SERVER'S AXIS: the LOGGED-IN identity, never the act-as subject. A parent acting as // their child keeps `currentUser.fhir` as the PARENT - act-as swaps the data subject through a separate // `actingAs` target and never reassigns `currentUser` - so the parent keeps messaging, which is the // whole model. Keying on the VIEWED patient instead would hide messaging from the parent: the exact // inversion the server-side work flagged as a worse failure than the gap it closes. // // Band is 12-17 INCLUSIVE, matching the server's `utils/adolescentBand.ts`. Kept as a local mirror // rather than shared because the two repos deploy independently; the tests pin both edges so drift // between them fails loudly rather than silently widening or narrowing the band. export const ADOLESCENT_MIN_AGE = 12; export const ADOLESCENT_MAX_AGE = 17; /** * Structural, NOT `IUser` from AuthService - deliberately. Importing that type pulls the services * barrel into every type-check of this module, which is the 2.1GB tsc blow-up this repo already knows * about. Keeping the shape structural leaves this a true leaf that can be checked on its own, and the * only fields the decision needs are these two. */ type SelfIdentity = { fhir?: { resourceType?: string; birthDate?: string } | null } | null | undefined; /** * True when the LOGGED-IN user is a patient in the adolescent band, i.e. when the 18+ surfaces should * not be rendered at all. * * `asOf` is injected (never `Date.now()` inside) so the 18th-birthday transition is testable and the * answer stays COMPUTED - there is no stored "canMessage" flag to go stale, which is what makes the * gate lift by itself when the patient turns 18. */ export function isAdolescentSelf(user: SelfIdentity, asOf: Date = new Date()): boolean { return adolescentGateReason(user, asOf) !== "not-applicable"; } /** * WHY the 18+ surfaces are hidden - the same decision `isAdolescentSelf` makes, with its two very * different causes kept apart. * * WHY THIS EXISTS, and it is not tidiness. A single boolean made two situations indistinguishable: * - `adolescent` - a 12-17 patient. Spencer's ruling is that the surfaces are **ABSENT**, not disabled * and not explained. Saying "you cannot message because you are 15" to a 15-year-old is the product * commenting on their age, and the decision was deliberately silence. * - `unknown-age` - an ADULT with no `birthDate` on file, blocked only because the gate fails closed. * **They can clear it themselves in one field, and nothing told them so.** Measured in a browser on * 2026-08-19: a brand-new patient had no Messages door and no way to discover why. * * THE SERVER AGREES AND IT IS NOT A CLIENT-ONLY QUIRK. `isAdolescentPatientCaller` blocks on an * unknown birthDate too, calling the cost *"an ADULT with no birthDate on file loses messaging"* and a * *"data-quality problem which should surface loudly"*. It surfaces loudly in CloudWatch; to the patient * it surfaced not at all. Telling the ONE person who can fix it is what this distinction buys. * * IT DOES NOT CHANGE WHAT IS HIDDEN. `isAdolescentSelf` is defined in terms of this, so the gate is * byte-for-byte the decision it always was - and a caller that wants to EXPLAIN the block asks for the * reason instead of re-deriving it from a birthDate and drifting. */ export type AdolescentGateReason = "not-applicable" | "adolescent" | "unknown-age"; export function adolescentGateReason(user: SelfIdentity, asOf: Date = new Date()): AdolescentGateReason { const fhir = user?.fhir; // Not a patient acting as themselves -> the gate does not apply. This branch is what keeps the // parent (RelatedPerson) and staff (Practitioner) surfaces intact, so it comes first. // // AND IT IS WHY ACTING-AS IS NOT AFFECTED BY THIS GATE, which is deliberate rather than an // oversight: the caller is keyed on the LOGGED-IN identity, so a parent switched into their child // keeps messaging on the child's behalf. Inverting it to the viewed patient would block the one // person the model exists to let through. Do not "fix" acting-as by keying this on the subject. if (fhir?.resourceType !== "Patient") { return "not-applicable"; } const age = computeAgeYears(fhir.birthDate, asOf); if (age === undefined) { // Fail CLOSED, matching the server. Hiding a surface the server would deny anyway is the // harmless direction; showing one that errors is not. return "unknown-age"; } return age >= ADOLESCENT_MIN_AGE && age <= ADOLESCENT_MAX_AGE ? "adolescent" : "not-applicable"; } /** * True when a SUBJECT patient falls in the adolescent band - the "is this invoice for a 12-17 year * old" question, as opposed to `isAdolescentSelf`'s "is the logged-in user a minor". * * FAILS **OPEN** (returns false on an unknown birthDate), which is the OPPOSITE of every other * decision in this feature, and deliberately so. This drives an ADVISORY NOTICE to staff, not an access * decision - the server's generalization is the actual control and runs regardless. Failing closed here * would show "this patient is a minor, keep it vague" on every patient whose birthDate happens to be * missing, and a warning that cries wolf is one staff learn to dismiss. Nothing is disclosed by its * absence. */ export function isAdolescentPatient( patient: { birthDate?: string } | null | undefined, asOf: Date = new Date(), ): boolean { const age = computeAgeYears(patient?.birthDate, asOf); return age !== undefined && age >= ADOLESCENT_MIN_AGE && age <= ADOLESCENT_MAX_AGE; } // A DELIBERATE LINE-FOR-LINE MIRROR of the server's utils/adolescentBand.ts `computeAgeYears`, // NOT a reuse of this repo's dateUtils.parseFhirDate. That helper is module-private, and more // importantly it parses a date-only value as LOCAL midnight (correct for RENDERING a birthday on the // right calendar day) - mixing it with UTC getters here would produce a timezone-dependent off-by-one. // A gate that disagrees with the server about someone's age is worse than one that is slightly // duplicated: the client would hide a surface the server allows, or show one it denies. /** Under 18 on a known date of birth. An unknown age is not treated as a minor. */ export function isMinorPatient( patient: { birthDate?: string } | null | undefined, asOf: Date = new Date(), ): boolean { const age = computeAgeYears(patient?.birthDate, asOf); return age !== undefined && age < 18; } function computeAgeYears(birthDate: string | undefined, asOf: Date): number | undefined { if (!birthDate) return undefined; const m = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/.exec(birthDate.trim()); if (!m) return undefined; const bd = new Date(Date.UTC(Number(m[1]), m[2] ? Number(m[2]) - 1 : 0, m[3] ? Number(m[3]) : 1)); if (Number.isNaN(bd.getTime())) return undefined; let age = asOf.getUTCFullYear() - bd.getUTCFullYear(); const beforeBirthday = asOf.getUTCMonth() < bd.getUTCMonth() || (asOf.getUTCMonth() === bd.getUTCMonth() && asOf.getUTCDate() < bd.getUTCDate()); if (beforeBirthday) age -= 1; return age >= 0 ? age : undefined; }