import { useEffect, useRef, useState } from 'react'; import { useOptionalPatientNoteDraftService, type PatientNoteDraftApi, type PatientNoteDraftToken, type PatientNoteDraftTransport } from '../../services/PatientNoteDraftService'; import { EMPTY_DRAFT, type PatientNoteDraft } from './patientNoteDraftStore'; import { createDraftSaveQueue, type DraftSaveQueue } from './patientNoteDraftSaveQueue'; import { startSaveOperation, type SaveOperation } from '../../utils/saveOperation'; import { onViewerChange, viewerEpoch } from '../../utils/writeThroughHold'; export type PrivateNoteDraftStatus = 'idle' | 'loading' | 'draft-saving' | 'draft-saved' | 'draft-failed' | 'draft-conflict'; export interface PatientNoteDraftScope { key: string; organizationId?: string; applicable: boolean; admit: () => void; } export interface FrozenDraftCleanup extends PatientNoteDraftToken { revision: string; } export type FrozenDraftResult = | { state: 'acknowledged'; cleanup: FrozenDraftCleanup; revision: string } | { state: 'unavailable' | 'conflict' | 'not-applicable' }; export type SubmittedDraftCleanupResult = 'removed' | 'already-absent' | 'preserved' | 'conflict' | 'unknown'; export interface PatientNoteDraftDurability { status: PrivateNoteDraftStatus; restored?: PatientNoteDraft; freezeSubmitted: (snapshot: PatientNoteDraft) => Promise; cleanupSubmitted: (token: FrozenDraftCleanup) => Promise; isSubmittedCurrent: (token: FrozenDraftCleanup) => boolean; resume: () => void; /** An unkeyed cleanup is refused; callers must freeze the submitted revision. */ discard: () => Promise; } const draftKey = (draft: PatientNoteDraft): string => JSON.stringify([ draft.newBody, draft.newVisibility.mode, draft.newVisibility.categoryLabels, draft.editingId ?? null, draft.editBody, draft.editVisibility.mode, draft.editVisibility.categoryLabels, ]); const copy = (draft: PatientNoteDraft): PatientNoteDraft => ({ ...draft, newVisibility: { ...draft.newVisibility, categoryLabels: [...draft.newVisibility.categoryLabels] }, editVisibility: { ...draft.editVisibility, categoryLabels: [...draft.editVisibility.categoryLabels] }, }); const validToken = (value: { incarnation?: string; version?: number }): value is PatientNoteDraftToken => typeof value.incarnation === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value.incarnation) && Number.isSafeInteger(value.version) && Number(value.version) > 0; interface Lifetime { key: string; epoch: number; live: boolean; id: string; revision: number; content: string; ready: boolean; readFailed: boolean; paused: boolean; loadedAbsent: boolean; acknowledged?: { key: string; token: PatientNoteDraftToken }; queue: DraftSaveQueue; operations: Set; patientId: string; service?: PatientNoteDraftApi; scope?: PatientNoteDraftScope; } const retire = (life: Lifetime) => { life.live = false; life.queue.reset(); life.operations.forEach(operation => operation.retire()); life.operations.clear(); }; export const usePatientNoteDraftDurability = (patientId: string, draft: PatientNoteDraft, scope?: PatientNoteDraftScope): PatientNoteDraftDurability => { const service = useOptionalPatientNoteDraftService(); const [status, setStatus] = useState('idle'); const [restored, setRestored] = useState<{ owner: Lifetime; draft: PatientNoteDraft }>(); const [resumeGeneration, setResumeGeneration] = useState(0); const key = `${scope?.key ?? 'private-note'}|${patientId}`; const current = useRef(); const belongs = (life: Lifetime) => { if (!life.live || current.current !== life || life.epoch !== viewerEpoch()) return false; try { life.scope?.admit(); return true; } catch { return false; } }; const transport = (life: Lifetime): { operation: SaveOperation; transport: PatientNoteDraftTransport } => { const operation = startSaveOperation(() => belongs(life)); life.operations.add(operation); return { operation, transport: { ...operation.transport, organizationId: life.scope?.organizationId, admit: () => { life.scope?.admit(); operation.admit(); }, } }; }; const prior = current.current; if (!prior || prior.key !== key || prior.epoch !== viewerEpoch() || prior.service?.savePatientNoteDraft !== service?.savePatientNoteDraft) { if (prior) retire(prior); const life = { key, epoch: viewerEpoch(), live: true, id: crypto.randomUUID(), revision: 0, content: draftKey(draft), ready: false, readFailed: false, paused: false, loadedAbsent: false, operations: new Set(), patientId, service, scope } as Lifetime; life.queue = createDraftSaveQueue({ save: async (snapshot, version, incarnation) => { if (!belongs(life) || !life.service) return { status: 'failed' }; const request = transport(life); try { const answer = await request.operation.step(() => life.service!.savePatientNoteDraft({ patientId: life.patientId, draft: snapshot, version, ...(incarnation ? { incarnation } : {}) }, request.transport)); request.transport.admit(); if (answer.status === 'saved' && validToken(answer)) life.acknowledged = { key: draftKey(snapshot), token: { incarnation: answer.incarnation, version: answer.version } }; return answer.status === 'saved' && !validToken(answer) ? { status: 'failed' } : answer; } catch { return { status: 'failed' }; } finally { request.operation.retire(); life.operations.delete(request.operation); } }, onState: state => { if (!belongs(life)) return; setStatus(state === 'saving' ? 'draft-saving' : state === 'saved' ? 'draft-saved' : state === 'conflict' ? 'draft-conflict' : 'draft-failed'); }, }); current.current = life; } const life = current.current!; const content = draftKey(draft); if (life.content !== content) { life.content = content; life.revision += 1; } const sentKey = useRef<{ life: Lifetime; key: string }>(); useEffect(() => { if (current.current === life && life.epoch === viewerEpoch()) life.live = true; setRestored(undefined); if (!life.service || !patientId || life.scope?.applicable === false) { life.ready = true; setStatus('idle'); return; } setStatus('loading'); const request = transport(life); void request.operation.step(() => life.service!.getPatientNoteDraft(life.patientId, request.transport)).then(found => { request.transport.admit(); if (found) { if (!validToken(found)) throw new Error('Draft identity missing'); life.queue.reset(found.version, found.incarnation); const restoredDraft = found.draft ?? EMPTY_DRAFT; sentKey.current = { life, key: draftKey(restoredDraft) }; life.acknowledged = { key: draftKey(restoredDraft), token: { incarnation: found.incarnation, version: found.version } }; setRestored({ owner: life, draft: copy(restoredDraft) }); } else life.loadedAbsent = true; life.ready = true; setStatus('idle'); setResumeGeneration(value => value + 1); }).catch(() => { if (request.operation.belongs()) { life.ready = false; life.readFailed = true; setStatus('draft-failed'); } }).finally(() => { request.operation.retire(); life.operations.delete(request.operation); }); return () => retire(life); }, [life]); useEffect(() => onViewerChange(() => { if (current.current) retire(current.current); }), []); useEffect(() => { if (!life.service || !life.ready || !belongs(life) || life.paused || life.scope?.applicable === false || life.queue.stopped) return; const isEmpty = draft.newBody.trim() === '' && draft.editBody.trim() === ''; if (isEmpty && life.queue.version === 0) return; if (sentKey.current?.life === life && sentKey.current.key === content) return; const snapshot = copy(draft); const timer = setTimeout(() => { if (!belongs(life) || life.paused) return; sentKey.current = { life, key: content }; life.queue.save(snapshot); }, 1_500); return () => clearTimeout(timer); }, [life, content, resumeGeneration]); const revision = (owner: Lifetime) => `${owner.id}:${owner.revision}`; const freezeSubmitted = async (snapshot: PatientNoteDraft): Promise => { if (life.scope?.applicable === false) return { state: 'not-applicable' }; if (!belongs(life) || !life.service || !life.ready) return { state: 'unavailable' }; const frozenKey = draftKey(snapshot); if (frozenKey !== life.content) return { state: 'unavailable' }; life.paused = true; const localRevision = revision(life); const outcome = await life.queue.flush(copy(snapshot)); if (!belongs(life)) return { state: 'unavailable' }; if (outcome.status !== 'saved') return { state: outcome.status === 'conflict' ? 'conflict' : 'unavailable' }; const acknowledged = life.acknowledged; if (!acknowledged || acknowledged.key !== frozenKey || revision(life) !== localRevision) return { state: 'unavailable' }; sentKey.current = { life, key: frozenKey }; return { state: 'acknowledged', revision: localRevision, cleanup: { ...acknowledged.token, revision: localRevision } }; }; const cleanupSubmitted = async (token: FrozenDraftCleanup): Promise => { if (!belongs(life) || !life.service || !life.ready || life.scope?.applicable === false || !validToken(token)) return 'unknown'; if (token.revision.startsWith(`${life.id}:`) && revision(life) !== token.revision) return 'conflict'; const matchesAcknowledged = life.acknowledged?.key === life.content && life.acknowledged.token.incarnation === token.incarnation && life.acknowledged.token.version === token.version; if (!token.revision.startsWith(`${life.id}:`) && !matchesAcknowledged && !life.loadedAbsent) return 'conflict'; const cleanupRevision = revision(life); const request = transport(life); try { const answer = await request.operation.step(() => life.service!.deletePatientNoteDraft(life.patientId, token, request.transport)); request.transport.admit(); if (revision(life) === cleanupRevision && (cleanupRevision === token.revision || matchesAcknowledged) && ['removed', 'already-absent', 'preserved'].includes(answer.status)) { if (answer.status === 'preserved') { life.queue.reset(answer.current.version, answer.current.incarnation); life.acknowledged = { key: draftKey(answer.current.draft), token: { incarnation: answer.current.incarnation, version: answer.current.version } }; sentKey.current = { life, key: life.acknowledged.key }; } else { life.queue.reset(); life.acknowledged = undefined; sentKey.current = undefined; } life.paused = true; } return answer.status; } catch { return 'unknown'; } finally { request.operation.retire(); life.operations.delete(request.operation); } }; return { status: life.readFailed ? 'draft-failed' : life.ready ? status : 'loading', ...(restored?.owner === life ? { restored: restored.draft } : {}), freezeSubmitted, cleanupSubmitted, isSubmittedCurrent: token => belongs(life) && revision(life) === token.revision, resume: () => { if (belongs(life)) { life.paused = false; setResumeGeneration(value => value + 1); } }, discard: async () => { throw new Error('Freeze the submitted draft before cleanup'); }, }; };