import { noteRecordAuthorizationAnswer } from '../utils/recordAuthorizationAnswers'; import { useCallback, createContext, useContext, ReactNode } from 'react'; import { environment } from '../environments/environment.ts'; import { useHttpService } from './HttpService.tsx'; import { ConsentInstrumentId } from '../config/consentInstruments.ts'; import type { SaveTransport } from '../utils/saveOperation'; // Phase 2 (clinical-authorization gate), client Task 8 - the CLIENT half of the org-authorization // gate: a patient (or their proxying related person) must agree to / attest to an organization's // authorization-to-treat agreement before certain clinical actions are allowed. These routes live // on their OWN gateway `orgAuthorizationApi` (/R4/org-authorization/...) - T7 relocated the // org-authorization family off the maxed-out (500-resource) fhirBaseApi into a dedicated nested stack. // NOT apiUrl. Mirrors ProofingService.tsx's shape (hook + context provider + useXContext guard). const API_URL = environment.orgAuthorizationApi; // DocumentReference itself is NOT registered on the FhirBase gateway - it lives on Foundation // (matches DocumentReferenceService.tsx's `API_URL = environment.fhirFoundationApi`). Only the // final step of uploadSignedForm (posting the DocumentReference resource) uses this base; the // org-authorization routes above (including the consent-form-upload presign) stay on orgAuthorizationApi. const DOCUMENT_REFERENCE_API_URL = environment.fhirFoundationApi; // - `withdrawn` is a FOURTH state, and conflating it with the other three is the defect it // was added to fix. It is NOT `none` (the patient granted, then took it back - a decision, not an absence) // and it is NOT `proposed` (which every surface renders as "has not agreed YET", so a withdrawn patient // got re-prompted seconds after withdrawing, and the front desk was invited to capture an in-office // attestation that would RE-OPEN the record they had just closed). // // - `declined` is a FIFTH state, and it is the one this list used to swallow. A patient who was // ASKED AND SAID NO read back as `proposed`, so every surface treated their answer as silence: the portal // re-asked on the next sign-in as though nothing had happened, and the practice could not show they had been // asked at all. // IT IS NOT A PROMPT. Spencer ruled the reason for the state is AUDITING, not workflow - a decline is an // ANSWER, and nothing built on it may chase it. A surface rendering `declined` says *"you declined - you can // change this"*, never *"authorization needed"*. // And it is NOT `withdrawn` either: a withdrawal closes a window that really opened, a decline never // opened one. The practice keeps what it saw before a withdrawal; after a decline there is nothing to keep. export type OrgAuthorizationStatusValue = 'none' | 'proposed' | 'active' | 'withdrawn' | 'declined'; export type PracticeDecisionConsequences = { appointmentsStillStanding?: number; appointmentSearchFailed?: boolean; tasksUnsettled?: boolean; teamMembershipsNotRemoved?: number; teamDiscoveryIncomplete?: boolean; teamLineageUnresolved?: boolean; teamBindingUnresolved?: boolean; teamRemovalUnconfirmed?: boolean; referralConsentsNotDeactivated?: number; referralConsentCleanupUnconfirmed?: boolean; teamEvidencePending?: boolean; decisionTimeUnproved?: boolean; followUpUnconfirmed?: boolean; }; export type OrgAuthorizationDeclineResponse = { declined: boolean; /** ISO-8601. Present when declined is true - including on a repeat call, where it is the FIRST answer. */ declinedAt?: string; /** 'currently-authorized' - the practice HAS access, so this is a withdrawal, not a decline. */ reason?: string; /** True when this call found the answer already standing. The instant did not move. */ alreadyDeclined?: boolean; message?: string; /** How many appointments with the practice the decline cancelled. */ appointmentsCancelled?: number; /** Present only when some end-of-care step did not finish; what it names is for the practice, not the patient. */ consequencesIncomplete?: PracticeDecisionConsequences; teamMembershipsRemoved?: number; consequenceRetrying?: boolean; }; export type OrgAuthorizationWithdrawResponse = { withdrawn: boolean; /** ISO-8601. Present when withdrawn is true - including on a repeat call, where it is the FIRST instant. */ withdrawnAt?: string; /** 'no-authorization' | 'never-granted' - why there was nothing to withdraw. Never an error state. */ reason?: string; /** True when this call found it already withdrawn. The boundary did not move. */ alreadyWithdrawn?: boolean; /** How many appointments this call cancelled - a count, never which ones. */ appointmentsCancelled?: number; /** Present when the withdrawal stands but its cleanup did not finish. */ consequencesIncomplete?: PracticeDecisionConsequences; teamMembershipsRemoved?: number; consequenceRetrying?: boolean; message?: string; }; // Whether the viewer may answer: the same decision the answer routes enforce. Sent to a patient or helper viewer, never // to staff; unavailable is neither a refusal nor an agreement. export type AgreementAnswerEligibility = { status: 'eligible' } | { status: 'refused' | 'unavailable'; code?: string }; export type OrgAuthorizationStatusResponse = { status: OrgAuthorizationStatusValue; answerEligibility?: AgreementAnswerEligibility; /** * When the agreement reached the state above. Optional because a patient with no record at all * has no date, and a `proposed` one carries the date the question was RAISED rather than an * answer - the status is what says nobody has answered. */ answeredAt?: string; /** * WHICH VERSION OF THE WORDING THE ANSWER WAS GIVEN AGAINST, read by the server off the uri the * Consent pinned at the moment of agreement. * * It is the half a surface showing a PAST answer cannot do without: `getAgreement` resolves the * wording in force NOW unless it is asked for a version, so the current text beside an older * signature makes every part of the evidence individually sound and the record as a whole false. * * A NEGATIVE NUMBER IS THE PLATFORM'S OWN VERSION and not an error - a practice that never edited * its wording agreed to Akashic's copy, which is versioned separately so that editing it cannot * silently restate what somebody agreed to. * * Optional, and an absence is an absence: the server returns nothing rather than substituting the * version in force now, so a row that does not have this must say it does not know. */ agreedVersion?: number; }; /* * ALL THREE AT ONCE, PLUS THE TWO QUESTIONS. * * `mayTreat` and `mayAccessRecord` are computed by the server rather than derived here, and that is * deliberate: treatment needs all three answered, record access needs only the HIPAA Authorization, and * a screen left to work that out from three statuses will reason from whichever of the three its author * had in mind. That is exactly the conflation this card exists to undo - do not re-derive them. */ export type OrgConsentStatusesResponse = { answerEligibility?: AgreementAnswerEligibility; instruments: Record; /** Every instrument answered. False while any one is outstanding or refused. */ mayTreat: boolean; /** The HIPAA Authorization alone. A patient may refuse care and still leave this standing. */ mayAccessRecord: boolean; }; export type OrgAuthorizationAgreement = { version: number; uri: string; text: string; /** Present on the per-instrument routes. Absent on the legacy welded one. */ instrument?: ConsentInstrumentId; /** The document's final name, carried WITH the text so no screen labels it from its own vocabulary. */ name?: string; }; export type OrgAuthorizationCaptureMethod = 'portal-pending' | 'self-service'; export type AgreeParams = { captureMethod: OrgAuthorizationCaptureMethod; agreedVersion: number; /** Which of the three is being answered. Omitted only by the legacy welded path. */ instrument?: ConsentInstrumentId; }; export type AttestParams = { patientId: string; grantorRelatedPersonId?: string; documentReferenceId?: string; agreedVersion: number; /** * Which document this attestation answers. One scanned packet may be cited by all three - the desk * scans the new-patient forms once and ticks what they satisfy - so this is called once per * instrument with the SAME documentReferenceId. The per-document audit trail survives because the * marking is per document even when the file is not. */ instrument?: ConsentInstrumentId; // Repeated on a retry of the same attestation; a new attestation carries a new one. attestationId?: string; }; // An answer that returned is what every record-standing reader reads again. export const useOrgAuthorizationService = () => { const { get, post } = useHttpService(); // GET {orgAuthorizationApi}/org-authorization[?patient={id}] -> {status}. Self-scoped when no // patientId is passed (the server derives the caller's own patient from auth context); a // patientId is passed by a related person/staff acting on behalf of a specific patient. // - `organizationId` overrides the X-Organization-ID header for this ONE request. The patient's // record is org-agnostic and My Providers lists every practice involved in their care, so a patient // standing in one practice's portal must still be able to see and control another practice's access. // Omitted, it keeps today's behaviour exactly: the WordPress install's own organization. // - naming an `instrument` asks about ONE of the three. Without it the server answers for all // three at once, which is what getConsentStatuses below reads - so a caller that wants one status // must say which, and a caller that wants the whole picture must use the other function. There is no // longer a single blended answer to fall back on. const getStatus = useCallback(async ( patientId?: string, organizationId?: string, instrument?: ConsentInstrumentId, transport?: SaveTransport, ): Promise => { const params = new URLSearchParams(); if (patientId) params.set('patient', patientId); if (instrument) params.set('instrument', instrument); const query = params.toString() ? `?${params.toString()}` : ''; const read = await get(`${API_URL}/org-authorization${query}`, { ...(organizationId ? { organizationId } : {}), ...transport }); // A read its owner has since retired is not used, even once its body has arrived. transport?.admit(); return read; }, [get]); // GET {orgAuthorizationApi}/org-authorization[?patient={id}] -> {instruments, mayTreat, // mayAccessRecord}. ONE round trip for the whole consent picture, which is what a check-in screen and // a booking flow both need - three separate status calls would let a screen render two answers from // one moment and the third from another. const getConsentStatuses = useCallback(async ( patientId?: string, organizationId?: string, transport?: SaveTransport, ): Promise => { const query = patientId ? `?patient=${encodeURIComponent(patientId)}` : ''; const read = await get(`${API_URL}/org-authorization${query}`, { ...(organizationId ? { organizationId } : {}), ...transport }); transport?.admit(); return read; }, [get]); // GET {orgAuthorizationApi}/org-authorization/agreement -> {version, uri, text}. The current // organization's authorization-to-treat agreement, versioned so a stale client-held // agreedVersion can be detected server-side (see attest's 409 'stale' below). //: `organizationId` targets a specific practice, exactly as on getStatus/withdraw above - the // agreement text is per-organization, so restoring access to an away practice must read THAT practice's // agreement rather than the one belonging to the portal the patient happens to be standing in. /** * THIS RESOLVES THE ORG'S CURRENT AGREEMENT. IT IS THE WRONG CALL FOR AN AUDIT, AND THE WRONG * ANSWER LOOKS RIGHT. * * `instrument` selects WHICH DOCUMENT, never WHICH VERSION. There is no version argument here, so * every caller gets TODAY'S wording of whichever instrument it asked for. That is correct while * somebody is being ASKED to agree - they must see what they are agreeing to now. It is wrong * anywhere that shows what a patient ALREADY agreed to. * * Render this beside a March signature and every part of the evidence is individually sound - the * version is recorded, the signature is valid, the timestamp is correct, the text is plausible - and * the record as a whole is FALSE. That is worse than showing nothing, because nothing is visibly * missing and this is not. * * THE SERVER CAN ALREADY ANSWER THE RIGHT QUESTION. The agreement route takes `?version=`, * proven against a superseded version and failing loudly when it cannot resolve. **Nothing in this * client asks it.** An audit surface must fetch the version the Consent PINNED, and must render an * explicit failure rather than falling back here - the server deliberately throws instead of * substituting, and a client that catches and calls this would undo that. * * `v0` is the platform default and a legitimate, resolvable answer for a consent given before the * practice ever edited its wording. * * `AgreedTextResolution` in `components/OrgAgreements/orgAgreements.ts` is the shape that keeps * this honest: `resolved` carries the text, `unavailable` carries only the version, and there is no * member that lets a caller quietly show current wording instead. */ const getAgreement = useCallback(async ( organizationId?: string, instrument?: ConsentInstrumentId, transport?: SaveTransport, ): Promise => { const query = instrument ? `?instrument=${encodeURIComponent(instrument)}` : ''; return get(`${API_URL}/org-authorization/agreement${query}`, { ...(organizationId ? { organizationId } : {}), ...transport }); }, [get]); // GET {orgAuthorizationApi}/org-authorization/agreement -> {agreements: [...]}. All three texts in // one call, resolved server-side in parallel - a patient should not wait on three round trips to read // the documents they are being asked about, and an admin editor showing three boxes needs all three // before it can render any of them. const getAgreements = useCallback(async (organizationId?: string): Promise => { const { agreements } = await get<{ agreements: OrgAuthorizationAgreement[] }>( `${API_URL}/org-authorization/agreement`, organizationId ? { organizationId } : {}, ); return agreements; }, [get]); // POST {orgAuthorizationApi}/org-authorization/agree {captureMethod, agreedVersion} -> {status}. The // caller agreeing for themselves (patient self-service) or on behalf of a pending in-office // capture (portal-pending). agreedVersion is required by the server - always the version just // read from getAgreement. const agree = useCallback(async (params: AgreeParams, organizationId?: string, transport?: SaveTransport): Promise => { const answer = await post( `${API_URL}/org-authorization/agree`, params, { ...(organizationId ? { organizationId } : {}), ...transport }, ); noteRecordAuthorizationAnswer(); return answer; }, [post]); // POST {orgAuthorizationApi}/org-authorization/withdraw -> {withdrawn, withdrawnAt?,...}. - the // patient (or a full-proxy related person acting for them) takes the authorization back. // // THERE IS NO REQUEST BODY, AND THAT IS THE POINT. The server reads the patient from the caller's own // auth context and the organization from the header - it does not parse a body at all, so there is nothing // a client could name to withdraw on someone else's behalf. `organizationId` here sets the HEADER for this // one request (see getStatus above), never a field the server trusts. // // Withdrawal is FORWARD-ONLY: the practice keeps everything it could see up to the instant returned as // `withdrawnAt` and gets nothing after it. Any copy shown to a patient must say so - a bare "access // revoked" over-promises, and this card exists because the product's patient-facing words already did. // // IT NOW CARRIES ONE FIELD, AND ONLY ONE: which document is being taken back. That is not a patient a // client could name - the server still reads the patient from auth context - it is which of three // grants this withdrawal ends. Taking back the HIPAA Authorization closes the record; taking back the // Consent to Treat stops the care and leaves the record grant standing. const withdraw = useCallback(async ( organizationId?: string, instrument?: ConsentInstrumentId, transport?: SaveTransport, ): Promise => { const answer = await post( `${API_URL}/org-authorization/withdraw`, instrument ? { instrument } : {}, { ...(organizationId ? { organizationId } : {}), ...transport }, ); noteRecordAuthorizationAnswer(); return answer; }, [post]); // POST {orgAuthorizationApi}/org-authorization/decline -> {declined, declinedAt?,...}. - the // patient was asked, and said no, DURABLY. Before this the answer went nowhere: the server had no // declined state, so a patient who declined was indistinguishable from one who had never been asked. // // NO REQUEST BODY, for the same reason /withdraw has none - the server reads the patient from the // caller's own auth context and the organization from the header, so there is nothing a client could name // to decline on someone else's behalf. // // IT RECORDS, IT DOES NOT ENFORCE. The self-booking step already refuses the booking on a decline // and does not wait on this call to do it - this makes the ANSWER durable so the practice can // show what was answered, and so the portal stops re-asking as though it never had been. // Nothing built on this may chase the answer: no task, no reminder, no nag. Spencer's fence. // // A 409 is not a failure to handle as an error - it means the practice CURRENTLY HAS ACCESS, so what // the patient wants is `withdraw`, which records the instant that access ended. Declining over a live // grant would leave every read still serving everything while the record claimed they never agreed. // // IT NOW CARRIES ONE FIELD, AND ONLY ONE: which document is being refused. That is not a patient a // client could name - it is which of three questions this answer belongs to - and the server still // reads the patient from auth context. A decline answers ONE document and leaves the other two // standing, so sending nothing would refuse an ambiguous thing. // The HIPAA Privacy is refused with a 400 by the server, because you cannot decline having been told // something. No surface should offer it in the first place - see declinableInstruments(). const decline = useCallback(async ( organizationId?: string, instrument?: ConsentInstrumentId, // The action this decline belongs to: admitted after the token and proof waits and cancelled with it, so a // decline prepared for one patient or person is never sent under another. transport?: SaveTransport, ): Promise => { const answer = await post( `${API_URL}/org-authorization/decline`, instrument ? { instrument } : {}, { ...(organizationId ? { organizationId } : {}), ...(transport ? { admit: transport.admit, signal: transport.signal } : {}), }, ); noteRecordAuthorizationAnswer(); return answer; }, [post]); // POST {orgAuthorizationApi}/org-authorization/attest {patientId, grantorRelatedPersonId?, // documentReferenceId?, agreedVersion} -> {status}. A related person attesting authorization on // a patient's behalf, optionally pointing at an uploaded signed form (documentReferenceId, see // uploadSignedForm below). May 409 with {status:'stale'} when agreedVersion no longer matches // the server's current agreement version - left to throw (via HttpService's error handling) so // the caller can re-fetch getAgreement() and retry, rather than being swallowed here. const attest = useCallback(async (params: AttestParams): Promise => { const answer = await post(`${API_URL}/org-authorization/attest`, params); noteRecordAuthorizationAnswer(); return answer; }, [post]); // Three-step signed-form upload, mirrors PhotoUploadService's presign -> direct-to-S3 PUT // shape: (1) presign via consent-form-upload -> {uploadUrl, key}; (2) PUT the raw file straight // to S3 (bypasses useHttpService - no auth header, this is a pre-signed URL); (3) POST a // DocumentReference pointing at the uploaded key, and return its id so the caller can pass it // as attest()'s documentReferenceId. const uploadSignedForm = useCallback(async (patientId: string, file: File): Promise => { const { uploadUrl, key } = await post<{ uploadUrl: string; key: string }>( `${API_URL}/org-authorization/consent-form-upload`, { patientId, fileName: file.name, contentType: file.type }, ); const putResponse = await fetch(uploadUrl, { method: 'PUT', body: file, headers: { 'Content-Type': file.type }, }); if (!putResponse.ok) { throw new Error(`Consent form upload failed: ${putResponse.status} ${putResponse.statusText}`); } const documentReference = await post<{ id: string }>(`${DOCUMENT_REFERENCE_API_URL}/DocumentReference`, { resourceType: 'DocumentReference', status: 'current', subject: { reference: `Patient/${patientId}` }, content: [{ attachment: { contentType: file.type, url: key, title: file.name, }, }], }); return documentReference.id; }, [post]); return { getStatus, getConsentStatuses, getAgreement, getAgreements, agree, withdraw, decline, attest, uploadSignedForm }; }; const OrgAuthorizationServiceContext = createContext | undefined>(undefined); export const OrgAuthorizationServiceProvider = ({ children }: { children: ReactNode }) => { const orgAuthorizationService = useOrgAuthorizationService(); return ( {children} ); }; /** The service where one is provided, and nothing outside the app's providers (an isolated mount). */ export const useOptionalOrgAuthorizationServiceContext = () => useContext(OrgAuthorizationServiceContext); export const useOrgAuthorizationServiceContext = () => { const context = useContext(OrgAuthorizationServiceContext); if (context === undefined) { throw new Error('useOrgAuthorizationServiceContext must be used within an OrgAuthorizationServiceProvider'); } return context; };