import { useCallback } from 'react'; import { environment } from '../environments/environment.ts'; import { useHttpService } from './HttpService.tsx'; import type { SaveTransport } from '../utils/saveOperation'; // - bringing a practice's existing patients in. // // `environment.apiUrl`, NOT `fhirBaseApi`. Every `/R4/patient-import/*` route is an api-service route; // the wrong base is a silent 404 rather than an error anyone can read. // // THE UPLOAD IS NOT THE IMPORT. Putting the file in S3 creates nothing. Nothing exists until a dry run // has been reviewed and committed - the reconciliation IS the feature, because patient identity in // Akashic is global and a naive import quietly creates duplicates of people who already exist. const API_URL = environment.apiUrl; export interface ImportUploadPolicy { batchId: string; url: string; fields: Record; maxBytes: number; } export type ImportUploadOutcome = 'uploaded' | 'failed' | 'unconfirmed'; export type ImportFieldName = string; export type ColumnMapping = Partial>; export interface ColumnSuggestion { field: string; header: string; confidence: 'certain' | 'likely'; reason: string; } export interface AmbiguousField { field: string; headers: string[]; reason: string; } export interface InferredMapping { mapping: ColumnMapping; suggestions: ColumnSuggestion[]; ambiguous: AmbiguousField[]; unmapped: string[]; needsReview: boolean; } /** A candidate this practice may see: its own patient, with the id a link names. */ export interface VisibleImportCandidate { sameOrganization: true; patientId: string; name?: string; birthDate?: string; } /** Someone this import cannot identify. It carries nothing else, and a row has at most one. */ export interface PrivateImportCandidate { sameOrganization: false; } export type ImportCandidate = VisibleImportCandidate | PrivateImportCandidate; /** The opening a request belongs to: asked before it is sent and again before its answer is used. */ export type ImportTransport = Pick; export interface PlannedImportRow { rowNumber: number; sourceId?: string; outcome: 'create' | 'match' | 'already-imported' | 'error'; reason?: string; patientId?: string; candidates?: ImportCandidate[]; /** What this row was offered, as the server names it; an answer names it back. */ offer?: string; } export interface ImportSummary { total: number; create: number; match: number; alreadyImported: number; error: number; withoutSourceId: number; reRunnable: boolean; } export interface DryRunResult { batchId: string; columns: string[]; mapping: ColumnMapping; inferred?: InferredMapping; validation: { errors: string[]; warnings: string[] }; rows: PlannedImportRow[]; summary: ImportSummary; limitations: string[]; } export interface ImportDecision { rowNumber: number; /** `skip` leaves the row out: nothing is written for it. */ action: 'create' | 'link' | 'skip'; /** On a link only. */ patientId?: string; /** The row's revision this answer was given against, when the import already recorded the row. */ revision?: number; /** The offer this answer was given against, as the review showed it. */ offer?: string; } export interface ImportQuestion { rowNumber: number; field: string; value: string; ambiguous?: boolean; } export interface CommitResult { batchId: string; created: Array<{ rowNumber: number; patientId: string }>; linked: Array<{ rowNumber: number; patientId: string }>; failed: Array<{ rowNumber: number; reason: string }>; undecided: number[]; /** Rows the practice left out. An older server sends none, which is no rows left out. */ skipped?: number[]; questions: ImportQuestion[]; /** Where the import stands. An older server sends none of these, which reads as an import that finished. */ state?: ImportOperationState; raw?: 'present' | 'cleanup-pending' | 'purged'; code?: string; alreadyImported?: Array<{ rowNumber: number; patientId: string }>; /** Rows that could not be checked against existing patients this time. */ unavailable?: number[]; /** Rows whose Patient may have been written: not confirmed either way yet. */ unknown?: number[]; /** Rows held by another import of this practice with the same patient ID. */ pendingElsewhere?: number[]; conflict?: number[]; /** Rows the upload expired before anything was written for them. */ expired?: number[]; /** Patients created whose record of how they were created is still owed. */ evidenceOwed?: number[]; remaining?: { initialize: number; decide: number; dispatch: number; evidence: number; settle: number }; /** Rows whose answer this step recorded; the rest are sent again on the next continue. */ recorded?: number[]; } export type ImportOperationState = 'registered' | 'initializing' | 'sealed' | 'settled'; /** One of this practice's imports: ids, state and dates only. */ export interface ImportOperationSummary { batchId: string; state: ImportOperationState; raw: 'present' | 'cleanup-pending' | 'purged'; rows: number | null; registeredAt: string; updatedAt: string; } export interface ImportOperationsPage { operations: ImportOperationSummary[]; next?: string; } /** What one import has done and still owes, read without doing anything. */ export interface ImportStatus extends Omit { mapping?: ColumnMapping; rows: Array<{ rowNumber: number; outcome: string; revision: number; evidence: string; reason?: string; patientId?: string; detail?: 'present' | 'expired' }>; } export const usePatientImportService = () => { const { get, post } = useHttpService(); const requestUploadPolicy = useCallback( async (transport?: ImportTransport): Promise => post(`${API_URL}/patient-import/upload`, {}, transport), [post], ); /** * THREE OUTCOMES, NOT TWO, and the third is the one that matters. A `fetch` POST carrying * `FormData` is a CORS-SIMPLE request, so a missing bucket CORS rule does not stop the send - S3 * stores the object and withholds only the RESPONSE. A thrown promise therefore means "we do not * know", never "nothing was uploaded", and telling a practice their list failed when it is sitting * in the bucket sends them round the loop again. * * Plain `fetch`, never `fetchWithAuth`: this goes straight to S3 and must not carry our headers. */ const uploadFile = useCallback( async (policy: ImportUploadPolicy, file: File, transport?: ImportTransport): Promise => { const form = new FormData(); // Policy fields BEFORE the file. S3 reads the form top to bottom and ignores anything // after `file`, so a field appended later is simply not part of the policy. for (const [key, value] of Object.entries(policy.fields)) form.append(key, value); form.append('file', file); // The opening is asked before the send and before the answer is used; a retired one throws to its caller. transport?.admit(); let response: Response; try { response = await fetch(policy.url, { method: 'POST', body: form, ...(transport ? { signal: transport.signal } : {}) }); } catch { transport?.admit(); return 'unconfirmed'; } transport?.admit(); return response.ok ? 'uploaded' : 'failed'; }, [], ); /** Writes nothing. Run it as many times as it takes to get the mapping right. */ const dryRun = useCallback( async (batchId: string, mapping?: ColumnMapping, transport?: ImportTransport): Promise => post(`${API_URL}/patient-import/${batchId}/dry-run`, mapping ? { mapping } : {}, transport), [post], ); /** Starts or continues one import. A continue may leave the mapping out: the import keeps the one it began with. */ const commit = useCallback( async (batchId: string, mapping: ColumnMapping | undefined, decisions: ImportDecision[], transport?: ImportTransport): Promise => post(`${API_URL}/patient-import/${batchId}/commit`, mapping ? { mapping, decisions } : { decisions }, transport), [post], ); /** Reads only. The answer is used only while the opening that asked still holds. */ const getStatus = useCallback( async (batchId: string, transport?: ImportTransport): Promise => { const status = await get(`${API_URL}/patient-import/${encodeURIComponent(batchId)}/status`, transport ?? {}); transport?.admit(); return status; }, [get], ); const listOperations = useCallback( async (after?: string, transport?: ImportTransport): Promise => { const query = after ? `?after=${encodeURIComponent(after)}` : ''; const page = await get(`${API_URL}/patient-import/operations${query}`, transport ?? {}); transport?.admit(); return page; }, [get], ); return { requestUploadPolicy, uploadFile, dryRun, commit, getStatus, listOperations }; };