import { createContext, useCallback, useContext, ReactNode } from 'react'; import { environment } from '../environments/environment.ts'; import { useHttpService, HttpError } from './HttpService.tsx'; import type { SaveTransport } from '../utils/saveOperation'; /** * E3 - the client half of the encounter note autosave. * * > Spencer: *"The practitioner doesn't even have to think about saving the draft."* * * THIS IS NOT THE SCRIBE SERVICE AND MUST NEVER BE FOLDED INTO IT. The scribe is a paid add-on; * writing a note is not. `ScribeService` is reached through `useOptionalScribeService` precisely so a * missing scribe provider cannot take down note writing - if this lived there, the add-on's absence would * take the autosave with it, which is the same failure one layer down. * * `environment.apiUrl`, NOT `fhirBaseApi`. `/R4/encounter-note-draft` is an api-service route, not FHIR * resource CRUD, and the wrong base is a silent 404. `apiUrl` already * ends in /R4; do not append another. * * THE READ AND DELETE PUT IDS IN THE QUERY STRING. A Lambda request authorizer never receives the * request body, so a body-borne patient id could not have been authorised before the handler ran. The * SAVE has to carry the note text, so it has a body - and its handler re-derives both ids from that body * and re-checks the patient boundary itself for exactly this reason. */ const API_URL = environment.apiUrl; export interface EncounterNoteDraft { encounterId: string; patientId: string; /** * Who wrote this text - always the caller. * * Spencer, 2026-08-13: *"we definitely don't want different practitioners all writing to the same * note, these all need to be separate."* The author is part of the server's sort key, so there is no * request that reaches somebody else's draft. An earlier version carried an `isOwnDraft` flag * because a colleague's draft COULD arrive; that state no longer exists. */ authorPractitionerId: string; text: string; /** E2 - which AI draft is already merged in, so a reload cannot merge it twice. */ mergedDraftJobId?: string; version: number; createdAtMs: number; updatedAtMs: number; } /** * The outcome of an autosave. * * THREE OUTCOMES, NOT TWO, and collapsing them is the defect this type exists to prevent. E3 makes a * clinician STOP THINKING ABOUT SAVING - so a failure they are not told about is strictly worse than the * old behaviour, because they have been taught to trust it. "Saved", "your other window got there first" * and "it did not save" need three different things said on screen. */ export type NoteDraftSaveOutcome = | { status: 'saved'; version: number; updatedAtMs: number } | { status: 'conflict'; current?: EncounterNoteDraft } | { status: 'failed'; tooLarge: boolean }; /** One unfinished note, as the list route reports it. No text - the caller counts and ages, never reads. */ export interface UnfinishedNoteSummary { encounterId: string; patientId: string; createdAtMs: number; updatedAtMs: number; } export interface EncounterNoteDraftApi { /** The in-progress note for this encounter, or null when nobody has started one. */ getNoteDraft: (patientId: string, encounterId: string) => Promise; saveNoteDraft: (input: { patientId: string; encounterId: string; text: string; version: number; mergedDraftJobId?: string; }) => Promise; /** Only after a CONFIRMED commit - there is nothing behind a clinician's own writing to restore it from. */ /** `transport`: the note save this cleanup follows, so a cleanup it no longer owns is never sent. */ deleteNoteDraft: (patientId: string, encounterId: string, transport?: SaveTransport) => Promise; /** * Which of MY notes are unfinished. * * A DIFFERENT QUESTION FROM THE ONE ABOVE, which needs a patient and an encounter. The notes worth * chasing belong to encounters nobody has reopened, so there is no id a caller could name. */ listUnfinishedNotes: () => Promise; } const EncounterNoteDraftContext = createContext(undefined); export const useEncounterNoteDraftService = (): EncounterNoteDraftApi => { const ctx = useContext(EncounterNoteDraftContext); if (!ctx) { throw new Error('useEncounterNoteDraftService must be used within an EncounterNoteDraftProvider'); } return ctx; }; /** * The same accessor, tolerant of an absent provider. * * Used by the notes store, which mounts on surfaces this provider does not always wrap (and in tests * that predate it). Absent the note behaves exactly as it did before E3: in memory, lost on refresh. * That is a degradation, not a break, and it is the right direction - the alternative is a missing * provider taking down note writing entirely the rule `useOptionalScribeService` already states. */ export const useOptionalEncounterNoteDraftService = (): EncounterNoteDraftApi | undefined => useContext(EncounterNoteDraftContext); export const EncounterNoteDraftProvider = ({ children }: { children: ReactNode }) => { const { get, put, delete: del } = useHttpService(); const getNoteDraft = useCallback(async ( patientId: string, encounterId: string, ): Promise => { try { return await get( `${API_URL}/encounter-note-draft?patient=${encodeURIComponent(patientId)}` + `&encounter=${encodeURIComponent(encounterId)}`, ); } catch (error) { // 404 is the ordinary "nobody has started a note for this encounter" case - the overwhelming // majority of encounters. Surfaced as null so every caller does not have to unwrap it. if (error instanceof HttpError && error.status === 404) return null; throw error; } }, [get]); const saveNoteDraft = useCallback(async (input: { patientId: string; encounterId: string; text: string; version: number; mergedDraftJobId?: string; }): Promise => { try { const result = await put<{ version: number; updatedAtMs: number }>( `${API_URL}/encounter-note-draft`, input, ); return { status: 'saved', version: result.version, updatedAtMs: result.updatedAtMs }; } catch (error) { if (error instanceof HttpError && error.status === 409) { // NOT a failure and NOT a success. Text this client has not seen is in there - written // by this same practitioner somewhere else - and the caller has to stop overwriting rather // than retry. A retry loop would race their other window and the loser's work vanishes. const current = (error.body as { current?: EncounterNoteDraft } | undefined)?.current; return { status: 'conflict', current }; } if (error instanceof HttpError && error.status === 413) { return { status: 'failed', tooLarge: true }; } /* SWALLOWED HERE AND REPORTED AS `failed`, never thrown. This runs on a timer while somebody is typing; an exception escaping into the editor would be an unhandled rejection on every keystroke batch during an outage. The caller's job is to SAY it did not save - which is the whole safety property - not to crash. */ console.error('Failed to autosave the encounter note draft', error); return { status: 'failed', tooLarge: false }; } }, [put]); const listUnfinishedNotes = useCallback(async (): Promise => { // Never throws. This feeds a reminder, and a reminder that takes down the page it sits on has // done more harm than the note it was chasing. An empty list is the safe reading: it says // nothing, which is exactly what an unreachable server should say. try { const result = await get<{ drafts: UnfinishedNoteSummary[] }>( `${API_URL}/encounter-note-drafts`, ); return result.drafts ?? []; } catch (error) { console.error('Could not read the unfinished note list', error); return []; } }, [get]); const deleteNoteDraft = useCallback(async (patientId: string, encounterId: string, transport?: SaveTransport): Promise => { await del( `${API_URL}/encounter-note-draft?patient=${encodeURIComponent(patientId)}` + `&encounter=${encodeURIComponent(encounterId)}`, { ...transport }, ); }, [del]); return ( {children} ); };