// src/utils/archivedResource.ts // Delete Archive (Phase 1b). Single source of truth for how a clinical resource is // archived and detected. See docs/_CLAUDE/2026-07-16-delete-to-archive-phase1b-design.md. export const ARCHIVED_EXTENSION_URL = 'https://akashichealthrecords.com/fhir/StructureDefinition/resource-archived'; const ARCHIVED_REASON_SUB_URL = 'reason'; const ARCHIVED_AT_SUB_URL = 'at'; const ARCHIVED_NOTE_SUB_URL = 'note'; // Real FHIR CodeSystem URLs (the codebase only has the ValueSet URLs, used to populate // terminology-bound pickers; we set verificationStatus programmatically, so we need these). export const CONDITION_VER_STATUS_SYSTEM = 'http://terminology.hl7.org/CodeSystem/condition-ver-status'; export const ALLERGY_VERIFICATION_SYSTEM = 'http://terminology.hl7.org/CodeSystem/allergyintolerance-verification'; export type ArchiveReason = | 'entered-in-error' | 'duplicate' | 'no-longer-applies' | 'other'; const VOID_REASONS: ReadonlySet = new Set(['entered-in-error', 'duplicate']); // How each type carries "entered-in-error". Default (unlisted) = plain `status`. // 'none' = no status field always uses the archived extension. type StatusFieldKind = 'status' | 'lifecycleStatus' | 'verificationStatus' | 'none'; const STATUS_FIELD: Record = { Condition: 'verificationStatus', AllergyIntolerance: 'verificationStatus', Goal: 'lifecycleStatus', AdverseEvent: 'none', ImmunizationRecommendation: 'none', }; const verificationSystemFor = (resourceType?: string): string => resourceType === 'AllergyIntolerance' ? ALLERGY_VERIFICATION_SYSTEM : CONDITION_VER_STATUS_SYSTEM; /** Immutably return a copy of `resource` archived per the reason. Pure. */ export function applyArchive>( resource: T, reason: ArchiveReason, note?: string, ): T { const kind = STATUS_FIELD[resource.resourceType ?? ''] ?? 'status'; const isVoid = VOID_REASONS.has(reason); if (isVoid && kind !== 'none') { if (kind === 'verificationStatus') { // Set verificationStatus = entered-in-error; the FHIR invariant requires clinicalStatus // be absent when verificationStatus is entered-in-error, so drop it. const next: Record = { ...resource }; delete next.clinicalStatus; next.verificationStatus = { coding: [ { system: verificationSystemFor(resource.resourceType), code: 'entered-in-error', display: 'Entered in Error' }, ], text: 'Entered in Error', }; return withArchiveDetail(next as T, reason, note); } // The void status says the record is withdrawn; the detail says which reason was chosen, when, and any note, // so a duplicate is not later read as a mistake. if (kind === 'lifecycleStatus') { return withArchiveDetail({ ...resource, lifecycleStatus: 'entered-in-error' } as T, reason, note); } return withArchiveDetail({ ...resource, status: 'entered-in-error' } as T, reason, note); } // Archived-extension path: no-longer-applies / other, OR a void reason on a no-status type. return withArchiveDetail(resource, reason, note); } export function withArchiveDetail>( resource: T, reason: ArchiveReason, note?: string, ): T { const others = (resource.extension ?? []).filter((e: any) => e.url !== ARCHIVED_EXTENSION_URL); const sub: any[] = [ { url: ARCHIVED_REASON_SUB_URL, valueCode: reason }, { url: ARCHIVED_AT_SUB_URL, valueDateTime: new Date().toISOString() }, ]; if (note && note.trim()) sub.push({ url: ARCHIVED_NOTE_SUB_URL, valueString: note.trim() }); return { ...resource, extension: [...others, { url: ARCHIVED_EXTENSION_URL, extension: sub }] }; } export interface ArchiveDetail { reason: ArchiveReason; at?: string; note?: string; } /** The reason in plain words, for a reader rather than a picker. */ export const ARCHIVE_REASON_LABELS: Record = { 'entered-in-error': 'entered in error', duplicate: 'a duplicate', 'no-longer-applies': 'no longer applies', other: 'another reason', }; /** Reads archive detail only when the extension records a recognized reason. */ export function readArchiveDetail( resource?: ({ extension?: any[] } & Record) | null, ): ArchiveDetail | undefined { const archived = (resource?.extension ?? []).find((e: any) => e.url === ARCHIVED_EXTENSION_URL); const parts: any[] = archived?.extension ?? []; const reason = parts.find((e) => e.url === ARCHIVED_REASON_SUB_URL)?.valueCode; if (typeof reason !== 'string' || !Object.prototype.hasOwnProperty.call(ARCHIVE_REASON_LABELS, reason)) { return undefined; } return { reason: reason as ArchiveReason, at: parts.find((e) => e.url === ARCHIVED_AT_SUB_URL)?.valueDateTime, note: parts.find((e) => e.url === ARCHIVED_NOTE_SUB_URL)?.valueString, }; } /** True if the resource is archived by any signal (extension OR entered-in-error). Resource-agnostic. */ export function isArchived(resource?: ({ resourceType?: string } & Record) | null): boolean { if (!resource) return false; if ((resource.extension ?? []).some((e: any) => e.url === ARCHIVED_EXTENSION_URL)) return true; if (resource.status === 'entered-in-error') return true; if (resource.lifecycleStatus === 'entered-in-error') return true; const vs = resource.verificationStatus; if (vs?.coding?.some((c: any) => c.code === 'entered-in-error')) return true; return false; } export interface ArchiveSummary { /** Why, in plain words: from the archive record, or "entered in error" from the status that marks it. */ reasonLabel: string; /** When it was archived, where the archive record says. */ at?: string; note?: string; } /** * How an archived record is described wherever it is shown: archived, why and when. Undefined when it is not * archived. The record's own clinical status is a different fact and is left to be shown as recorded. */ export function archiveSummary( resource?: ({ resourceType?: string; extension?: any[] } & Record) | null, ): ArchiveSummary | undefined { if (!isArchived(resource)) return undefined; const detail = readArchiveDetail(resource); if (detail) return { reasonLabel: ARCHIVE_REASON_LABELS[detail.reason], at: detail.at, note: detail.note }; return { reasonLabel: ARCHIVE_REASON_LABELS['entered-in-error'] }; }