import type { SaveTransport } from '../utils/saveOperation'; import { useCallback } from 'react'; import { environment } from '../environments/environment.ts'; import { useHttpService } from './HttpService.tsx'; import type { CommChannel, CommPreference } from '../config/communicationPreferences'; export interface CommPreferencesResponse { preferences: Partial>; } // S2b - the patient's own SMS-number verification, self-scoped via the trusted authorizer // context server-side (no id is sent, same as putPreference below). Both routes live on the SAME // fhir-base gateway as patient-communication-preferences (templates/fhir/02-fhir-base.yaml), not // api.yaml - see reference-api-base-url-routing before pointing these at environment.apiUrl. export interface SmsVerificationRequestResponse { pendingVerificationId: string; // Already masked server-side (e.g. "--------2919") - render as given, never reconstruct the full number. maskedNumber: string; } export interface SmsVerificationVerifyResponse { verified: true; verifiedAt: string; } // S2b, Task 8 - THE IN-OFFICE PATH. Staff, with the patient present, send a code to the // patient's own selected mobile number and record an attestation that the patient agreed. Same // gateway/response shapes as the patient routes above (confirmed against routes.ts and // templates/fhir/02-fhir-base.yaml: 'POST /R4/staff-sms-verification/request' and '.../verify' are // both on the fhir-base stack, not api.yaml). // // Unlike the patient routes, `patientId` is REQUIRED in the body of BOTH calls - the server has no // "caller's own patient" to fall back to here, since the caller is staff acting for someone else. // (handlers/StaffSmsVerification/shared.ts::parseStaffRequest reads `body.patientId` unconditionally // for both routes and 400s without it; the task-8 brief's example omitted it from the verify call, // but the actual handler requires it - traced directly against source, not assumed.) export interface StaffSmsVerificationRequestResponse { pendingVerificationId: string; // Already masked server-side - render as given, never reconstruct the full number. maskedNumber: string; } export interface StaffSmsVerificationVerifyResponse { verified: true; verifiedAt: string; } // - the patient's own CONTACT-EMAIL verification, the email twin of the SMS pair above and, // like it, self-scoped server-side via the trusted authorizer context. Same fhir-base gateway // (templates/fhir/02-fhir-base.yaml: 'POST /R4/patient-email-verification/request' and '.../verify'), // NOT api.yaml - see reference-api-base-url-routing before repointing these at environment.apiUrl. // // THIS IS NOT THE LOGIN EMAIL. `PatientLoginEmail` is a Cognito-bound login-email CHANGE flow that // sends its code to the OLD address on purpose and assumes an existing account. This pair proves // control of a CONTACT address on the Patient resource - a different fact, with its own extension // (`patient-email`) and its own doors. export interface EmailVerificationRequestResponse { pendingVerificationId: string; // Already masked server-side (e.g. "a---@example.com") - render as given, never reconstruct the // full address. The raw address deliberately never leaves the server on this route, which is what // stops the door becoming a way to READ a patient's email address. maskedAddress: string; } export interface EmailVerificationVerifyResponse { verified: true; verifiedAt: string; } // Thin client for the patient communication-preferences sub-route. The patient reads their current // preferences straight off their own Patient resource (currentUser.fhir); this service only writes one channel. // Self-scoped server-side to the caller's own Patient (via the trusted token), so no id is sent. export const useCommunicationPreferenceService = () => { const { put, post } = useHttpService(); const putPreference = useCallback( // `transport`: these routes act for whoever is signed in, so a request prepared in one session must not be // sent, or its answer held, in the next. See SaveTransport. (channel: CommChannel, preference: CommPreference, transport?: SaveTransport): Promise => put( `${environment.fhirBaseApi}/patient-communication-preferences`, { channel, preference }, { ...transport }, ), [put], ); // Issues OR resends a code to the patient's currently selected mobile number. There is deliberately // no separate resend route - calling this again while a verification is already open resends // against it server-side. const requestSmsVerification = useCallback( (transport?: SaveTransport): Promise => post( `${environment.fhirIdentityApi}/patient-sms-verification/request`, {}, { ...transport }, ), [post], ); // Redeems the code. Every failure - wrong code, expired, already burnt, unknown record - collapses // to the SAME 400 by server design (never distinguishable); 429 is a rate cap, 502/503 are provider // or audit-durability failures. See CommunicationPreferenceService's caller for how each is surfaced. const verifySmsCode = useCallback( (pendingVerificationId: string, code: string, transport?: SaveTransport): Promise => post( `${environment.fhirIdentityApi}/patient-sms-verification/verify`, { pendingVerificationId, code }, { ...transport }, ), [post], ); // - issues OR resends a code to the patient's currently selected CONTACT email. As with // the SMS pair there is deliberately no separate resend route: calling this again while a // verification is already open resends against it server-side (and, once the resend allowance is // gone, silently replaces the record and spends a start slot instead - a spent resend cap is // deliberately NOT terminal, because email is slow and lands in spam). // // THE BODY CARRIES NO PATIENT IDENTIFIER, AND MUST NOT. The server derives the subject from the // caller's own authorizer context and its handler never parses one, so "this route acted on the // wrong patient" is structurally impossible rather than merely checked. Do NOT add one for symmetry // with the guardian door in DependentService - that door is a SEPARATE route pair precisely so this // one can keep the property. ( widened the SMS patient door with an optional body // `patientId` and backed it out; PatientEmailVerification/request.ts records the same rule.) const requestEmailVerification = useCallback( (transport?: SaveTransport): Promise => post( `${environment.fhirIdentityApi}/patient-email-verification/request`, {}, { ...transport }, ), [post], ); // Redeems the code. Every code-ladder failure - wrong code, expired, already burnt, unknown record, // attempt cap spent - collapses to the SAME opaque 400 by server design, so the endpoint never // confirms whether a code was ever valid. 409 is the one TERMINAL verify failure (the selected // address changed after the code was sent, so verifyFlow DELETED the record and this // pendingVerificationId is dead); 429 is a rate cap; 502/503 are provider or audit-durability // failures. See PatientSettingsPage for how each is surfaced. // // The code rides in the BODY of a POST, never in the URL: it is a live credential, and a URL // lands in access logs, browser history and every proxy in between. const verifyEmailCode = useCallback( (pendingVerificationId: string, code: string, transport?: SaveTransport): Promise => post( `${environment.fhirIdentityApi}/patient-email-verification/verify`, { pendingVerificationId, code }, { ...transport }, ), [post], ); return { putPreference, requestSmsVerification, verifySmsCode, requestEmailVerification, verifyEmailCode }; }; // S2b, Task 8 - staff-attested SMS verification, separate from the patient's self-scoped // hook above: the caller here is staff acting FOR a named patient, never the patient themselves. export const useStaffSmsVerificationService = () => { const { post } = useHttpService(); // Issues OR resends a code to the named patient's currently selected mobile number, recording the // staff member's attestation that the patient agreed. There is deliberately no separate resend // route -- calling this again while a verification is already open resends against it server-side. const requestStaffSmsVerification = useCallback( (patientId: string, attestationText: string): Promise => post( `${environment.fhirIdentityApi}/staff-sms-verification/request`, { patientId, attestation: { affirmed: true, text: attestationText } }, ), [post], ); // Redeems the code the patient read aloud. Every failure -- wrong code, expired, already burnt, // a record-state refusal -- collapses to the SAME 400 by server design (never distinguishable); // 403 is "not this staff member's patient", 429 is a rate cap, 502/503 are provider or // audit-durability failures. See the caller for how each is surfaced. const verifyStaffSmsCode = useCallback( (patientId: string, pendingVerificationId: string, code: string): Promise => post( `${environment.fhirIdentityApi}/staff-sms-verification/verify`, { patientId, pendingVerificationId, code }, ), [post], ); return { requestStaffSmsVerification, verifyStaffSmsCode }; };