import { useCallback, createContext, useContext, ReactNode } from 'react'; import type { Basic, Bundle } from 'fhir/r4'; import { environment } from '../environments/environment.ts'; import { useHttpService } from './HttpService.tsx'; import { useFhirServiceContext } from './FhirService.tsx'; import { ORGANIZATION_ROLE_GROUPS, getOrganizationRoleGroupForCode, } from '../utils/staffRoleCatalog.ts'; import { fetchAllPagesAsBundle } from '../utils/fetchAllPagesAsBundle'; import { overlayHeldBundle, refersToPatient } from '../utils/writeThroughHold'; import { searchPageSize } from '../utils/pageSizeOverride'; import type { SaveTransport } from '../utils/saveOperation'; const API_URL = environment.fhirBaseApi; // --------------------------------------------------------------------------- // Wire-format constants - MUST mirror // aws/sam/services/api/src/utils/orgNote.ts exactly (server is authoritative; // this is the client-side read of the same `Basic` extension shape). // --------------------------------------------------------------------------- const NOTE_BODY_EXTENSION_URL = 'https://akashichealthrecords.com/fhir/StructureDefinition/basic-note-body'; const NOTE_VISIBILITY_EXTENSION_URL = 'https://akashichealthrecords.com/fhir/StructureDefinition/basic-note-visibility'; const NOTE_VISIBILITY_MODE_URL = 'mode'; const NOTE_VISIBILITY_ROLE_CODE_URL = 'roleCode'; export type NoteVisibilityMode = 'me-only' | 'all-staff' | 'roles'; export interface NoteVisibility { mode: NoteVisibilityMode; roleCodes: string[]; } // Normalized client-side shape for an org-internal patient note. The server stores/reads a note // as a FHIR `Basic` resource (note body + visibility encoded as extensions) - this service is // the translation layer, so components consume/produce this flat shape instead of parsing FHIR // extensions directly. Mirrors what `fetchNotes`/`createNote`/`updateNote` return, and what // `updateNote`/`deleteNote` accept back. export interface OrgNote { id: string; patientId: string; authorId?: string; body: string; visibility: NoteVisibility; // `meta.lastUpdated` - precise timestamp for the list's datetime display (`Basic.created` is // date-only; see the design doc's "Open / deferred" section). createdAt?: string; } interface CreateOrgNoteInput { patientId: string; body: string; visibility: NoteVisibility; operationId: string; } export type PrivateNoteCreateOutcome = { operationId: string; state: 'closed' } | { operationId: string; state: 'committed'; noteId: string; firstCreatedAt: string; current: 'present' | 'deleted' | 'unavailable'; evidencePending?: true; note?: OrgNote }; export interface PrivateNoteTransport extends SaveTransport { authorId: string; organizationId?: string; bypassOrganization?: boolean } // --------------------------------------------------------------------------- // Basic <-> OrgNote translation (private) // --------------------------------------------------------------------------- const getReferenceId = (reference?: string): string | undefined => reference?.split('/').pop(); const getNoteVisibilityFromBasic = (basic: Basic): NoteVisibility => { const visibilityExtension = basic.extension?.find( (extension) => extension.url === NOTE_VISIBILITY_EXTENSION_URL, ); const childExtensions = visibilityExtension?.extension ?? []; const modeValue = childExtensions.find( (extension) => extension.url === NOTE_VISIBILITY_MODE_URL, )?.valueCode; const mode: NoteVisibilityMode = modeValue === 'all-staff' || modeValue === 'roles' ? modeValue : 'me-only'; const roleCodes = childExtensions .filter( (extension) => extension.url === NOTE_VISIBILITY_ROLE_CODE_URL && typeof extension.valueString === 'string', ) .map((extension) => extension.valueString as string); return { mode, roleCodes }; }; const basicToOrgNote = (basic: Basic): OrgNote => ({ id: basic.id as string, patientId: getReferenceId(basic.subject?.reference) ?? '', authorId: getReferenceId(basic.author?.reference), body: basic.extension?.find((extension) => extension.url === NOTE_BODY_EXTENSION_URL) ?.valueString ?? '', visibility: getNoteVisibilityFromBasic(basic), createdAt: basic.meta?.lastUpdated, }); export function parsePrivateNoteOutcome(raw: unknown, operationId: string, patientId: string, authorId?: string, organizationId?: string, allowNote = true): PrivateNoteCreateOutcome { if (!raw || typeof raw !== 'object') throw new Error('The note save outcome could not be confirmed.'); const r = raw as Record; if (r.operationId !== operationId) throw new Error('The note save outcome could not be confirmed.'); if (r.state === 'closed') { if (Object.keys(r).some(k => !['operationId', 'state'].includes(k))) throw new Error('The note save outcome could not be confirmed.'); return { operationId, state: 'closed' }; } if (Object.keys(r).some(k => !['operationId', 'state', 'noteId', 'firstCreatedAt', 'current', 'currentNote', 'evidencePending'].includes(k)) || ('evidencePending' in r && r.evidencePending !== true) || r.state !== 'committed' || typeof r.noteId !== 'string' || !/^[A-Za-z0-9.-]{1,64}$/.test(r.noteId) || typeof r.firstCreatedAt !== 'string' || !/^\d{4}-\d{2}-\d{2}T/.test(r.firstCreatedAt) || !Number.isFinite(Date.parse(r.firstCreatedAt)) || typeof r.current !== 'string' || !['present', 'deleted', 'unavailable'].includes(r.current)) { throw new Error('The note save outcome could not be confirmed.'); } let note: OrgNote | undefined; if (r.currentNote !== undefined) { const basic = r.currentNote as Basic; if (!allowNote || r.current !== 'present' || basic?.resourceType !== 'Basic' || basic.id !== r.noteId || basic.subject?.reference !== `Patient/${patientId}` || !basic.author?.reference?.startsWith('Practitioner/') || (authorId && basic.author.reference !== `Practitioner/${authorId}`)) { throw new Error('The note save outcome could not be confirmed.'); } const proofUrl = 'https://akashichealthrecords.com/fhir/StructureDefinition/private-note-create-proof'; const opSystem = 'https://akashichealthrecords.com/fhir/identifier/private-note-create-operation'; const body = basic.extension?.filter(e => e.url === NOTE_BODY_EXTENSION_URL) ?? []; const visibility = basic.extension?.filter(e => e.url === NOTE_VISIBILITY_EXTENSION_URL) ?? []; if (basic.extension?.some(e => e.url === proofUrl) || basic.identifier?.some(i => i.system === opSystem) || body.length !== 1 || typeof body[0]?.valueString !== 'string' || !body[0].valueString.trim() || visibility.length !== 1 || !basic.meta?.security?.some(c => c.code === 'R') || (authorId && !basic.meta?.tag?.some(t => t.system === `https://akashichealthrecords.com/fhir/tags/${organizationId ? 'organization' : 'practitionerScope'}` && t.code === (organizationId ?? authorId)))) { throw new Error('The note save outcome could not be confirmed.'); } const modes = visibility[0]?.extension?.filter(e => e.url === 'mode') ?? []; const roles = visibility[0]?.extension?.filter(e => e.url === 'roleCode') ?? []; if (modes.length !== 1 || !['me-only', 'all-staff', 'roles'].includes(modes[0]?.valueCode ?? '') || (!organizationId && authorId && modes[0]?.valueCode !== 'me-only') || roles.some(r => typeof r.valueString !== 'string' || !r.valueString.trim()) || (modes[0]?.valueCode === 'roles' ? roles.length === 0 : roles.length > 0)) { throw new Error('The note save outcome could not be confirmed.'); } note = basicToOrgNote(basic); } return { operationId, state: 'committed', noteId: r.noteId, firstCreatedAt: r.firstCreatedAt, current: r.current as 'present' | 'deleted' | 'unavailable', ...(r.evidencePending === true ? { evidencePending: true as const } : {}), ...(note ? { note } : {}) }; } // --------------------------------------------------------------------------- // Visibility translation - category (ORGANIZATION_ROLE_GROUPS) <-> role codes. // The server only ever stores/reads expanded role codes (Approach 3 in the design doc); the // client owns the role-code -> category catalog, so this expansion/collapse lives here. // --------------------------------------------------------------------------- // Expands the given org role-category labels (ORGANIZATION_ROLE_GROUPS `label`s) into the full, // flattened set of member role codes. Used when saving a `roles`-mode visibility selection. export const expandCategoriesToRoleCodes = (categoryLabels: string[]): string[] => { const labelSet = new Set(categoryLabels); return ORGANIZATION_ROLE_GROUPS .filter((group) => labelSet.has(group.label)) .flatMap((group) => group.items.map((item) => item.code)); }; // Reverse-maps a note's stored `visibility.roleCodes` back to the role-category labels they // represent, for display (e.g. showing "Nursing & Advanced Practice" instead of a raw code // list). Only meaningful for `roles`-mode notes; `me-only`/`all-staff` have no categories. export const getVisibilityCategoriesForNote = (note: Pick): string[] => { if (note.visibility.mode !== 'roles') return []; const labels = new Set(); for (const roleCode of note.visibility.roleCodes) { const group = getOrganizationRoleGroupForCode(roleCode); if (group) labels.add(group.label); } return Array.from(labels); }; // --------------------------------------------------------------------------- // Service // --------------------------------------------------------------------------- export const useOrgNoteService = () => { const { get, post, put, delete: del } = useHttpService(); const { unpackBundle } = useFhirServiceContext(); // GET /R4/Basic?patient= - server builds `subject=Patient/{patientId}` + // org-tag scope + code filter internally (see Basic/get.ts); the client only supplies the // bare patient id via the `patient` query param. const fetchNotes = useCallback(async (patientId: string): Promise => { // No `_count` was stated, so this returned one server page of the practice's internal notes on a // patient - the running commentary staff leave for each other. A page boundary drops the oldest // context on a long-standing patient, which is exactly the patient it matters most for. const searched = await fetchAllPagesAsBundle((pageParam) => { const params = new URLSearchParams(); params.append('patient', patientId); params.append('_count', searchPageSize('100')); if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/Basic?${params.toString()}`); }); // Notes are the only Basic resources this client writes, so a held one belongs by its patient. const bundle = overlayHeldBundle('Basic', searched, (note) => refersToPatient(note, patientId)) as Bundle; return unpackBundle(bundle).map(basicToOrgNote); }, [get, unpackBundle]); // POST /R4/Basic - body matches ICreateOrgNoteRequest in Basic/post.ts exactly: // { operationId, patientId, body, visibility }. `author`, `code`, and `meta.security` are always // server-derived - the client never supplies them. const createNote = useCallback(async (input: CreateOrgNoteInput, transport: PrivateNoteTransport): Promise => { const { authorId, ...options } = transport; const result = await post(`${API_URL}/Basic`, input, options); return parsePrivateNoteOutcome(result, input.operationId, input.patientId, authorId, options.organizationId); }, [post]); const checkNoteCreate = useCallback(async (patientId: string, operationId: string, transport: PrivateNoteTransport): Promise => { const { authorId, ...options } = transport; const params = new URLSearchParams({ operationId, patientId }); return parsePrivateNoteOutcome(await get(`${API_URL}/Basic?${params}`, options), operationId, patientId, authorId, options.organizationId, false); }, [get]); const closeNoteCreate = useCallback(async (patientId: string, operationId: string, transport: PrivateNoteTransport): Promise => { const { authorId, ...options } = transport; return parsePrivateNoteOutcome(await post(`${API_URL}/Basic`, { operationAction: 'close', operationId, patientId, }, options), operationId, patientId, authorId, options.organizationId, false); }, [post]); // PUT /R4/Basic/{id} - body matches IUpdateOrgNoteRequest in Basic/put.ts exactly: // { body, visibility }. Everything else (id, author, subject, created, org context) is // preserved server-side from the existing resource; the server also enforces author-only. const updateNote = useCallback(async (note: OrgNote): Promise => { const result = await put(`${API_URL}/Basic/${note.id}`, { body: note.body, visibility: note.visibility, }); return basicToOrgNote(result); }, [put]); // DELETE /R4/Basic/{id} - server enforces author-only; confirm-before-delete is a UI concern // (house rule), not this service's. const deleteNote = useCallback(async (note: OrgNote): Promise => { await del<{ message: string }>(`${API_URL}/Basic/${note.id}`); }, [del]); return { fetchNotes, createNote, checkNoteCreate, closeNoteCreate, updateNote, deleteNote, expandCategoriesToRoleCodes, getVisibilityCategoriesForNote, }; }; const OrgNoteServiceContext = createContext | undefined>(undefined); export const OrgNoteServiceProvider = ({ children }: { children: ReactNode }) => { const orgNoteService = useOrgNoteService(); return ( {children} ); }; export const useOrgNoteServiceContext = () => { const context = useContext(OrgNoteServiceContext); if (context === undefined) { throw new Error('useOrgNoteServiceContext must be used within an OrgNoteServiceProvider'); } return context; };