import { createContext, useCallback, useContext, useEffect, useMemo, useRef } from 'react'; import type { ClinicalImpression, Patient, Practitioner, Reference, RelatedPerson } from 'fhir/r4'; import { formatFhirHumanName } from '../../../utils/fhirHumanName'; import { useClinicalImpressionServiceContext } from '../../../services/ClinicalImpressionService'; import { useFhirServiceContext } from '../../../services/FhirService'; import { useOptionalEncounterNoteDraftService, type NoteDraftSaveOutcome, } from '../../../services/EncounterNoteDraftService'; import { isArchived } from '../../../utils/archivedResource'; import { isRecordChanged } from '../../../utils/recordChanged'; import { isSaveRetired, type SaveTransport } from '../../../utils/saveOperation'; import { onViewerChange } from '../../../utils/writeThroughHold'; import { noteOutcomeUnknown } from '../../../utils/clinicalNoteOutcome'; /** Why a note's last save ended its basis: refused because it changed (`conflict`), or sent with no answer. */ export type NoteRefusal = 'conflict' | 'unconfirmed'; /** Nothing more is sent from a note whose last save was refused or unconfirmed until the saved note is reviewed. */ export class NoteNeedsReviewError extends Error { constructor(public readonly reason: NoteRefusal) { super('The saved note has to be reviewed before this note is saved again.'); this.name = 'NoteNeedsReviewError'; } } /** * Draft store for the encounter clinical note, one entry per encounter. * * WHY THIS EXISTS: the note is NOT autosaved to FHIR on every keystroke (that produced a new HealthLake * version per pause - a long chain of half-baked mid-sentence versions - and an empty-string PUT threw a * 400 when the box was cleared). The draft is held here and committed to the patient's record as ONE * ClinicalImpression only on an explicit "Save to record" or on Finish encounter. The store lives ABOVE * the encounter workspace (in ClinicianOverviewPage) so a draft survives the provider juggling between * multiple open encounters. * * THE AT-REST INVARIANT, RESTATED FOR THE SECOND TIME - E3 * * This file used to say the draft is held **in memory only**, *"never written to disk / localStorage - no * at-rest PHI"*, and that *"a hard browser refresh discards uncommitted drafts BY DESIGN (the Save button * is the mitigation)"*. **Both sentences are now false, deliberately, and this is the second time this * invariant has moved:** * * 1. **** replaced it for the AI draft - a note generated minutes after the encounter ends * cannot exist at all if nothing persists it. Bounded to 30 days, deleted on accept. * 2. ** E3** replaces it again, for the clinician's **own typing**. Spencer: *"I could see a * busy practitioner waiting until the end of the day to review all of the notes."* "Later" is * routinely a different workstation, and "the Save button is the mitigation" was never true for * someone with patients waiting - it just moved the loss onto them. * * **The invariant as it now stands:** * * An UNSIGNED encounter note exists at rest, server-side, from the first keystroke until it is * committed to the record or explicitly discarded. It is NEVER in localStorage, NEVER in FHIR before * it is committed, and it is deleted the moment its content reaches the chart. * * **Each move enlarges the at-rest PHI surface, and this one is the biggest** - it covers every * encounter, not just recorded ones, and unlike the AI draft it has **no expiry** (a timer here destroys * work nothing can regenerate `encounterNoteDraftStore.ts`). State it plainly rather than letting the * third move happen quietly against a comment nobody updated. * * **Persistence is best-effort, and note writing does not depend on it.** With no * `EncounterNoteDraftProvider` above it, every entry below behaves exactly as it did before E3 - in * memory, lost on refresh. A degradation, never a broken editor. */ interface NoteEntry { draft: string; // current in-memory text (source of truth; survives juggling) committedSummary: string; // last text persisted to FHIR ('' if never) note?: ClinicalImpression; // the existing FHIR resource, for update-in-place loaded: boolean; // committed note has been fetched for this encounter loadFailed?: boolean; // the committed note could not be read, so its content is unknown touched: boolean; // user has typed since mount (guards the seed-after-fetch) /** * E3 - the server draft's version, or 0 when this encounter has no server draft. * * Echoed back on every autosave so the server can refuse a write over text this client has not seen. * Never guessed and never reset to 0 to "start fresh": 0 asserts *there is no draft*, and asserting * it wrongly is exactly how a colleague's note gets clobbered. */ serverVersion: number; /** * Set when a server draft was found for an encounter this client had ALREADY started typing into, with * different text - so neither copy may be silently discarded. The editor reports it; nothing merges. * * Always the SAME practitioner's other window. The draft is keyed per author server-side, so a * colleague's text can never arrive here Spencer's ruling, 2026-08-13. */ conflictingServerText?: string; /** * E2 - the `jobId` of the AI draft already merged into `draft`, if any. * * THIS IS WHAT STOPS THE DRAFT BEING APPENDED TWICE, and it has to live HERE rather than in the * editor. Under E2 the draft auto-populates the note the moment it arrives, and `ScribeDraftPanel` * unmounts and refetches every time the practitioner juggles between open encounters - so a marker held * in component state is gone exactly when it is needed, and coming back to the encounter would append * the same generated note to the end of itself. * * It sits ON the entry deliberately: the fact "this note already contains that draft" is only true of * THIS text, so it must be forgotten at the same moment the text is - one object, one lifetime. Held in * memory with the draft, so it inherits the same "no at-rest PHI" property (a job id is not PHI, but it * would be meaningless attached to a note that had been discarded). */ mergedDraftJobId?: string; /** The last save was refused or went unanswered: `note` is no longer a basis to save over until reviewed. */ refused?: NoteRefusal; /** The record's note as last reviewed on purpose, for the editor to show beside the text. */ savedText?: string; /** The basis was taken from a review, not a confirmed save: the text is written over it before the note counts as saved. */ reviewed?: boolean; /** * Which opening (viewer, practice, patient, visit) established the merged marker, the loaded note and the last * confirmed write's recording. The entry is shared across openings; proof of acceptance is not. */ markerScope?: string; loadedScope?: string; committedDraft?: { jobId: string; scope: string }; } export type CommitResult = 'created' | 'updated' | 'noop'; // Whether the save a write belongs to is still its opening's: retired (closed, another visit, another person) it is // not, though one that only ran out of time still is. const stillOpen = (transport?: SaveTransport): boolean => { try { transport?.admit(); return true; } catch (error) { return !isSaveRetired(error); } }; export interface EncounterNotesApi { /** Fetch the encounter's committed note once and seed the draft (unless the user has already typed). */ ensureLoaded: (patient: Patient, encounterId: string, scope?: string) => Promise; getEntry: (encounterId: string) => NoteEntry | undefined; setDraft: (encounterId: string, text: string) => void; /** * Record that an AI draft's text is now part of this encounter's note. * * Says nothing about the note reaching the chart - that is still `commit`, and still the only thing * that does. This exists solely so the same draft is not appended a second time on a remount. */ markDraftMerged: (encounterId: string, jobId: string, scope?: string) => void; /** * Write the draft to FHIR as one ClinicalImpression, over the version of the note loaded. No-ops on unchanged or * empty drafts. `transport`: the save this belongs to, admitted and cancelled with it. */ commit: (patientId: string, encounterId: string, transport?: SaveTransport) => Promise; /** * Read the record's note again on purpose and take it as the basis of the next save. The text is not touched. * Absent, nothing can be reviewed (a store with no record service behind it). */ reviewSaved?: (encounterId: string, transport?: SaveTransport) => Promise; /** * E3 - push the current draft to the server. Called on a debounce by the editor. * * NOT A COMMIT. It writes to the draft store; the note reaches the record only through `commit`. * Returns the outcome rather than throwing, because it runs on a timer while somebody is typing. */ persistDraft: (patientId: string, encounterId: string) => Promise; } /** * Who a note written here is credited to: the signed-in practitioner, by name, so the patient reads * "What Dr. Whitfield wrote" (deck patient-portal "A visit, opened"). Anyone else signed in writes no * assessor at all - never a guess. */ export const noteAuthorOf = (user?: Patient | Practitioner | RelatedPerson): Reference | undefined => user?.resourceType === 'Practitioner' && user.id ? { reference: `Practitioner/${user.id}`, display: formatFhirHumanName(user.name) } : undefined; export function useEncounterNotesStore(author?: Reference): EncounterNotesApi { const { fetchClinicalImpressions, fetchClinicalImpression, createClinicalImpression, updateClinicalImpression } = useClinicalImpressionServiceContext(); const { unpackBundle } = useFhirServiceContext(); const entriesRef = useRef>({}); // Read at the moment of creation, so the api identity does not change when the session resolves. const authorRef = useRef(author); authorRef.current = author; const loadingRef = useRef>>({}); // The entries belong to the person signed in, as who they act for, at the practice they are in. Another of those // starts from nothing: no earlier text, loaded note or load under way becomes theirs, and a late load or answer // lands only on the entries it was made for. useEffect(() => onViewerChange(() => { entriesRef.current = {}; loadingRef.current = {}; }), []); // Read through a ref so the store's api identity does not churn when the provider re-renders - the same // reason the scribe panels do it. Optional: no provider the note behaves exactly as it did before E3. const draftService = useOptionalEncounterNoteDraftService(); const draftServiceRef = useRef(draftService); draftServiceRef.current = draftService; const ensure = (encounterId: string): NoteEntry => { let entry = entriesRef.current[encounterId]; if (!entry) { entry = { draft: '', committedSummary: '', loaded: false, touched: false, serverVersion: 0, }; entriesRef.current[encounterId] = entry; } return entry; }; const getEntry = useCallback((encounterId: string) => entriesRef.current[encounterId], []); const setDraft = useCallback((encounterId: string, text: string) => { const entry = ensure(encounterId); entry.draft = text; entry.touched = true; }, []); const markDraftMerged = useCallback((encounterId: string, jobId: string, scope?: string) => { const entry = ensure(encounterId); entry.mergedDraftJobId = jobId; entry.markerScope = scope; }, []); const ensureLoaded = useCallback(async (patient: Patient, encounterId: string, scope?: string) => { const existing = entriesRef.current[encounterId]; if (existing?.loaded) return; if (loadingRef.current[encounterId]) return loadingRef.current[encounterId]; // dedupe concurrent loads const entry = ensure(encounterId); // The loads of the viewer this one starts for; a later viewer's record is a different object, left alone. const loads = loadingRef.current; const load = (async () => { let noteFailed = false; try { const bundle = await fetchClinicalImpressions(patient); const found = (unpackBundle(bundle) as ClinicalImpression[]) .filter((ci) => !isArchived(ci)) .find((ci) => ci.encounter?.reference === `Encounter/${encounterId}`); entry.note = found; entry.committedSummary = found?.summary ?? ''; if (!entry.touched) entry.draft = entry.committedSummary; // seed only if the user hasn't typed yet } catch { // Unknown is not empty: a blank draft written now would sit beside the note that failed to load. noteFailed = true; } /* E3 - the server draft, SECOND and separately guarded. It is fetched after the committed note and in its own try, so that a HealthLake failure cannot cost the practitioner their in-progress draft and a draft-store failure cannot hide their committed note. They answer different questions and neither is a prerequisite for the other. The server draft WINS over the committed seed when it exists. That is the point of the feature: the committed note is what was last filed, the draft is what is being written now. */ try { const server = draftServiceRef.current ? await draftServiceRef.current.getNoteDraft(patient.id as string, encounterId) : null; if (server) { entry.serverVersion = server.version; entry.mergedDraftJobId = server.mergedDraftJobId; entry.markerScope = scope; if (!entry.touched) { entry.draft = server.text; } else if (entry.draft !== server.text) { /* They started typing before the load returned AND a different draft already existed. Neither copy may be silently discarded - theirs is unsaved work, the server's may be a colleague's. Nothing is merged: splicing two prose notes fabricates text neither of them wrote. */ entry.conflictingServerText = server.text; } } } catch { // Same posture as above: an unreachable draft store must not stop the editor opening. The autosave // will report its own failures; this one is invisible and harmless. } // Not cached as loaded, so reopening the encounter reads it again. entry.loaded = !noteFailed; entry.loadFailed = noteFailed; entry.loadedScope = scope; delete loads[encounterId]; })(); loads[encounterId] = load; return load; }, [fetchClinicalImpressions, unpackBundle]); /** * E3 - push the current draft to the server. * * NOT A COMMIT, and it deliberately does not touch `committedSummary` or `note`. A save that moved * either would make the editor read "Saved to record" for text that is not in the record. */ const persistDraft = useCallback(async ( patientId: string, encounterId: string, ): Promise => { const service = draftServiceRef.current; const entry = entriesRef.current[encounterId]; if (!service || !entry) return { status: 'failed', tooLarge: false }; const outcome = await service.saveNoteDraft({ patientId, encounterId, text: entry.draft, version: entry.serverVersion, ...(entry.mergedDraftJobId ? { mergedDraftJobId: entry.mergedDraftJobId } : {}), }); if (outcome.status === 'saved') { entry.serverVersion = outcome.version; entry.conflictingServerText = undefined; } else if (outcome.status === 'conflict') { // The version is NOT advanced to the server's. Advancing it would let the very next autosave // succeed and overwrite the other draft - turning a reported conflict into a silent one on a timer. entry.conflictingServerText = outcome.current?.text; } return outcome; }, []); const commit = useCallback(async (patientId: string, encounterId: string, transport?: SaveTransport): Promise => { const entry = entriesRef.current[encounterId]; if (!entry) return 'noop'; if (entry.loadFailed) throw new Error('The saved note could not be loaded, so nothing was written over it.'); if (entry.refused) throw new NoteNeedsReviewError(entry.refused); const value = entry.draft; // The write belongs to this entry (another person or practice gets new entries) and to the save that sent it (its // opening). Its answer is a server fact, but it is put on the entry, and the draft cleared, only while both hold. const owns = () => entriesRef.current[encounterId] === entry && stillOpen(transport); const drafts = draftServiceRef.current; // A review is not a save: after one, the text is written over what was reviewed even if it reads the same. if (value === entry.committedSummary && !entry.reviewed) return 'noop'; // nothing changed since the last write if (!value.trim()) return 'noop'; // never PUT an empty FHIR string (invalid 400); clearing != delete /** * E3 - the draft is finished with once its content is in the record. * * ONLY AFTER THE FHIR WRITE RESOLVES, and the constraint is stricter than the scribe draft's * identical-looking one: a generated draft has a transcript behind it for a moment, a clinician's own * writing has NOTHING. Deleting before the commit confirms destroys the only copy. * * A failed DELETE is swallowed. The note IS in the record, which is what the practitioner cares * about; a stale draft costs a "reload shows older text" surprise, and throwing here would report a * successful save as a failure and invite them to press Save again. */ const carried = entry.mergedDraftJobId && entry.markerScope ? { jobId: entry.mergedDraftJobId, scope: entry.markerScope } : undefined; const dropServerDraft = async () => { // Typed since this save was sent: that newer text is what the server draft now holds, so it stays. if (entry.draft !== value) return; try { // Sent for the save it follows, through the draft service it began with: never for whoever is signed in now. await drafts?.deleteNoteDraft(patientId, encounterId, transport); entry.serverVersion = 0; entry.mergedDraftJobId = undefined; entry.markerScope = undefined; } catch (error) { if (isSaveRetired(error)) return; console.error('Committed the note but could not clear its server draft', error); } }; if (entry.note?.id) { const sent: ClinicalImpression = { ...entry.note, summary: value, date: new Date().toISOString() }; let saved: ClinicalImpression; try { saved = await updateClinicalImpression(sent, entry.note.meta?.versionId, transport); } catch (error) { // Refused, or sent with no answer (or cancelled after it may have left): the text, its server draft and the // basis all stay, and nothing more is sent from here until the saved note is reviewed. if (owns()) { if (isRecordChanged(error)) entry.refused = 'conflict'; else if (noteOutcomeUnknown(error) || transport?.signal.aborted) entry.refused = 'unconfirmed'; } throw error; } if (!owns()) return 'updated'; // Only a version the server returned is the next basis; without one the note is reviewed before it is saved again. entry.note = saved?.resourceType === 'ClinicalImpression' ? saved : { ...sent, meta: undefined }; entry.committedSummary = value; entry.savedText = undefined; entry.reviewed = undefined; if (carried) entry.committedDraft = carried; await dropServerDraft(); return 'updated'; } const created = await createClinicalImpression({ resourceType: 'ClinicalImpression', status: 'in-progress', subject: { reference: `Patient/${patientId}` }, encounter: { reference: `Encounter/${encounterId}` }, // The patient's visit page names this ("What … wrote"). Set once, here, on creation only. ...(authorRef.current && { assessor: authorRef.current }), summary: value, date: new Date().toISOString(), }, transport); if (!owns()) return 'created'; entry.note = created; entry.committedSummary = value; if (carried) entry.committedDraft = carried; await dropServerDraft(); return 'created'; }, [createClinicalImpression, updateClinicalImpression]); /** * The record's note, read fresh, becomes the basis of the next save: a deliberate act after a refused or unconfirmed * save. The text is left alone, so the clinician compares and decides. Reading is not saving: a matching note says * how the record stands now, not which save wrote it, so the note stays unsaved until a save over it is confirmed, * and nothing is resolved or cleared here. */ const reviewSaved = useCallback(async (encounterId: string, transport?: SaveTransport) => { const entry = entriesRef.current[encounterId]; const id = entry?.note?.id; if (!entry || !id) return; const latest = await fetchClinicalImpression(id, true, transport); if (entriesRef.current[encounterId] !== entry || !stillOpen(transport)) return; entry.note = latest; entry.savedText = latest.summary ?? ''; entry.reviewed = true; entry.refused = undefined; }, [fetchClinicalImpression]); return useMemo( () => ({ ensureLoaded, getEntry, setDraft, markDraftMerged, commit, persistDraft, reviewSaved }), [ensureLoaded, getEntry, setDraft, markDraftMerged, commit, persistDraft, reviewSaved], ); } const EncounterNotesContext = createContext(undefined); export const EncounterNotesProvider = EncounterNotesContext.Provider; export function useEncounterNotesContext(): EncounterNotesApi { const ctx = useContext(EncounterNotesContext); if (!ctx) throw new Error('useEncounterNotesContext must be used within an EncounterNotesProvider'); return ctx; }