import { useCallback } from 'react'; import { environment } from '../environments/environment.ts'; import { useHttpService } from './HttpService.tsx'; import type { RecordsCallScope, RecordsRequest } from './RecordsService.tsx'; // Connections from this practice to another system's SMART on FHIR server. The server holds the state, the PKCE // verifier, the code and every token; the browser only ever sees the vendor's authorize address, a one-time claim on // the way back, and a connection's public fields. The shapes mirror infra utils/smart/smartConnect.ts. const API_URL = environment.orgAuthorizationApi; const sentUnder = (scope?: RecordsCallScope) => (scope ? { admit: scope.admit, signal: scope.signal, organizationId: scope.organizationId } : {}); /** What came of the connection a Connect again replaces, as the server says. */ export type ReplacementOutcome = 'retired' | 'pending' | 'withdrawn' | 'not-replaced'; export interface CompletedConnection { connection: RecordsConnection; replacement?: ReplacementOutcome; } const REPLACEMENT_OUTCOMES: readonly string[] = ['retired', 'pending', 'withdrawn', 'not-replaced']; /** Only an outcome the server names is kept; anything else says nothing about a replacement. */ export const completed = (answer: CompletedConnection): CompletedConnection => ({ connection: answer.connection, ...(REPLACEMENT_OUTCOMES.includes(answer.replacement as string) ? { replacement: answer.replacement } : {}), }); /** * A switch-off as the server answers it. Unconfirmed: the connection is off, but what came of it could not be stored, * so the row is what was seen, not a stored result; only a later list says what was kept. */ export interface SwitchedOff { connection: RecordsConnection; unconfirmed: boolean; } export const switchedOff = (answer: { connection: RecordsConnection; withdrawalResult?: unknown }): SwitchedOff => ({ connection: answer.connection, unconfirmed: answer.withdrawalResult === 'unconfirmed' }); export const startBody = (issuerId: string, replacesConnectionId?: string) => ({ issuerId, ...(replacesConnectionId ? { replacesConnectionId } : {}) }); /** A system Akashic is registered with, named as the server names it. */ export interface RecordsIssuer { issuerId: string; vendorDisplay: string; environmentLabel: string; issuerDisplay: string; } /** Only 'connected' is usable: 'pending' is an adoption not yet confirmed, 'adoption-failed' one that was not. */ export type RecordsConnectionStatus = 'pending' | 'connected' | 'disconnected' | 'needs-reconnect' | 'adoption-failed'; export interface RecordsConnection { connectionId: string; vendorDisplay: string; environmentLabel: string; issuerDisplay: string; audience: 'staff-delegated' | 'patient'; owner: string; externalPatient?: string; /** A patient's own connection: the local patient it was made by, whose request alone it reads into. */ localPatient?: string; grantedScopes: string[]; status: RecordsConnectionStatus; renewing?: boolean; reconnectReason?: string; /** Set when the server could not read whether this connection's stored access is still usable. */ standing?: 'unknown'; connectedAt: string; expiresAt?: string; disconnectedAt?: string; revocation?: 'pending' | 'revoked' | 'not-supported' | 'could-not-confirm'; /** Whether Akashic's stored access was deleted after the connection was switched off. */ cleanup?: 'tokens-deleted' | 'tokens-not-deleted'; /** The registration it was made through; Connect again starts that same one. */ issuerId?: string; /** On a replacement: the connection it was made in place of. */ replaces?: string; /** On a replaced connection, once switched off: the one that replaced it. */ replacedBy?: string; /** On a connection being replaced and not yet switched off: its replacement. */ replacing?: string; /** What a staff lookup through this connection asks for, as the server's registration says; absent, a birth date is optional. */ lookup?: { birthDate: 'required' | 'optional' }; /** Whether the other system's person is proved to be this patient; never the proof itself. */ identity?: { class: 'source-self' | 'source-other' | 'source-unknown'; linked: boolean; action: 'none' | 'confirm' | 'reconnect' | 'held' }; } /** The kinds of record a retrieval reads, in the server's order (infra utils/smart/smartRetrieval.ts). */ export const RETRIEVABLE_TYPES = [ 'AllergyIntolerance', 'Condition', 'Immunization', 'MedicationRequest', 'MedicationStatement', 'Observation', 'Procedure', ] as const; export type RetrievableType = (typeof RETRIEVABLE_TYPES)[number]; /** The one scope parameter the server understands (infra smartRetrieval.ts): an Observation category in HL7's system. */ export const OBSERVATION_CATEGORY_SYSTEM = 'http://terminology.hl7.org/CodeSystem/observation-category'; interface ScopeGrant { kind: string; read: boolean; search: boolean; category?: string; unsupported?: boolean } /** One SMART scope as the server reads it: v2 letters, v1 read, *, and only an Observation category as a parameter. */ function grantOfScope(scope: string): ScopeGrant | undefined { const match = /^(?:patient|user|system)\/([A-Za-z]+|\*)\.(read|write|\*|c?r?u?d?s?)(?:\?(.*))?$/.exec(scope); if (!match) return undefined; const [, kind, permission, query] = match; const all = permission === 'read' || permission === '*'; const letters = !all && permission !== 'write' ? permission : ''; const read = all || letters.includes('r'); const search = all || letters.includes('s'); if (query === undefined) return { kind, read, search }; let decoded = ''; try { decoded = query.startsWith('category=') && !query.includes('&') ? decodeURIComponent(query.slice('category='.length)) : ''; } catch { decoded = ''; } const bar = decoded.lastIndexOf('|'); const code = decoded.slice(bar + 1); const category = kind === 'Observation' && bar > 0 && decoded.slice(0, bar) === OBSERVATION_CATEGORY_SYSTEM && /^[a-z][a-z0-9-]{0,63}$/.test(code) ? code : undefined; return category ? { kind, read, search, category } : { kind, read: false, search: false, unsupported: true }; } const grantsOf = (scopes: readonly string[]): ScopeGrant[] => scopes.map(grantOfScope).filter((grant): grant is ScopeGrant => Boolean(grant)); const searchesWhole = (grants: ScopeGrant[], type: string) => grants.some((grant) => !grant.category && grant.search && (grant.kind === type || grant.kind === '*')); /** The categories a kind may be searched in when it may not be searched whole, as the server decides them. */ export function grantedCategories(scopes: readonly string[], type: string): string[] { const grants = grantsOf(scopes); if (searchesWhole(grants, type)) return []; return [...new Set(grants.filter((grant) => grant.kind === type && grant.category && grant.search).map((grant) => grant.category!))].sort(); } /** * The kinds a connection's grant lets a retrieval read, as the server decides them: those it may search, whole or in * granted categories. A kind outside it is refused whole. */ export function grantedTypes(scopes: readonly string[]): RetrievableType[] { const grants = grantsOf(scopes); return RETRIEVABLE_TYPES.filter((type) => searchesWhole(grants, type) || grantedCategories(scopes, type).length > 0); } /** The kinds granted only in a form the server does not use (a scope parameter it does not understand). */ export function unsupportedScopes(scopes: readonly string[]): string[] { return [...new Set(grantsOf(scopes).filter((grant) => grant.unsupported).map((grant) => grant.kind))]; } /** A patient as the other system names them: identity only, never the local match. */ export interface ExternalPatientMatch { id: string; name?: string; birthDate?: string; gender?: string } /** What one retrieval read and kept, as the intake carries it. */ export interface RecordsRetrievalSummary { issuer: string; externalPatient: string; types: RetrievableType[]; pages: number; resources: number; counts: Record; /** Kinds kept in the original that the reviewed import does not offer as items. */ notOffered: string[]; } /** * What a retrieval is asked to read: whose connection, which patient there, and which kinds. A patient's own connection * names no patient: its grant is already that patient's. */ export interface RetrievalChoice { connectionId: string; externalPatient?: string; types: RetrievableType[] } export const useRecordsConnectionsService = () => { const { get, post } = useHttpService(); const listConnections = useCallback( async (scope?: RecordsCallScope) => (await get<{ connections: RecordsConnection[] }>(`${API_URL}/records/connections`, sentUnder(scope))).connections, [get], ); const listIssuers = useCallback( async (scope?: RecordsCallScope) => (await get<{ issuers: RecordsIssuer[] }>(`${API_URL}/records/connections/issuers`, sentUnder(scope))).issuers, [get], ); /** * Asks the server to start a connection; it answers with the vendor's sign-in address and nothing secret. Connect * again names the connection it replaces. */ const startConnection = useCallback( async (issuerId: string, scope?: RecordsCallScope, replacesConnectionId?: string) => post<{ attemptId: string; authorizeUrl: string }>(`${API_URL}/records/connections/authorize`, startBody(issuerId, replacesConnectionId), sentUnder(scope)), [post], ); /** * Hands the one-time claim back with the attempt it names; only the server's answer says whether anything was * connected, or replaced. */ const completeConnection = useCallback( async (claim: string, attemptId: string, scope?: RecordsCallScope) => completed(await post(`${API_URL}/records/connections/complete`, { claim, attemptId }, sentUnder(scope))), [post], ); const disconnectConnection = useCallback( async (connectionId: string, scope?: RecordsCallScope) => switchedOff(await post<{ connection: RecordsConnection; withdrawalResult?: unknown }>( `${API_URL}/records/connections/${encodeURIComponent(connectionId)}/disconnect`, {}, sentUnder(scope), )), [post], ); /** The owner's lookup at the other system: at most 20, with `more` when that system had further matches. */ const searchExternalPatients = useCallback( async (connectionId: string, name: string, birthDate: string | undefined, scope?: RecordsCallScope) => get<{ patients: ExternalPatientMatch[]; more: boolean }>( `${API_URL}/records/connections/${encodeURIComponent(connectionId)}/patients?name=${encodeURIComponent(name)}` + (birthDate ? `&birthdate=${encodeURIComponent(birthDate)}` : ''), sentUnder(scope), ), [get], ); /** Reads the patient's records into the request as one original; everything read is kept, or nothing. */ const startRetrieval = useCallback( async (requestId: string, body: RetrievalChoice & { operationId: string; expectedVersion: number }, scope?: RecordsCallScope) => post<{ intake: { intakeId: string }; request: RecordsRequest; evidence?: 'owed'; message?: string }>( `${API_URL}/records/requests/${encodeURIComponent(requestId)}/retrievals`, body, sentUnder(scope), ), [post], ); return { listConnections, listIssuers, startConnection, completeConnection, disconnectConnection, searchExternalPatients, startRetrieval }; };