import { useCallback, createContext, useContext, useEffect, useMemo, useRef, ReactNode } from 'react'; import { environment } from '../environments/environment.ts'; import { useHttpService } from './HttpService.tsx'; import { onViewerChange, viewerEpoch } from '../utils/writeThroughHold.ts'; import { authSessionId, credentials } from './authStorage'; import { isPatientExperience } from '../utils/mountContext.ts'; import { BirthDateSaveRetired, BirthDateSaveUnconfirmed } from './ownBirthDateOutcome.ts'; export { BirthDateSaveRetired, BirthDateSaveUnconfirmed }; /** Long enough for a slow connection, short enough that a mandatory gate never waits on a request forever. */ export const GATE_REQUEST_DEADLINE_MS = 15_000; export interface GateRequestOptions { /** The caller's own lifetime: aborting it retires the request. */ signal?: AbortSignal; deadlineMs?: number; } // Settles the operation the moment it is stopped, even when token or proof preparation never answers; the abort // still keeps a stopped request from being sent, and a late result is consumed, never applied. const settledWhenStopped = (run: (signal: AbortSignal) => Promise, controller: AbortController) => { let stop!: (reason: Error) => void; const stopped = new Promise((_, reject) => { stop = reject; }); const work = run(controller.signal); work.catch(() => undefined); return { race: (finish: (result: T) => T, fail: (error: unknown) => never) => Promise.race([work.then(finish, fail), stopped]), stop: (reason: Error) => { controller.abort(); stop(reason); }, }; }; // A read with a deadline over preparation and request; it fails rather than waits, and the gate offers Try again. const withDeadline = async (run: (signal: AbortSignal) => Promise, { signal, deadlineMs = GATE_REQUEST_DEADLINE_MS }: GateRequestOptions = {}): Promise => { const operation = settledWhenStopped(run, new AbortController()); const cancel = () => operation.stop(new DOMException('The read was cancelled.', 'AbortError')); if (signal?.aborted) cancel(); else signal?.addEventListener('abort', cancel, { once: true }); const timer = setTimeout(() => operation.stop(new Error('The read did not finish in time.')), deadlineMs); try { return await operation.race((result) => result, (error) => { throw error; }); } finally { clearTimeout(timer); signal?.removeEventListener('abort', cancel); } }; // A save owned by the viewer who started it. A viewer, act-as or practice change, or the caller's signal, retires it: // aborted before it is sent, and any answer withheld. Past the deadline it is unconfirmed, since it may have landed. const ownedSave = async (run: (signal: AbortSignal) => Promise, { signal, deadlineMs = GATE_REQUEST_DEADLINE_MS }: GateRequestOptions = {}): Promise => { const epoch = viewerEpoch(); const operation = settledWhenStopped(run, new AbortController()); let ended: 'retired' | 'deadline' | undefined; const retire = () => { if (ended) return; ended = 'retired'; operation.stop(new BirthDateSaveRetired()); }; const stopWatching = onViewerChange(retire); if (signal?.aborted) retire(); else signal?.addEventListener('abort', retire, { once: true }); const timer = setTimeout(() => { if (ended) return; ended = 'deadline'; operation.stop(new BirthDateSaveUnconfirmed()); }, deadlineMs); const outcome = () => { if (ended === 'retired' || viewerEpoch() !== epoch) return new BirthDateSaveRetired(); if (ended === 'deadline') return new BirthDateSaveUnconfirmed(); return undefined; }; try { return await operation.race( (result) => { const ending = outcome(); if (ending) throw ending; return result; }, (error) => { throw outcome() ?? error; }, ); } finally { clearTimeout(timer); stopWatching(); signal?.removeEventListener('abort', retire); } }; import type { AgreementBlock } from '../components/FirstSignInGate/agreementCopy.ts'; // (first-sign-in gate, Plan B), client Task 1 - the CLIENT half of the hard-blocking // first-sign-in gate: every authenticated human must accept Akashic's current Terms of Service, and // every patient-portal caller must additionally accept the platform Privacy Notice & Consent, before // they may proceed. This module is transport only - no gate UI, no copy, no mount; those are later // tasks in this plan. Mirrors OrgAuthorizationService.tsx's shape (hook + context provider + // useXContext guard). // // THE BASE IS apiUrl, NOT orgAuthorizationApi and NOT fhirBaseApi. These three routes // (akashic-agreements, akashic-terms, akashic-privacy-consent) live on the SAME nested stack as // ValueSetService.tsx's /LOINC and /UCUM (ValueSetService.tsx:363, also `environment.apiUrl`) - a // different gateway than the org-authorization-to-treat instrument OrgAuthorizationService.tsx talks // to. apiUrl already ends in /R4, so the path is `${API_URL}/akashic-agreements`, never // `${API_URL}/R4/akashic-agreements`. Getting this base wrong 404s silently - there is no type-check // in this repo to catch a typo'd environment key. const API_URL = environment.apiUrl; /** The instruments a patient answers, and the only ones this client carries copy for. */ export type PatientAgreementKind = 'terms-of-service' | 'privacy-consent'; // Patients and healthcare staff answer separate Terms; the server says which one this person owes. export type AgreementKind = PatientAgreementKind | 'staff-terms-of-service'; export type AgreementSurface = 'patient-portal' | 'staff'; export type AgreementDecisionOutcome = 'accept' | 'decline'; /** * The document itself, when the server has authored copy for the version it just resolved. * * ABSENT MEANS "no authored copy on this server for this version", which is the server's own stated * contract and an ordinary state rather than an error - versions published before the legal text moved * into the store have no blocks. A client that treats absence as a failure would gate on nothing. * * The block type is imported rather than restated so there is ONE definition of what a document is * made of; it is a type-only import and nothing of the gate's rendering reaches this module. */ export interface ServedAgreementCopy { title: string; blocks: AgreementBlock[]; body: string; } export interface OutstandingAgreement { kind: AgreementKind; version: string; uri: string; copy?: ServedAgreementCopy; /** A minor cannot contract: a parent or guardian accepts this for them, so they are not offered Accept. */ acceptedByGuardian?: boolean; /** A guardian accepting the terms for this child, apart from their own. */ forPatient?: { id: string; name: string }; /** No usable birth date is on record, so nobody may answer this yet. */ ageUnknown?: boolean; /** This caller may not answer it: only a guardian may for this child, or who may could not be read. */ answerUnavailable?: 'guardian-only' | 'retry'; } export interface OutstandingAgreementsResponse { outstanding: OutstandingAgreement[]; notApplicable?: AgreementKind[]; } /** * WHAT THIS PERSON HAS ALREADY ACCEPTED, AND AT WHICH VERSION. * * The service could record a decision and ask what was still outstanding, and it could not ask what had * already been answered - so a patient accepted both instruments at first sign-in and then found nothing * anywhere in the portal that said they had. * * ONE SHAPE FOR BOTH INSTRUMENTS, because a surface that shows them together must not have to know that * one is patient-scoped and the other is keyed on the Cognito sub. The servers answer the same question * with the same field names, which is what makes one type honest rather than a lowest common * denominator. * * `acceptedVersionUri` IS THE POINT OF THE WHOLE RECORD and is the field a reader must not confuse with * `agreementUri`: the first is the wording this person agreed to, the second is the wording in force * now. They are equal until a practice edits its text, and the day they differ is the day the * distinction matters - showing the current text beside an older signature makes every part of the * evidence sound and the record as a whole false. */ export interface AcceptedAgreement { /** False when nobody owes this instrument for the person being acted as, such as a login-less child. */ applies?: boolean; applicabilityReason?: 'demo'; /** True when a parent or guardian accepted it on this person's behalf. */ acceptedByGuardian?: boolean; /** The name of whoever answered on this person's behalf, when it can be read. */ acceptedByName?: string; /** Has ever accepted, version-blind - true even when the wording has moved on since. */ accepted: boolean; /** The wording in force now. */ currentVersion?: string; agreementUri?: string; /** The version this person accepted, absent when they never have. */ acceptedVersion?: string; acceptedVersionUri?: string; acceptedAt?: string; /** Whether the two versions are the same. Computed by the server, never re-derived here. */ acceptedCurrentVersion: boolean; /** The person is under 18, so a parent or guardian answers this for them. */ guardianAnswers?: boolean; /** No usable birth date is on record, so no answer counts yet. */ ageUnknown?: boolean; /** * The text of the ACCEPTED version, present only because this module requests `?include=copy`. * `null` is the server's own ordinary state for "no authored copy for that version" - not an * error, and not a reason to fall through to the current version's wording. */ acceptedCopy?: ServedAgreementCopy | null; } // GET {apiUrl}/akashic-terms and /akashic-privacy-consent. READS, and deliberately not built from // DECISION_ENDPOINT_BASE above: that map exists to record a decision and its comment says it is never // used for a read. Two purposes sharing one constant is how a write ends up pointed at a read's path. const ACCEPTED_ENDPOINT: Record = { 'terms-of-service': `${API_URL}/akashic-terms`, 'staff-terms-of-service': `${API_URL}/akashic-terms`, 'privacy-consent': `${API_URL}/akashic-privacy-consent`, }; // kind -> the ONE explicit endpoint family that records a decision on it. No server-supplied URLs - // two kinds, two hardcoded bases, mapped here and only here. `terms-of-service` is Akashic's OWN // Terms of Service (AkashicTerms/accept.ts + decline.ts, every human); `privacy-consent` is the // platform Privacy Notice & Consent (AkashicPrivacyConsent/accept.ts + decline.ts, patient-portal // callers only). Both are POST-only writes; this map is never used for a read. const DECISION_ENDPOINT_BASE: Record = { 'terms-of-service': `${API_URL}/akashic-terms`, 'staff-terms-of-service': `${API_URL}/akashic-terms`, 'privacy-consent': `${API_URL}/akashic-privacy-consent`, }; export const useAkashicAgreementsService = () => { const { get, post, put } = useHttpService(); // GET {apiUrl}/akashic-agreements?surface=patient-portal|staff -> {outstanding}. THE aggregator - // the ONE read this module issues, and the only read the first-sign-in gate needs: it already // combines both instruments server-side (AkashicAgreements/get.ts), so the client never has to // reason about their separate storage shapes or re-derive who owes what. // // NOTHING ELSE IN THIS MODULE MAY READ /akashic-privacy-consent DIRECTLY. That endpoint // (AkashicPrivacyConsent/get.ts) resolves the subject by RelatedPerson link alone, with no // authority-tier check - reading it here would re-create the exact lockout the server slice was // just fixed for: an assist-only guardian gets told they owe a consent that // POST /akashic-privacy-consent/accept will then 403 them for, on a gate with no close button, // whose only other move is decline-and-sign-out - and they can never reach the identity-proofing // flow that would fix it, because proofing sits behind this same gate. See // AkashicAgreementsService.test.ts's lockout-guard test, which asserts this against the source, // not just this comment. const getOutstanding = useCallback(async (surface: AgreementSurface, options?: GateRequestOptions): Promise => { return withDeadline((signal) => get(`${API_URL}/akashic-agreements?surface=${encodeURIComponent(surface)}`, { signal }), options); }, [get]); // POST {apiUrl}/akashic-terms/{accept|decline} or {apiUrl}/akashic-privacy-consent/{accept|decline}, // body {surface}. // // `surface` MUST be in every write body. AkashicTerms/accept.ts and decline.ts both 400 without // it (`surface must be 'patient-portal' or 'staff'`) - verified against the handlers. Sending it on // every call, including the two privacy-consent writes that don't currently validate it, keeps this // one rule instead of two and costs nothing if that ever changes. /** * What this person has already accepted for ONE instrument. * * Separate calls per instrument rather than one aggregate, because the two live on different * routes with different subjects - terms are keyed on the Cognito sub so staff have them too, * the privacy consent resolves a patient. An aggregate would have to pick one subject. */ // `?include=copy` asks the server to add `acceptedCopy` to the response (text of the version this // person accepted, present only when they have). The one caller of this method needs that text; // add an option here rather than hardcoding the query if a text-free caller ever shows up. const getAccepted = useCallback(async (kind: AgreementKind): Promise => { return get(`${ACCEPTED_ENDPOINT[kind]}?include=copy`); }, [get]); const recordDecision = useCallback(async ( kind: AgreementKind, outcome: AgreementDecisionOutcome, surface: AgreementSurface, forPatientId?: string, // The version on screen. The server refuses one no longer in force (409 stale-version). version?: string, options?: GateRequestOptions, ): Promise => { await ownedSave((signal) => post(`${DECISION_ENDPOINT_BASE[kind]}/${outcome}`, { surface, ...(forPatientId ? { forPatientId } : {}), ...(version ? { version } : {}), // Both Terms share one route, so a Terms answer names the Terms shown; the server refuses the other one. ...(kind === 'privacy-consent' ? {} : { kind }), }, { signal, // A retired attempt is never sent, even if the token/proof wait outlasted the viewer switch. admit: () => { if (signal.aborted) throw new BirthDateSaveRetired(); }, }), options); }, [post]); // PUT {fhirIdentityApi}/my-birth-date -> {birthDate}. The signed-in patient's own missing birth date, which the // Privacy notice needs before it can be answered. The server refuses a record that already has one (409). const recordOwnBirthDate = useCallback(async (birthDate: string, options?: GateRequestOptions): Promise<{ birthDate: string }> => { return ownedSave((signal) => put<{ birthDate: string }>(`${environment.fhirIdentityApi}/my-birth-date`, { birthDate }, { signal }), options); }, [put]); return { getOutstanding, getAccepted, recordDecision, recordOwnBirthDate }; }; const AkashicAgreementsServiceContext = createContext | undefined>(undefined); export const AkashicAgreementsServiceProvider = ({ children }: { children: ReactNode }) => { const service = useAkashicAgreementsService(); // The gate's read starts when this page sees a new sign-in land, beside the Practitioner read, not after it. The // gate's first read for that session and surface joins it; any other read, and every retry, asks afresh. const early = useRef<{ epoch: string; surface: AgreementSurface; answer: Promise }>(); const read = useRef(service.getOutstanding); read.current = service.getOutstanding; useEffect(() => { let last = credentials().current(); return credentials().onChange((view) => { const before = last; last = view; if (view.state !== 'signed-in' || before.state === 'loading') return; if (before.state === 'signed-in' && before.session.epoch === view.session.epoch) return; const surface: AgreementSurface = isPatientExperience() ? 'patient-portal' : 'staff'; const answer = read.current(surface); answer.catch(() => undefined); early.current = { epoch: view.session.epoch, surface, answer }; }); }, []); const getOutstanding = useCallback((surface: AgreementSurface, options?: GateRequestOptions): Promise => { const held = early.current; if (held && held.surface === surface && held.epoch === authSessionId()) { early.current = undefined; return withDeadline(() => held.answer, options); } return service.getOutstanding(surface, options); }, [service.getOutstanding]); const akashicAgreementsService = useMemo(() => ({ ...service, getOutstanding }), [service, getOutstanding]); return ( {children} ); }; export const useAkashicAgreementsServiceContext = () => { const context = useContext(AkashicAgreementsServiceContext); if (context === undefined) { throw new Error('useAkashicAgreementsServiceContext must be used within an AkashicAgreementsServiceProvider'); } return context; };