import { useCallback } from 'react'; import { useHttpService } from './HttpService.tsx'; import { environment } from '../environments/environment.ts'; import type { AgreementBlock } from '../components/FirstSignInGate/agreementCopy.ts'; import type { SaveTransport } from '../utils/saveOperation.ts'; import type { MsaPreview, MsaSummary } from '../utils/msaAgreement.ts'; import { isMsaPreview, isMsaRequest, msaObject, msaPreviewMatches } from '../utils/msaRecovery'; import { classifyTransferResponse, type BaaUploadReference, type PendingDraftRef, type TransferResult, } from '../utils/baaUploadAttempt.ts'; /** * The organization BAA Akashic has published and holds in force. Once there is one, it is the text the * practice reads and the version it signs; the server refuses any other (409 missing-version / * stale-version). Null before the first publication, when the S3 template is still the agreement. */ export interface PublishedBaaDocument { version: string; title: string; blocks: AgreementBlock[]; } export interface BaaRecord { // follow-on (Spencer 2026-08-02): a BAA is never REVOKED - "once a BAA is signed it's // signed". A later executed agreement SUPERSEDES it and the old one is retired. `revoked` is removed // rather than left unused: a dead option on a compliance record gets read as permission. // `status` is the agreement currently IN FORCE; `pending` is a custom BAA awaiting our // counter-signature, which deliberately does NOT displace it. status: 'pending' | 'active' | 'superseded' | null; pending?: { type: string; signedAt: string; signedBy: { name: string; title: string; orgName: string }; documentKey: string; /** The upload attempt that admitted this draft; absent on drafts from before attempts were recorded. */ uploadAttemptId?: string; /** * A link to the document the practice actually sent. * * Absent when the presign failed -- the submission is still real and still reported, because a * link that could not be minted must not hide the fact that something is awaiting * counter-signature. */ documentUrl?: string; }; type?: 'default' | 'custom'; signedAt?: string; signedBy?: { name: string; title: string; orgName: string; }; documentKey?: string; pdfTemplateUrl?: string; htmlTemplateUrl?: string; downloadUrl?: string; activatedAt?: string; /** The published agreement in force, or null before the first publication. */ document?: PublishedBaaDocument | null; /** The version the agreement in force was signed at; absent for one signed on the template. */ documentVersion?: string; /** The admission count a new upload names; null when it could not be read, so no upload is started. */ pendingGeneration?: number | null; /** Present when the awaiting-review read failed: unknown, not "nothing awaiting review". */ pendingRead?: 'failed'; msa?: MsaSummary; /** This practice's own signature, as its receipt recorded it, kept beside what was observed now. */ ownReceipt?: { status: 'active'; signedAt: string; documentKey: string }; /** The receipt is known but the agreement now in force could not be read: it is not shown as in force. */ currentUnconfirmed?: true; } export interface AcceptMsaRequest { operationId: string; signerName: string; signerTitle: string; documentVersion: string; editionHash: string; signDate: string; factsDigest: string; signingAttestation: BaaSigningAttestation; } export interface MsaAcceptance { agreement: 'msa'; state: 'accepted'; replay: boolean; operationId: string; version: string; editionHash: string; acceptedAt: string; documentKey: string; } export interface MsaOperationRead { state: 'accepted' | 'admitted' | 'conflict' | 'absent'; operationId: string; version?: string; acceptedAt?: string; documentKey?: string; actor?: { userId: string; practitionerId: string }; actorMayResume: boolean; actorIsOriginal: boolean; frozen?: { request: AcceptMsaRequest; preview: MsaPreview | null }; } const msaDate = (value: unknown): value is string => typeof value === 'string' && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value; export const isMsaAcceptance = (value: unknown, request: AcceptMsaRequest, organizationId: string): value is MsaAcceptance => msaObject(value) && value.agreement === 'msa' && value.state === 'accepted' && typeof value.replay === 'boolean' && value.operationId === request.operationId && value.version === request.documentVersion && value.editionHash === request.editionHash && msaDate(value.acceptedAt) && value.documentKey === `baa/msa/${organizationId}/${request.operationId}.pdf`; export const isMsaOperationRead = (value: unknown, operationId: string, organizationId: string): value is MsaOperationRead => { if (!msaObject(value) || value.operationId !== operationId || typeof value.actorMayResume !== 'boolean' || typeof value.actorIsOriginal !== 'boolean') return false; if (value.state === 'absent') return value.actorMayResume === false && value.actorIsOriginal === false; if (!['admitted', 'conflict', 'accepted'].includes(String(value.state)) || typeof value.version !== 'string' || !value.version || !msaDate(value.acceptedAt) || !msaObject(value.actor) || typeof value.actor.userId !== 'string' || !value.actor.userId || typeof value.actor.practitionerId !== 'string' || !value.actor.practitionerId) return false; if (value.state === 'accepted' && value.documentKey !== `baa/msa/${organizationId}/${operationId}.pdf`) return false; if (!value.actorMayResume) return true; if (!value.actorIsOriginal || value.state !== 'admitted' || !msaObject(value.frozen) || !isMsaRequest(value.frozen.request) || value.frozen.request.operationId !== operationId || value.frozen.request.documentVersion !== value.version) return false; return value.frozen.preview === null || (isMsaPreview(value.frozen.preview) && msaPreviewMatches(value.frozen.preview, value.frozen.request)); }; /** What the upload route answers about an attempt. A URL comes only while the attempt owns its pending draft. */ export interface BaaUploadAnswer { outcome: 'admitted' | 'withdrawn' | 'replaced' | 'executed' | 'retired' | 'not-admitted'; documentKey?: string; signedAt?: string; uploadUrl?: string; replay?: boolean; notice?: 'unconfirmed'; } /** * - THE SIGNING-AUTHORITY ATTESTATION, and it is a SECOND claim, not the owner one repeated. * * - at owner designation: *"I am a direct representative of this organization"* - that is MEMBERSHIP. * - here, at signature: *"I am authorized by this covered entity to bind it"* - that is the claim with * legal weight on a BAA, and a person can honestly be staff without holding it. * * The version is sent, never the text. The server owns the wording and stores its own copy on the * executed agreement - a client that sent the text could claim the signer agreed to something else. * And WHO attested is never sent: the server takes it from the token. */ export interface BaaSigningAttestation { statementVersion: string; } /** The wording the signer must be shown. Mirrors the server constant; a signature is refused if the * version does not match, which is what stops a stale tab attesting to superseded text. */ export const BAA_SIGNING_ATTESTATION = { version: 'v1', // WORD FOR WORD WHAT THE SERVER STORES (`utils/baaSigningAuthority.ts`). This is the sentence the // sign modal renders, and the server writes its own copy onto the executed agreement - so the evidence // is exactly what the signer read. Changing either side alone makes the record misrepresent what was // shown, which is worse than having no record because it looks like one. text: 'I am authorized to sign this agreement on behalf of the organization, and I understand ' + 'this constitutes a legally binding electronic signature.', } as const; export interface SignBaaRequest { signerName: string; signerTitle: string; orgName: string; orgAddress: string; orgPhone: string; signingAttestation?: BaaSigningAttestation; /** The published version the signer was shown; omitted before the first publication. */ documentVersion?: string; } /** * Uploading a custom agreement introduces it exactly as signing the default one does, so the same * claim is required -- and required here rather than optional, unlike the inherited field. The * server refuses an upload without it, and inheriting it as optional is what let this path compile * while sending nothing. */ export interface UploadBaaRequest extends SignBaaRequest { fileName: string; signingAttestation: BaaSigningAttestation; fileSha256: string; } const BAA_BASE = `${environment.fhirBaseApi}/Organization/baa`; /** * One retired agreement, as the practice's own history route returns it. * * NO ORGANISATION IS SENT OR RETURNED. The route takes the organisation from the authorizer, so a * practice can only ever read its own -- which is why this shape carries no org id to pass around. */ export interface BaaHistoryEntry { status: 'active' | 'superseded' | 'pending'; type?: 'default' | 'custom'; signedAt: string; signedBy?: { name?: string; title?: string; orgName?: string; orgAddress?: string }; supersededAt?: string; supersededBySignedAt?: string; counterSignedAt?: string; counterSignedBy?: { name?: string; title?: string }; /** Present only on a row rebuilt after the fact, never on one written by a signature. */ reconstruction?: { reconstructedAt: string; reconstructedBy: string; basis: string }; /** Absent when the document could not be signed for; the agreement is still part of the record. */ documentUrl?: string; } export const useBAAService = () => { const { get, post } = useHttpService(); /** * Every agreement this practice has executed and retired. * * THE PROMISE THIS SERVES is the one the replace screen makes in its own words: a replaced * agreement "remains readable, with the date it was replaced". Until this route existed there was * nowhere it remained readable to the practice -- only Akashic staff could see it, through the * internal console. */ const getBaaHistory = useCallback(async (): Promise => { const response = await get<{ history?: BaaHistoryEntry[] }>(`${BAA_BASE}/history`); return response?.history ?? []; }, [get]); /** * Take back a custom agreement Akashic has not counter-signed. * * The server refuses two ways and they mean different things: `nothing-pending` (there was nothing * to take back) and `already-counter-signed` (it was executed before the withdrawal landed, and is * now the agreement in force). Callers must not collapse them -- a practice told the first would * conclude the withdrawal worked. */ const withdrawPendingBaa = useCallback(async (draft: PendingDraftRef, transport?: SaveTransport): Promise => { await post(`${BAA_BASE}/withdraw`, { draft }, transport ? { admit: transport.admit, signal: transport.signal } : {}); }, [post]); /** Fences an attempt whose admission is unknown so it can never land; refused if it already has standing. */ const retireUploadAttempt = useCallback(async (reference: BaaUploadReference, transport?: SaveTransport): Promise => { const { attemptId, inputsDigest, admissionGeneration, expectedDraft } = reference; await post(`${BAA_BASE}/withdraw`, { retireAttempt: { attemptId, inputsDigest, admissionGeneration, expectedDraft } }, transport ? { admit: transport.admit, signal: transport.signal } : {}); }, [post]); const getBaa = useCallback(async (transport?: SaveTransport & { organizationId?: string }): Promise => { return get(BAA_BASE, transport ? { admit: transport.admit, signal: transport.signal, organizationId: transport.organizationId } : {}); }, [get]); /** * Sent for the practice the signature was started for (`organizationId`), and admitted again after the token * and proof waits, so a practice switch while it waits retires it rather than signing for the new practice. */ const signBaa = useCallback(async ( body: SignBaaRequest, { transport, organizationId }: { transport?: SaveTransport; organizationId?: string } = {}, ): Promise<{ status: 'active'; signedAt: string; documentKey: string }> => { return post<{ status: 'active'; signedAt: string; documentKey: string }>( `${BAA_BASE}/sign`, body, { ...(transport ? { admit: transport.admit, signal: transport.signal, onSend: transport.onSend } : {}), ...(organizationId ? { organizationId } : {}), }, ); }, [post]); /** An attempt's first send, carrying its original reference; the server binds the inputs to it. */ // The MSA rides the BAA sign route under its own discriminator; it never signs, uploads or changes a BAA. const acceptMsa = useCallback(async (body: AcceptMsaRequest, transport: SaveTransport, organizationId: string): Promise => { const answer = await post(`${BAA_BASE}/sign`, { agreement: 'msa', ...body }, { admit: transport.admit, signal: transport.signal, organizationId }); if (!isMsaAcceptance(answer, body, organizationId)) throw new Error('The acceptance answer could not be confirmed.'); return answer; }, [post]); const getMsaOperation = useCallback(async (operationId: string, transport: SaveTransport, organizationId: string): Promise => { const answer = await get(`${BAA_BASE}?msaOperation=${encodeURIComponent(operationId)}`, { admit: transport.admit, signal: transport.signal, organizationId }); if (!msaObject(answer) || answer.agreement !== 'msa' || !isMsaOperationRead(answer.operation, operationId, organizationId)) { throw new Error('The acceptance could not be read.'); } return answer.operation; }, [get]); const requestUpload = useCallback(async ( body: UploadBaaRequest, reference: BaaUploadReference, transport?: SaveTransport, ): Promise => { const { attemptId, inputsDigest, admissionGeneration, expectedDraft } = reference; return post(`${BAA_BASE}/upload`, { ...body, mode: 'admit', attemptId, inputsDigest, admissionGeneration, expectedDraft }, transport ? { admit: transport.admit, signal: transport.signal } : {}); }, [post]); /** What became of an attempt. Read-only: the same original reference, never today's status. */ const recoverUpload = useCallback(async (reference: BaaUploadReference, transport?: SaveTransport): Promise => { const { attemptId, inputsDigest, admissionGeneration, expectedDraft } = reference; return post(`${BAA_BASE}/upload`, { mode: 'recover', attemptId, inputsDigest, admissionGeneration, expectedDraft }, transport ? { admit: transport.admit, signal: transport.signal } : {}); }, [post]); /** * Sends the file and says what is known: stored, refused by storage before anything was kept, or * unknown (no answer, or one that does not prove nothing was stored). */ const uploadToS3 = useCallback(async (uploadUrl: string, file: File, checksum: string, transport?: SaveTransport): Promise => { transport?.admit(); let response: Response; try { response = await fetch(uploadUrl, { method: 'PUT', body: file, headers: { 'Content-Type': 'application/pdf', 'x-amz-checksum-sha256': checksum }, signal: transport?.signal, }); } catch { return 'unknown'; } return classifyTransferResponse(response); }, []); return { getBaa, getBaaHistory, withdrawPendingBaa, retireUploadAttempt, signBaa, acceptMsa, getMsaOperation, requestUpload, recoverUpload, uploadToS3 }; };