import { useMemo } from 'react'; import { environment } from '../environments/environment'; import { HttpError, useHttpService } from './HttpService'; import type { RecordsCallScope } from './RecordsService'; import type { CompletedConnection, RecordsConnection, RecordsIssuer, RetrievableType } from './RecordsConnectionsService'; export type PersonalEventEvidence = { state: 'unconfirmed' } | { events: Array<{ step: string; state: 'owed' | 'handed-off'; transport?: 'queue' | 'outbox' }>; more?: boolean; }; export type PersonalConnection = RecordsConnection & { disconnectOperationId?: string }; export interface PersonalImport { operationId: string; connectionId: string; actor: string; at: string; issuerId: string; types: RetrievableType[]; state: string; rowVersion: number; eventEvidence?: PersonalEventEvidence; original: { status: 'attached' | 'unconfirmed'; sha256?: string }; summary?: Record; supersededBy?: string; supersedes?: { operationId: string; expectedVersion: number }; evidence: unknown; } export interface PersonalCandidate { sourceIdentity: string; sourceEdition: string; proposalDigest: string; kind: RetrievableType; fields: Record; unavailable?: string; priorCopies: Array<{ resource: string; version: string; sameEdition: boolean }>; } export interface PersonalCandidates { operationId: string; importVersion: number; originalVersionId: string; originalSha256: string; mapping: string; candidates: PersonalCandidate[]; skipped: unknown; cursor?: string; } export interface PersonalSelection { sourceIdentity: string; sourceEdition: string; proposalDigest: string; decision: 'accept' | 'decline'; expectedLocalCopies: Array<{ resource: string; version: string }>; } export interface PersonalAcceptanceRequest { acceptanceId: string; expectedImportVersion: number; originalVersionId: string; originalSha256: string; items: PersonalSelection[]; } export interface PersonalAcceptance { operationId: string; acceptanceId: string; rowVersion: number; eventEvidence?: PersonalEventEvidence; actor: string; at: string; items: Array<{ sourceIdentity: string; sourceEdition: string; kind: RetrievableType; decision: string; phase: string; reason?: string; resourceId?: string; proof?: { resource: string; versionId: string }; evidence?: unknown; existing?: unknown }>; } export type PersonalImportRequest = { operationId: string; connectionId: string; types: RetrievableType[]; supersedes?: { operationId: string; expectedVersion: number } }; export type PersonalIdentityLink = { evidenceDigest: string; confirm: 'self' | 'self-name-reviewed' }; export type PersonalIdentityFacts = { birthDate: string; names: string[] }; type IdentityBinding = { operationId: string; connectionId: string; requestDigest: string }; export type PersonalIdentityStop = IdentityBinding & ({ kind: 'review'; code: 'link-required' | 'name-review'; review: { evidenceDigest: string; outcome: 'match' | 'name-review'; source: PersonalIdentityFacts; local: PersonalIdentityFacts }; } | { kind: 'hold'; code: 'identity-conflict' | 'facts-missing' | 'reconnect-required' }); export type PersonalImportStart = { kind: 'started'; status: string; operationId: string } | PersonalIdentityStop; const digest = (value: unknown): value is string => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value); const record = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); const facts = (value: unknown): PersonalIdentityFacts | undefined => record(value) && typeof value.birthDate === 'string' && Array.isArray(value.names) && value.names.every(name => typeof name === 'string') ? { birthDate: value.birthDate, names: [...value.names] } : undefined; const HOLD_CODES = ['identity-conflict', 'facts-missing', 'reconnect-required'] as const; const REVIEW_OUTCOMES = { 'link-required': 'match', 'name-review': 'name-review' } as const; /** The server's before-intent identity answer to exactly this sent read; anything else is not one and stays unknown. */ export const decodeIdentityStop = (failure: unknown, sent: { operationId: string; connectionId: string }): PersonalIdentityStop | undefined => { if (!(failure instanceof HttpError) || failure.status !== 409 || failure.awsErrorType !== undefined) return undefined; const body = failure.body; if (!record(body) || body.protocol !== 'personal-smart-identity-review-v1' || body.phase !== 'before-intent' || body.operationId !== sent.operationId || body.connectionId !== sent.connectionId || !digest(body.requestDigest)) return undefined; const binding = { operationId: sent.operationId, connectionId: sent.connectionId, requestDigest: body.requestDigest }; const hold = HOLD_CODES.find(code => code === body.code); if (hold) return 'review' in body ? undefined : { ...binding, kind: 'hold', code: hold }; const code = body.code === 'link-required' || body.code === 'name-review' ? body.code : undefined; const review = body.review; if (!code || !record(review) || review.outcome !== REVIEW_OUTCOMES[code] || !digest(review.evidenceDigest)) return undefined; const source = facts(review.source), local = facts(review.local); if (!source || !local) return undefined; return { ...binding, kind: 'review', code, review: { evidenceDigest: review.evidenceDigest, outcome: REVIEW_OUTCOMES[code], source, local } }; }; export interface PersonalAttempt { attemptId: string; status: string; eventEvidence?: PersonalEventEvidence; connection?: PersonalConnection } const root = environment.orgAuthorizationApi + '/records/personal'; const id = (value: string) => { if (!/^[A-Za-z0-9_-]{16,80}$/.test(value)) throw new Error('Invalid personal operation identifier'); return encodeURIComponent(value); }; export const personalOperationId = () => crypto.randomUUID(); export class PersonalConnectionUnconfirmed extends Error { constructor(readonly attemptId: string) { super('The connection result is unconfirmed. Check this attempt before starting again.'); } } const options = (scope: RecordsCallScope) => { if (!scope?.admit || !scope.signal) throw new Error('Personal call requires a live opening'); scope.admit(); return { admit: scope.admit, signal: scope.signal, bypassOrganization: true, bypassActingAs: true, organizationId: '' }; }; const query = (cursor?: string) => cursor ? '?cursor=' + encodeURIComponent(cursor) : ''; const owned = async (answer: Promise, operationId: string): Promise => { const value = await answer; if (value.operationId !== operationId) throw new Error('Personal operation answer changed'); return value; }; export const usePersonalRecordsService = () => { const { get, post } = useHttpService(); return useMemo(() => ({ connections: (scope: RecordsCallScope, cursor?: string) => get<{ connections: PersonalConnection[]; cursor?: string }>(root + '/connections' + query(cursor), options(scope)), issuers: async (scope: RecordsCallScope) => (await get<{ issuers: RecordsIssuer[] }>(root + '/connections/issuers', options(scope))).issuers, authorize: (attemptId: string, issuerId: string, scope: RecordsCallScope, replacesConnectionId?: string) => { id(attemptId); return post<{ attemptId: string; authorizeUrl: string }>(root + '/connections/authorize', { attemptId, issuerId, ...(replacesConnectionId ? { replacesConnectionId } : {}) }, options(scope)); }, complete: (claim: string, attemptId: string, scope: RecordsCallScope) => post(root + '/connections/complete', { claim, attemptId: id(attemptId) }, options(scope)), attempt: async (attemptId: string, scope: RecordsCallScope) => { const answer = await get(root + '/connections/attempts/' + id(attemptId), options(scope)); if (!answer || answer.attemptId !== attemptId || !['unconfirmed', 'awaiting-completion', 'awaiting-vendor', 'connected', 'refused', 'expired', 'disconnected', 'needs-reconnect', 'adoption-failed'].includes(answer.status)) { throw new Error('The connection attempt observation could not be confirmed'); } if ((!['unconfirmed', 'awaiting-completion', 'awaiting-vendor', 'refused', 'expired'].includes(answer.status) && !answer.connection) || (answer.connection && (answer.connection.status !== answer.status || answer.connection.audience !== 'patient' || answer.connection.owner !== 'Patient/' + answer.connection.localPatient || !answer.connection.connectionId))) { throw new Error('The connection attempt standing could not be confirmed'); } return answer; }, disconnect: (connectionId: string, operationId: string, scope: RecordsCallScope) => post<{ connection?: PersonalConnection; operationId: string; status?: string; evidence?: unknown }>(root + '/connections/' + id(connectionId) + '/disconnect', { operationId: id(operationId) }, options(scope)), startImport: async (body: PersonalImportRequest & { link?: PersonalIdentityLink }, scope: RecordsCallScope): Promise => { try { return { ...(await post<{ status: string; operationId: string }>(root + '/imports', body, options(scope))), kind: 'started' }; } catch (failure) { const stop = decodeIdentityStop(failure, body); if (stop) return stop; throw failure; } }, imports: (scope: RecordsCallScope, cursor?: string) => get<{ imports: PersonalImport[]; cursor?: string }>(root + '/imports' + query(cursor), options(scope)), importStatus: (operationId: string, scope: RecordsCallScope) => owned(get(root + '/imports/' + id(operationId), options(scope)), operationId), resumeImport: (operationId: string, expectedVersion: number, scope: RecordsCallScope) => post<{ status: string; operationId: string }>(root + '/imports/' + id(operationId) + '/resume', { expectedVersion }, options(scope)), original: (operationId: string, scope: RecordsCallScope) => get(root + '/imports/' + id(operationId) + '/original', options(scope)), candidates: (operationId: string, scope: RecordsCallScope, cursor?: string) => owned(get(root + '/imports/' + id(operationId) + '/candidates' + query(cursor), options(scope)), operationId), accept: (operationId: string, body: PersonalAcceptanceRequest, scope: RecordsCallScope) => post<{ status: string; acceptance?: PersonalAcceptance; acceptanceId?: string }>(root + '/imports/' + id(operationId) + '/accept', body, options(scope)), acceptances: (operationId: string, scope: RecordsCallScope, cursor?: string) => get<{ acceptances: PersonalAcceptance[]; cursor?: string }>(root + '/imports/' + id(operationId) + '/acceptances' + query(cursor), options(scope)), acceptanceStatus: async (operationId: string, acceptanceId: string, scope: RecordsCallScope) => { const answer = await owned(get(root + '/imports/' + id(operationId) + '/acceptances/' + id(acceptanceId), options(scope)), operationId); if (answer.acceptanceId !== acceptanceId) throw new Error('Personal acceptance answer changed'); return answer; }, resumeAcceptance: (operationId: string, acceptanceId: string, expectedVersion: number, scope: RecordsCallScope) => post<{ status: string; acceptance?: PersonalAcceptance }>(root + '/imports/' + id(operationId) + '/acceptances/' + id(acceptanceId) + '/resume', { expectedVersion }, options(scope)), }), [get, post]); };