import { useCallback, useRef } from 'react'; import { environment } from '../environments/environment.ts'; import { useHttpService } from './HttpService.tsx'; import { HttpError } from './HttpService'; import { personalOperationId, PersonalConnectionUnconfirmed, usePersonalRecordsService, type PersonalConnection } from './PersonalRecordsService'; import type { RecordsCallScope } from './RecordsService.tsx'; import { completed, startBody, switchedOff, type CompletedConnection, type RecordsConnection, type RecordsIssuer } from './RecordsConnectionsService.tsx'; // Patient adapters select the personal host or the existing practice routes; neither shares server authority. const API_URL = environment.orgAuthorizationApi; const sentUnder = (scope?: RecordsCallScope) => (scope ? { admit: scope.admit, signal: scope.signal, organizationId: scope.organizationId } : {}); export type LinkFacts = { birthDate: string; names: string[] }; export type PracticeLinkRequest = { connectionId: string; operationId: string; evidenceDigest?: string; confirm?: 'self' | 'self-name-reviewed' }; export type PracticeLinkReview = { evidenceDigest: string; outcome: 'match' | 'name-review'; source: LinkFacts; local: LinkFacts }; export type PracticeLinkAnswer = | { kind: 'linked' | 'recognized'; connection?: RecordsConnection } | { kind: 'review'; code: 'link-required' | 'name-review'; review: PracticeLinkReview } | { kind: 'hold'; code: 'identity-conflict' | 'facts-missing' | 'reconnect-required' }; const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); const linkFacts = (value: unknown): LinkFacts | undefined => isRecord(value) && typeof value.birthDate === 'string' && Array.isArray(value.names) && value.names.every(name => typeof name === 'string') ? { birthDate: value.birthDate, names: [...value.names] } : undefined; const LINK_HOLDS = ['identity-conflict', 'facts-missing', 'reconnect-required'] as const; const LINK_REVIEWS = { 'link-required': 'match', 'name-review': 'name-review' } as const; /** The practice's before-link answer to exactly this sent link; anything else is not one and stays the caller's to relay. */ export const decodeLinkStop = (failure: unknown, sent: PracticeLinkRequest): PracticeLinkAnswer | undefined => { if (!(failure instanceof HttpError) || failure.status !== 409 || failure.awsErrorType !== undefined) return undefined; const body = failure.body; if (!isRecord(body) || body.protocol !== 'patient-connection-identity-review-v1' || body.phase !== 'before-link' || body.operationId !== sent.operationId || body.connectionId !== sent.connectionId) return undefined; const hold = LINK_HOLDS.find(code => code === body.code); if (hold) return 'review' in body ? undefined : { kind: 'hold', code: hold }; const code = body.code === 'link-required' || body.code === 'name-review' ? body.code : undefined; const review = body.review; if (!code || !isRecord(review) || review.outcome !== LINK_REVIEWS[code] || typeof review.evidenceDigest !== 'string' || !/^[0-9a-f]{64}$/.test(review.evidenceDigest)) return undefined; const source = linkFacts(review.source), local = linkFacts(review.local); if (!source || !local) return undefined; return { kind: 'review', code, review: { evidenceDigest: review.evidenceDigest, outcome: LINK_REVIEWS[code], source, local } }; }; const linked = (answer: unknown, sent: PracticeLinkRequest): PracticeLinkAnswer => { if (!isRecord(answer) || (answer.status !== 'linked' && answer.status !== 'recognized')) throw new Error('The link answer could not be read'); const connection = answer.connection as RecordsConnection | undefined; if (connection !== undefined && (!isRecord(connection) || connection.connectionId !== sent.connectionId)) throw new Error('The link answer names another connection'); return { kind: answer.status, ...(connection ? { connection } : {}) }; }; // One decision per owned opening and connection, kept until the server settles it, so a lost answer is retried as itself. // Uncertain: a confirmation was sent whose write may still land, so only the server's settlement of it may retire it. export type PracticeLinkDecision = { operationId: string; uncertain: boolean }; const DECISIONS = 'akashic.practice-link-decisions.v1'; const isDecision = (value: unknown): value is PracticeLinkDecision => isRecord(value) && typeof value.operationId === 'string' && typeof value.uncertain === 'boolean'; // Undefined when the store cannot be read or holds something else: never mistaken for an empty one. const decisionsNow = (): Record | undefined => { try { const raw = sessionStorage.getItem(DECISIONS); if (raw === null) return {}; const held: unknown = JSON.parse(raw); return isRecord(held) && Object.values(held).every(isDecision) ? held as Record : undefined; } catch { return undefined; } }; const keep = (key: string, decision: PracticeLinkDecision | undefined): boolean => { const held = decisionsNow(); if (!held) return false; const { [key]: _old, ...rest } = held; try { sessionStorage.setItem(DECISIONS, JSON.stringify(decision ? { ...rest, [key]: decision } : rest)); } catch { return false; } const now = decisionsNow(); return Boolean(now) && JSON.stringify(now![key]) === JSON.stringify(decision); }; /** The retained decision, or a new one kept before any send; undefined when neither can be relied on. */ export const practiceLinkDecision = (key: string): PracticeLinkDecision | undefined => { const held = decisionsNow(); if (!held) return undefined; if (held[key]) return held[key]; const decision = { operationId: personalOperationId(), uncertain: false }; return keep(key, decision) ? decision : undefined; }; export const markPracticeLinkDecision = (key: string, decision: PracticeLinkDecision): boolean => decisionsNow()?.[key]?.operationId === decision.operationId && keep(key, decision); export const retirePracticeLinkDecision = (key: string, operationId: string) => { if (decisionsNow()?.[key]?.operationId === operationId) keep(key, undefined); }; export const usePatientConnectionsService = ({ personal = false }: { personal?: boolean } = {}) => { const own = usePersonalRecordsService(); const disconnects = useRef(new Map()); const { get, post } = useHttpService(); const listOwnConnections = useCallback( async (scope?: RecordsCallScope) => personal ? (await own.connections(scope!)).connections : (await get<{ connections: RecordsConnection[] }>(`${API_URL}/records/connections/patient`, sentUnder(scope))).connections, [get, personal, own], ); const listPatientIssuers = useCallback( async (scope?: RecordsCallScope) => personal ? own.issuers(scope!) : (await get<{ issuers: RecordsIssuer[] }>(`${API_URL}/records/connections/patient/issuers`, sentUnder(scope))).issuers, [get, personal, own], ); const startPatientConnection = useCallback( async (issuerId: string, scope?: RecordsCallScope, replacesConnectionId?: string, preparedAttemptId?: string) => { if (personal) { const attemptId = preparedAttemptId; if (!attemptId) throw new Error('Personal authorization requires its retained attempt identity'); try { const answer = await own.authorize(attemptId, issuerId, scope!, replacesConnectionId); if (answer.attemptId !== attemptId) throw new Error('Personal attempt answer changed'); return answer; } catch (error) { if (error instanceof HttpError && error.status < 500) throw error; throw new PersonalConnectionUnconfirmed(attemptId); } } return post<{ attemptId: string; authorizeUrl: string }>(`${API_URL}/records/connections/patient/authorize`, startBody(issuerId, replacesConnectionId), sentUnder(scope)); }, [post, personal, own], ); const completePatientConnection = useCallback( async (claim: string, attemptId: string, scope?: RecordsCallScope) => personal ? own.complete(claim, attemptId, scope!) : completed(await post(`${API_URL}/records/connections/patient/complete`, { claim, attemptId }, sentUnder(scope))), [post, personal, own], ); const switchOffOwnConnection = useCallback( async (connectionId: string, scope?: RecordsCallScope, existingOperationId?: string) => { if (personal) { const operationId = existingOperationId ?? disconnects.current.get(connectionId) ?? personalOperationId(); disconnects.current.set(connectionId, operationId); const answer = await own.disconnect(connectionId, operationId, scope!); if (!answer.connection) throw new HttpError(503, 'Local switch-off is unconfirmed. Check the connection before finishing.'); const connection: PersonalConnection = { ...answer.connection, disconnectOperationId: operationId }; return { connection, unconfirmed: answer.status === 'unconfirmed' }; } return switchedOff(await post<{ connection: RecordsConnection; withdrawalResult?: unknown }>( `${API_URL}/records/connections/${encodeURIComponent(connectionId)}/disconnect`, {}, sentUnder(scope), )); }, [post, personal, own], ); // The practice's own evidence and confirm step; never an OAuth claim, and never a personal connection. const confirmPatientLink = useCallback( async (link: PracticeLinkRequest, scope?: RecordsCallScope): Promise => { if (personal) throw new Error('A personal connection is confirmed through its own read'); try { return linked(await post(`${API_URL}/records/connections/patient/complete`, { link }, sentUnder(scope)), link); } catch (failure) { const stop = decodeLinkStop(failure, link); if (stop) return stop; throw failure; } }, [post, personal], ); return { listOwnConnections, listPatientIssuers, startPatientConnection, completePatientConnection, switchOffOwnConnection, confirmPatientLink, personalAttempt: own.attempt, personalConnectionsPage: own.connections }; };