import type { SaveTransport } from '../utils/saveOperation'; import { useCallback } from 'react'; import { environment } from '../environments/environment.ts'; import { useHttpService, HttpError } from './HttpService.tsx'; import type { GuardianProducerAnswer } from './guardianProducerRequest'; import { activationAnswerOf, type ActivationAnswer } from './activationContinuation'; // - the CLIENT half of the minor-child flow. // // The server has been deployed and complete to spec since 2026-07-31, but nothing in the plugin called // it: three live endpoints with no way in, so the whole minor-child lifecycle was unreachable from the // UI. This is the "server-first with MINIMAL client" the design called for - transport only, no // orchestration, so the surfaces above it stay free to change. // // BOTH ROUTES LIVE ON THE GUARDIANSHIP GATEWAY (`templates/fhir/05-guardianship.yaml`), NOT on // `fhirBaseApi`. Using the wrong base silently 404s instead of failing loudly - see //, a landmine that has bitten this codebase more than once. The // tests assert the full URL for exactly that reason. // // The org header is NOT set here: `useHttpService` adds `X-Organization-ID` to every authed request. // Both endpoints REQUIRE it (each returns 400 "Organization context is required"), and beyond that the // authorizer grants a patient the `Patients` role only when an org is present - without it the call is // denied before reaching the handler, which is how the sign-out beacon failed silently on 2026-07-31. /** 2a - a login-less minor Patient plus an immediately-active parent proxy, created server-atomically. */ /** Whose declaration this is: refused before sending once that opening, viewer or practice has moved on. */ export interface StaffGuardianTransport { admit?: () => void; onSend?: () => void; signal?: AbortSignal; organizationId?: string } export const STAFF_GUARDIAN_DECLARATION_VERSION = 1; export type StaffGuardianRelationship = 'parent' | 'legal-guardian'; export interface StaffGuardianDeclaration { requestId: string; relationship: StaffGuardianRelationship; adult: { email: string; firstname: string; lastname: string; birthDate: string }; basedOnGeneration?: number; } /** recorded: guardian now, on a link the adult already held. pending: guardian once they accept the invitation. */ export type StaffGuardianResult = { status: 'recorded' | 'pending'; relatedPersonId: string; /** Only for an account the request created: whether its invitation email was queued yet. */ invitation?: 'queued' | 'pending'; }; export interface CreateDependentInput { firstname: string; lastname: string; /** * REQUIRED, and not merely by the server's validation. Every age gate downstream keys on it - the * 12-17 adolescent redaction band, the 18+ messaging/collaboration gate, the invoice-author notice. * A child created without a birthDate sits silently outside all of them, so this is rejected here * rather than allowed to fail at the network boundary. */ birthDate: string; /** This request's own id: the same id sent again finishes the same child, never a second one. */ requestId: string; } /** `complete` carries the child and link; anything else is not finished (see GuardianProducerAnswer). */ export type CreateDependentResult = GuardianProducerAnswer; export type ActivateChildResult = ActivationAnswer; export interface ActivationTransport extends StaffGuardianTransport { check?: boolean; claimId?: string } /** - the other adult being invited to co-guard this child. The relationship values are * the three the server accepts; anything else is a 400 there, so the UI offers exactly these. */ export type CoGuardianRelationship = 'parent' | 'step-parent' | 'guardian'; export interface CoGuardianInvitee { email: string; firstname: string; lastname: string; relationship: CoGuardianRelationship; /** This request's own id: the same id sent again finishes the same invitation, never a second one. */ requestId: string; } /** `complete` is a finished invitation: its link waits for the invitee to accept and grants nothing until they do. */ export type CoGuardianInviteResult = GuardianProducerAnswer; export const useDependentService = () => { const { get, post, fetchWithAuth } = useHttpService(); // The form's opening owns the send (`transport`): nothing leaves for a form, viewer or practice that has moved on. const createDependent = useCallback( (input: CreateDependentInput, transport?: SaveTransport): Promise => { const firstname = input.firstname?.trim(); const lastname = input.lastname?.trim(); const birthDate = input.birthDate?.trim(); if (!firstname || !lastname) { return Promise.reject(new Error('A first and last name are required')); } if (!birthDate) { return Promise.reject(new Error("The child's birth date is required")); } if (!input.requestId) { return Promise.reject(new Error('A request id is required')); } return post( `${environment.fhirGuardianshipApi}/Patient/dependent`, { firstname, lastname, birthDate, requestId: input.requestId }, { ...transport }, ); }, [post], ); // How far one of this viewer's own requests got. Read-only: it never finishes or repeats the request. const readDependentRequest = useCallback( (requestId: string, transport?: SaveTransport): Promise => get(`${environment.fhirGuardianshipApi}/Patient/dependent-operation/${encodeURIComponent(requestId)}`, { ...transport }), [get], ); const readCoGuardianRequest = useCallback( (childPatientId: string, requestId: string, transport?: SaveTransport): Promise => get( `${environment.fhirGuardianshipApi}/Patient/${encodeURIComponent(childPatientId)}/co-guardian-operation/${encodeURIComponent(requestId)}`, { ...transport }, ), [get], ); // 2b - bind a NEW login to the child's EXISTING record. Deliberately parent-initiated: a minor // cannot bind terms of service, so the adult with capacity is always the acting party. The child's // record and full history carry over, because the Cognito user is created with the existing // patient_id pre-set rather than minting a fresh Patient. const activateChildLogin = useCallback( async (childPatientId: string, email: string, options: ActivationTransport = {}): Promise => { const childId = childPatientId?.trim(); const trimmedEmail = email?.trim().toLowerCase(); // An empty id would collapse the path to `/Patient//activate` and reach a different route // entirely - a confusing 404 rather than an obvious failure. if (!childId || !/^[A-Za-z0-9.-]{1,64}$/.test(childId)) { return Promise.reject(new Error('A child patient id is required')); } if (!trimmedEmail) { return Promise.reject(new Error("The child's email address is required")); } const { check = false, claimId, ...transport } = options; if (claimId !== undefined && !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(claimId)) throw new Error('The activation reference is invalid'); const response = await fetchWithAuth(`${environment.fhirGuardianshipApi}/Patient/${encodeURIComponent(childId)}/activate`, { ...transport, method: 'POST', body: JSON.stringify({ email: trimmedEmail, ...(check ? { check: true } : {}), ...(claimId ? { claimId } : {}) }), headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, }); transport.admit?.(); let body: unknown; try { body = await response.json(); } catch { body = undefined; } transport.admit?.(); if (!response.ok) throw new HttpError(response.status, 'Activation could not be confirmed', body, response.headers.get('x-amzn-errortype') ?? undefined); return activationAnswerOf(body, response.status, childId, claimId); }, [fetchWithAuth], ); // - invite ANOTHER adult to be a guardian of this child (the third live endpoint, // and the last one the client could not reach). // // The server is the authority on who may do this: it re-checks that the caller is an ACTIVE guardian // of the child (isActiveGuardianOfChild) and 403s otherwise, so this method deliberately adds no // authorization logic of its own - it only avoids sending a request that is obviously malformed. const inviteCoGuardian = useCallback( (childPatientId: string, invitee: CoGuardianInvitee, transport?: StaffGuardianTransport): Promise => { const childId = childPatientId?.trim(); const email = invitee.email?.trim(); const firstname = invitee.firstname?.trim(); const lastname = invitee.lastname?.trim(); // Same reasoning as activateChildLogin above: an empty id collapses the path to // `/Patient//co-guardian` and lands on a different route, producing a confusing 404 rather // than an obvious failure. if (!childId) { return Promise.reject(new Error('A child patient id is required')); } if (!email || !firstname || !lastname) { return Promise.reject(new Error('Their name and email address are required')); } if (!invitee.requestId) { return Promise.reject(new Error('A request id is required')); } return post( `${environment.fhirGuardianshipApi}/Patient/${childId}/co-guardian`, { email, firstname, lastname, relationship: invitee.relationship, requestId: invitee.requestId }, transport ?? {}, ); }, [post], ); // Staff record an adult's in-person declaration that they are this minor's parent or legal guardian. The witness // is the signed-in staff member on the server, never this body. A 409 EXISTING_LINK names the decision the // adult's current link carries, so the staff member confirms against it with the same requestId. const recordStaffGuardian = useCallback( (childPatientId: string, declaration: StaffGuardianDeclaration, transport?: StaffGuardianTransport): Promise => { const childId = childPatientId?.trim(); if (!childId) return Promise.reject(new Error('A child patient id is required')); return post(`${environment.fhirGuardianshipApi}/Patient/${childId}/guardian`, { requestId: declaration.requestId, declaration: { version: STAFF_GUARDIAN_DECLARATION_VERSION, declared: true, relationship: declaration.relationship }, adult: declaration.adult, ...(declaration.basedOnGeneration !== undefined ? { basedOnGeneration: declaration.basedOnGeneration } : {}), }, transport ?? {}); }, [post], ); // - prove a dependent's own mobile, so the teen is reachable at 18. // // The server raises `subjectNeedsTakeoverContact` on the proxy row from 16 until 18 while a channel is // missing; these two calls are what resolves it. Verification exists because a parent could otherwise // enter their OWN number: that satisfies the flag while leaving the teen just as unreachable, and an // unverified contact is worse than none because it turns a visible gap into an invisible one. // // `fhirBaseApi`, NOT `fhirGuardianshipApi`. The three methods above are on the guardianship gateway; // these routes live in templates/fhir/02-fhir-base.yaml, so they are on the BASE gateway. The wrong // base silently 404s rather than failing loudly - which is why the tests assert the FULL url. // // No authorization logic here: the server re-checks the proxy grant on every call (an expired // guardianship at 18 refuses), so duplicating it would only create a second place to drift. const requestDependentSmsCode = useCallback( // `transport`: the prompt this belongs to; nothing is sent for a prompt that is gone. See SaveTransport. (childPatientId: string, transport?: SaveTransport): Promise<{ message?: string; pendingVerificationId?: string }> => { const patientId = childPatientId?.trim(); if (!patientId) { return Promise.reject(new Error('A child patient id is required')); } return post(`${environment.fhirIdentityApi}/guardian-sms-verification/request`, { patientId }, { ...transport }); }, [post], ); const verifyDependentSmsCode = useCallback( (childPatientId: string, pendingVerificationId: string, code: string, transport?: SaveTransport): Promise<{ verified?: boolean }> => { const patientId = childPatientId?.trim(); const pending = pendingVerificationId?.trim(); const otp = code?.trim(); if (!patientId || !pending) { return Promise.reject(new Error('A child patient id and a pending verification are required')); } if (!otp) { return Promise.reject(new Error('Enter the code that was texted')); } return post(`${environment.fhirIdentityApi}/guardian-sms-verification/verify`, { patientId, pendingVerificationId: pending, code: otp, }, { ...transport }); }, [post], ); // - prove a dependent's own contact EMAIL, the other half of decision 9's "an email and // mobile phone contact". `subjectNeedsTakeoverContact` requires BOTH, so until this door existed the // prompt above could not be satisfied at all: the flag was right and the means was missing. // // EMAIL IS THE HALF THAT MATTERS MOST HERE, per own design: a text can nudge, but the // SELF-CLAIM LINK at 18 lands in an inbox. That is also why the server runs its distinctness guard // on this door - a parent who verifies their OWN address receives the teen's notices and the claim // link, converting a bounded, audited guardianship into unaudited control of the teen's login. // // SAME GATEWAY AS THE SMS PAIR ABOVE - `fhirBaseApi`, not `fhirGuardianshipApi`. These routes are // declared in templates/fhir/02-fhir-base.yaml; the wrong base silently 404s rather than failing // loudly, which is why the tests assert the FULL url. // // No authorization logic here: the server re-checks the proxy grant on every call via // `callerMayAccessPatient` (the same predicate the read boundary uses, so an expired guardianship at // 18 refuses), and duplicating it would only create a second place to drift. const requestDependentEmailCode = useCallback( (childPatientId: string, transport?: SaveTransport): Promise<{ pendingVerificationId?: string; maskedAddress?: string }> => { const patientId = childPatientId?.trim(); if (!patientId) { return Promise.reject(new Error('A child patient id is required')); } return post(`${environment.fhirIdentityApi}/guardian-email-verification/request`, { patientId }, { ...transport }); }, [post], ); const verifyDependentEmailCode = useCallback( (childPatientId: string, pendingVerificationId: string, code: string, transport?: SaveTransport): Promise<{ verified?: boolean; verifiedAt?: string }> => { const patientId = childPatientId?.trim(); const pending = pendingVerificationId?.trim(); const otp = code?.trim(); if (!patientId || !pending) { return Promise.reject(new Error('A child patient id and a pending verification are required')); } if (!otp) { return Promise.reject(new Error('Enter the code that was emailed')); } // The code is a live credential: body only, never the URL. return post(`${environment.fhirIdentityApi}/guardian-email-verification/verify`, { patientId, pendingVerificationId: pending, code: otp, }, { ...transport }); }, [post], ); return { createDependent, activateChildLogin, inviteCoGuardian, recordStaffGuardian, readDependentRequest, readCoGuardianRequest, requestDependentSmsCode, verifyDependentSmsCode, requestDependentEmailCode, verifyDependentEmailCode, }; };