diff --git a/MAINTAINERS.md b/MAINTAINERS.md index a118f16..09c34a8 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -2,7 +2,7 @@ ## Overview -DREAM (Drug Reaction Evaluation & Anaesthetic Management) is a private, local-first clinical Progressive Web App designed for the Royal Prince Alfred Hospital (RPAH) Department of Clinical Immunology & Allergy. It guides clinicians through perioperative anaesthetic allergy workups—from REDCap patient record import and tailored testing plan creation to skin prick/intradermal test logging, patient handouts, and eMR-ready clinical reports—all processed locally in the browser without transmitting identifiable patient data. Drug protocol definitions and dilution concentrations are tied to the SCRATCH drug library (repository `monchee/drug-library`, hosted at [scratch.yuson.au](https://scratch.yuson.au)), which serves as the upcoming source of truth for drug protocol data that DREAM consumes as a pinned JSON snapshot. +DREAM (Drug Reaction Evaluation & Anaesthetic Management) is a private, local-first clinical Progressive Web App designed for the Royal Prince Alfred Hospital (RPAH) Department of Clinical Immunology & Allergy. It guides clinicians through perioperative anaesthetic allergy workups—from REDCap patient record import and tailored testing plan creation to skin prick/intradermal test logging, patient handouts, and eMR-ready clinical reports—all processed locally in the browser without transmitting identifiable patient data. Drug protocol definitions, dilution concentrations, and cross-reactivity guidance are tied to the SCRATCH drug library (repository `monchee/drug-library`, hosted at [scratch.yuson.au](https://scratch.yuson.au)), which serves as the source of truth that DREAM consumes as a pinned JSON snapshot schema 1.1 (`src/shared/data/protocols.snapshot.json`). All protocol and cross-reactivity changes originate in SCRATCH. --- diff --git a/scripts/sync-protocols.mjs b/scripts/sync-protocols.mjs index 544c4bf..b6886ce 100644 --- a/scripts/sync-protocols.mjs +++ b/scripts/sync-protocols.mjs @@ -19,7 +19,11 @@ const DEFAULT_LOCAL_PATH = '/Users/monchee/Projects/scratch/docs/api/protocols.j // project only gets its requested *.pages.dev name if it is free, and it was not. export const PUBLISHED_PROTOCOLS_URL = 'https://scratch.yuson.au/api/protocols.json'; -const SUPPORTED_SCHEMA_VERSIONS = ['1.0']; +export const SUPPORTED_SCHEMA_VERSIONS = ['1.0', '1.1']; + +export function isSupportedSchemaVersion(version) { + return typeof version === 'string' && SUPPORTED_SCHEMA_VERSIONS.includes(version); +} export function computeDoseLevelDiff(oldSnapshot, newSnapshot) { const diffs = []; @@ -238,10 +242,10 @@ export async function syncProtocols(options = {}) { newSnapshotRaw = JSON.parse(fileContent); } - if (!SUPPORTED_SCHEMA_VERSIONS.includes(newSnapshotRaw.schema_version)) { - console.error(`\nError: Unrecognised schema_version "${newSnapshotRaw.schema_version}".`); - console.error(`Supported versions: ${SUPPORTED_SCHEMA_VERSIONS.join(', ')}`); - process.exit(1); + if (!isSupportedSchemaVersion(newSnapshotRaw.schema_version)) { + const errorMsg = `Unrecognised schema_version "${newSnapshotRaw.schema_version}". Supported versions: ${SUPPORTED_SCHEMA_VERSIONS.join(', ')}`; + console.error(`\nError: ${errorMsg}`); + throw new Error(errorMsg); } let oldSnapshot = null; diff --git a/src/features/info-pages/components/DrugReferencePage.test.tsx b/src/features/info-pages/components/DrugReferencePage.test.tsx new file mode 100644 index 0000000..457af48 --- /dev/null +++ b/src/features/info-pages/components/DrugReferencePage.test.tsx @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import DrugReferencePage from './DrugReferencePage'; +import { CROSS_REACTIVITY_ITEMS, CROSS_REACTIVITY_GOVERNANCE } from '@shared/data/crossReactivity'; +import snapshot from '@shared/data/protocols.snapshot.json'; + +describe('DrugReferencePage Component', () => { + const setScreenMock = vi.fn(); + + it('renders all six cross-reactivity categories from the pinned snapshot/adapter', () => { + const { container } = render(); + + expect(CROSS_REACTIVITY_ITEMS).toHaveLength(6); + expect(CROSS_REACTIVITY_ITEMS).toEqual(snapshot.cross_reactivity.items); + + // Verify each category exists as an accordion trigger + const triggers = container.querySelectorAll('[data-radix-collection-item]'); + expect(triggers.length).toBe(6); + + for (let i = 0; i < CROSS_REACTIVITY_ITEMS.length; i++) { + const item = CROSS_REACTIVITY_ITEMS[i]; + const trigger = triggers[i]; + expect(trigger).toHaveTextContent(item.category); + + // Open accordion item to verify content and alternatives + fireEvent.click(trigger); + expect(screen.getByText(item.info)).toBeInTheDocument(); + expect(screen.getByText(item.alternatives, { exact: false })).toBeInTheDocument(); + } + }); + + it('renders the "Alternatives:" label for each cross-reactivity item when expanded', () => { + const { container } = render(); + + const triggers = container.querySelectorAll('[data-radix-collection-item]'); + for (const trigger of triggers) { + fireEvent.click(trigger); + } + + const altLabels = screen.getAllByText('Alternatives:'); + expect(altLabels).toHaveLength(6); + }); + + it('renders the "Clinical review pending" notice when under_review is true', () => { + render(); + + expect(CROSS_REACTIVITY_GOVERNANCE.under_review).toBe(true); + const notice = screen.getByText(/Clinical review pending/i); + expect(notice).toBeInTheDocument(); + }); + + it('does not invent or render reviewer or date when fields are blank', () => { + render(); + + expect(CROSS_REACTIVITY_GOVERNANCE.reviewed_by).toBe(''); + expect(CROSS_REACTIVITY_GOVERNANCE.last_reviewed).toBe(''); + + // Ensure no fallback date/reviewer strings are rendered + expect(screen.queryByText(/Reviewed by/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Review date/i)).not.toBeInTheDocument(); + }); +}); diff --git a/src/features/info-pages/components/DrugReferencePage.tsx b/src/features/info-pages/components/DrugReferencePage.tsx index f766a7a..9cf2bc5 100644 --- a/src/features/info-pages/components/DrugReferencePage.tsx +++ b/src/features/info-pages/components/DrugReferencePage.tsx @@ -1,46 +1,15 @@ import React from 'react'; -import { Card, CardContent, Button, Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui'; +import { Card, CardContent, Button, Accordion, AccordionItem, AccordionTrigger, AccordionContent, Badge } from '@/components/ui'; import { Home, AlertTriangle, ArrowRight } from 'lucide-react'; import { Screen } from '@shared/types'; import { DRUG_CATEGORIES, CATEGORY_THEMES } from '@shared/utils/constants'; +import { CROSS_REACTIVITY_ITEMS, CROSS_REACTIVITY_GOVERNANCE } from '@shared/data/crossReactivity'; interface DrugReferencePageProps { setScreen: (screen: Screen) => void; } const DrugReferencePage: React.FC = ({ setScreen }) => { - const crossReactivityInfo = [ - { - category: "Muscle Relaxants", - info: "Quaternary ammonium compounds are thought to be responsible for most reactions. Cross-reactivity between agents is common due to structural similarities.", - alternatives: "Consider non-depolarising agents with different structures. Cisatracurium may have lower immunogenicity." - }, - { - category: "Penicillins", - info: "Cross-reactivity with cephalosporins depends on side chain similarity. First-generation cephalosporins have higher cross-reactivity (~2%).", - alternatives: "Cephalosporins with dissimilar side chains, carbapenems (low cross-reactivity), or non-beta-lactams." - }, - { - category: "Cephalosporins", - info: "Cross-reactivity is more related to R1 side chain similarity than the beta-lactam ring. Later generations have different side chains.", - alternatives: "Cephalosporins with different side chains, carbapenems, aztreonam (minimal cross-reactivity)." - }, - { - category: "Local Anaesthetics", - info: "True allergy is rare (<1%). Most reactions are vasovagal or due to adrenaline. Amide types rarely cross-react with each other.", - alternatives: "Different amide local anaesthetic, or ester type if amide allergy confirmed." - }, - { - category: "Opioids", - info: "Can cause direct mast cell degranulation (non-IgE mediated). Morphine and codeine are most histamine-releasing.", - alternatives: "Fentanyl or remifentanil (less histamine release), or non-opioid analgesia." - }, - { - category: "Hypnotics", - info: "Propofol reactions may be to the lipid emulsion or specific to propofol. Egg/soya allergy is not a contraindication.", - alternatives: "Different induction agent (thiopentone, ketamine, etomidate)." - } - ]; return (
@@ -95,11 +64,22 @@ const DrugReferencePage: React.FC = ({ setScreen }) => { {/* Cross-Reactivity & Alternatives */}
-

- Cross-Reactivity & Alternatives -

+
+

+ Cross-Reactivity & Alternatives +

+ {CROSS_REACTIVITY_GOVERNANCE.under_review && ( + + + )} +
- {crossReactivityInfo.map((item, idx) => ( + {CROSS_REACTIVITY_ITEMS.map((item, idx) => ( { + it('supports schema versions 1.0 and 1.1', () => { + expect(SUPPORTED_SCHEMA_VERSIONS).toContain('1.0'); + expect(SUPPORTED_SCHEMA_VERSIONS).toContain('1.1'); + expect(SUPPORTED_SCHEMA_VERSIONS).toEqual(['1.0', '1.1']); + }); + + it('accepts schema version 1.0 and 1.1 via isSupportedSchemaVersion', () => { + expect(isSupportedSchemaVersion('1.0')).toBe(true); + expect(isSupportedSchemaVersion('1.1')).toBe(true); + }); + + it('rejects unrecognised or malformed schema versions', () => { + expect(isSupportedSchemaVersion('0.9')).toBe(false); + expect(isSupportedSchemaVersion('1.2')).toBe(false); + expect(isSupportedSchemaVersion('2.0')).toBe(false); + expect(isSupportedSchemaVersion('')).toBe(false); + expect(isSupportedSchemaVersion(undefined as unknown as string)).toBe(false); + }); +}); + +describe('sync-protocols dose level diff review gate', () => { + it('detects no diff when drug protocols are identical', () => { + const snapshot = { + schema_version: '1.1', + drugs: [ + { + slug: 'test-drug', + title: 'Test Drug', + version: '1.0', + last_reviewed: 'review-marker-before', + protocols: [ + { + id: 'iv', + label: 'IV', + test_type: 'skin', + presentation: 'presentation-before', + diluent: 'diluent-placeholder', + under_review: false, + needs_pharmacy_verification: false, + spt: { dilution: 'dilution-before', concentration: 'concentration-before' }, + idt: [{ dilution: 'dilution-before', concentration: 'concentration-before' }], + challenge: { interval: 'interval-placeholder', steps: [{ dose: 'dose-placeholder' }] }, + }, + ], + }, + ], + }; + + const diffs = computeDoseLevelDiff(snapshot, snapshot); + expect(diffs).toEqual([]); + }); + + it('detects modifications in drug protocols', () => { + const oldSnapshot = { + drugs: [ + { + slug: 'test-drug', + title: 'Test Drug', + version: '1.0', + last_reviewed: 'review-marker-before', + protocols: [ + { + id: 'iv', + label: 'IV', + test_type: 'skin', + spt: { dilution: 'dilution-before', concentration: 'concentration-before' }, + }, + ], + }, + ], + }; + + const newSnapshot = { + drugs: [ + { + slug: 'test-drug', + title: 'Test Drug', + version: '1.1', + last_reviewed: 'review-marker-after', + protocols: [ + { + id: 'iv', + label: 'IV', + test_type: 'skin', + spt: { dilution: 'dilution-after', concentration: 'concentration-after' }, + }, + ], + }, + ], + }; + + const diffs = computeDoseLevelDiff(oldSnapshot, newSnapshot); + expect(diffs).toHaveLength(1); + expect(diffs[0].type).toBe('DRUG_MODIFIED'); + }); +}); diff --git a/src/shared/data/crossReactivity.test.ts b/src/shared/data/crossReactivity.test.ts new file mode 100644 index 0000000..fcd4c25 --- /dev/null +++ b/src/shared/data/crossReactivity.test.ts @@ -0,0 +1,307 @@ +import { describe, expect, it } from 'vitest'; +import snapshot from './protocols.snapshot.json'; +import { + CROSS_REACTIVITY_ITEMS, + CROSS_REACTIVITY_GOVERNANCE, + parseCrossReactivitySnapshot, +} from './crossReactivity'; + +describe('crossReactivity data adapter', () => { + it('exposes exactly six ordered items matching the pinned snapshot', () => { + expect(CROSS_REACTIVITY_ITEMS).toHaveLength(6); + expect(CROSS_REACTIVITY_ITEMS).toEqual(snapshot.cross_reactivity.items); + }); + + it('exposes the snapshot pending governance metadata accurately', () => { + expect(CROSS_REACTIVITY_GOVERNANCE).toEqual({ + version: snapshot.cross_reactivity.version, + last_reviewed: snapshot.cross_reactivity.last_reviewed, + reviewed_by: snapshot.cross_reactivity.reviewed_by, + under_review: snapshot.cross_reactivity.under_review, + provenance: snapshot.cross_reactivity.provenance, + }); + expect(CROSS_REACTIVITY_GOVERNANCE.under_review).toBe(true); + expect(CROSS_REACTIVITY_GOVERNANCE.last_reviewed).toBe(''); + expect(CROSS_REACTIVITY_GOVERNANCE.reviewed_by).toBe(''); + }); + + it('validates every item contains non-empty category, info, and alternatives', () => { + for (const item of CROSS_REACTIVITY_ITEMS) { + expect(typeof item.category).toBe('string'); + expect(item.category.trim().length).toBeGreaterThan(0); + expect(typeof item.info).toBe('string'); + expect(item.info.trim().length).toBeGreaterThan(0); + expect(typeof item.alternatives).toBe('string'); + expect(item.alternatives.trim().length).toBeGreaterThan(0); + } + }); + + it('preserves the exact order of categories from snapshot', () => { + const categories = CROSS_REACTIVITY_ITEMS.map((item) => item.category); + const expectedCategories = snapshot.cross_reactivity.items.map((item) => item.category); + expect(categories).toEqual(expectedCategories); + }); +}); + +describe('parseCrossReactivitySnapshot invariant error handling', () => { + const createValidSnapshot = () => ({ + schema_version: '1.1', + cross_reactivity: { + version: '1.0', + last_reviewed: '', + reviewed_by: '', + under_review: true, + provenance: 'Test provenance', + items: [ + { + category: 'Test category', + info: 'Test information', + alternatives: 'Test alternative', + }, + ], + }, + }); + + describe('snapshot and schema_version validation', () => { + it('throws if snapshot is null or not an object', () => { + expect(() => parseCrossReactivitySnapshot(null as any)).toThrow( + /expected a non-null snapshot object/i + ); + expect(() => parseCrossReactivitySnapshot(undefined as any)).toThrow( + /expected a non-null snapshot object/i + ); + expect(() => parseCrossReactivitySnapshot('string' as any)).toThrow( + /expected a non-null snapshot object/i + ); + }); + + it('throws if schema_version is missing, not a string, or not exactly "1.1"', () => { + const snapMissing = createValidSnapshot(); + delete (snapMissing as any).schema_version; + expect(() => parseCrossReactivitySnapshot(snapMissing)).toThrow( + /schema_version/i + ); + + const snapWrongType = { ...createValidSnapshot(), schema_version: 1.1 as any }; + expect(() => parseCrossReactivitySnapshot(snapWrongType)).toThrow( + /schema_version/i + ); + + const snapWrongVersion = { ...createValidSnapshot(), schema_version: '1.0' }; + expect(() => parseCrossReactivitySnapshot(snapWrongVersion)).toThrow( + /schema_version/i + ); + + const snapEmptyVersion = { ...createValidSnapshot(), schema_version: '' }; + expect(() => parseCrossReactivitySnapshot(snapEmptyVersion)).toThrow( + /schema_version/i + ); + }); + + it('throws if cross_reactivity is missing or not an object', () => { + expect(() => + parseCrossReactivitySnapshot({ schema_version: '1.1' } as any) + ).toThrow(/missing or invalid cross_reactivity/i); + expect(() => + parseCrossReactivitySnapshot({ + schema_version: '1.1', + cross_reactivity: null, + } as any) + ).toThrow(/missing or invalid cross_reactivity/i); + expect(() => + parseCrossReactivitySnapshot({ + schema_version: '1.1', + cross_reactivity: 'invalid', + } as any) + ).toThrow(/missing or invalid cross_reactivity/i); + }); + }); + + describe('governance fields validation', () => { + it('throws if under_review is not a boolean', () => { + const snap = createValidSnapshot(); + (snap.cross_reactivity as any).under_review = 'true'; + expect(() => parseCrossReactivitySnapshot(snap)).toThrow( + /under_review.*boolean/i + ); + + (snap.cross_reactivity as any).under_review = null; + expect(() => parseCrossReactivitySnapshot(snap)).toThrow( + /under_review.*boolean/i + ); + }); + + it('throws if version is missing, not a string, or empty/whitespace', () => { + const snapMissing = createValidSnapshot(); + delete (snapMissing.cross_reactivity as any).version; + expect(() => parseCrossReactivitySnapshot(snapMissing)).toThrow( + /version.*string/i + ); + + const snapNonString = createValidSnapshot(); + (snapNonString.cross_reactivity as any).version = 1.0; + expect(() => parseCrossReactivitySnapshot(snapNonString)).toThrow( + /version.*string/i + ); + + const snapEmpty = createValidSnapshot(); + snapEmpty.cross_reactivity.version = ''; + expect(() => parseCrossReactivitySnapshot(snapEmpty)).toThrow( + /version.*non-empty/i + ); + + const snapWhitespace = createValidSnapshot(); + snapWhitespace.cross_reactivity.version = ' '; + expect(() => parseCrossReactivitySnapshot(snapWhitespace)).toThrow( + /version.*non-empty/i + ); + }); + + it('throws if provenance is missing, not a string, or empty/whitespace', () => { + const snapMissing = createValidSnapshot(); + delete (snapMissing.cross_reactivity as any).provenance; + expect(() => parseCrossReactivitySnapshot(snapMissing)).toThrow( + /provenance.*string/i + ); + + const snapNonString = createValidSnapshot(); + (snapNonString.cross_reactivity as any).provenance = 123; + expect(() => parseCrossReactivitySnapshot(snapNonString)).toThrow( + /provenance.*string/i + ); + + const snapEmpty = createValidSnapshot(); + snapEmpty.cross_reactivity.provenance = ''; + expect(() => parseCrossReactivitySnapshot(snapEmpty)).toThrow( + /provenance.*non-empty/i + ); + + const snapWhitespace = createValidSnapshot(); + snapWhitespace.cross_reactivity.provenance = ' \t '; + expect(() => parseCrossReactivitySnapshot(snapWhitespace)).toThrow( + /provenance.*non-empty/i + ); + }); + + it('throws if last_reviewed or reviewed_by are not strings', () => { + const snapLastReviewedType = createValidSnapshot(); + (snapLastReviewedType.cross_reactivity as any).last_reviewed = null; + expect(() => parseCrossReactivitySnapshot(snapLastReviewedType)).toThrow( + /last_reviewed.*string/i + ); + + const snapReviewedByType = createValidSnapshot(); + (snapReviewedByType.cross_reactivity as any).reviewed_by = 42; + expect(() => parseCrossReactivitySnapshot(snapReviewedByType)).toThrow( + /reviewed_by.*string/i + ); + }); + + it('allows last_reviewed and reviewed_by to be empty when under_review is true', () => { + const snap = createValidSnapshot(); + snap.cross_reactivity.under_review = true; + snap.cross_reactivity.last_reviewed = ''; + snap.cross_reactivity.reviewed_by = ''; + const result = parseCrossReactivitySnapshot(snap); + expect(result.governance.under_review).toBe(true); + expect(result.governance.last_reviewed).toBe(''); + expect(result.governance.reviewed_by).toBe(''); + }); + + it('requires last_reviewed and reviewed_by to be non-empty when under_review is false', () => { + const snap = createValidSnapshot(); + snap.cross_reactivity.under_review = false; + snap.cross_reactivity.last_reviewed = ''; + snap.cross_reactivity.reviewed_by = 'Test reviewer'; + expect(() => parseCrossReactivitySnapshot(snap)).toThrow( + /last_reviewed.*non-empty/i + ); + + snap.cross_reactivity.last_reviewed = ' '; + expect(() => parseCrossReactivitySnapshot(snap)).toThrow( + /last_reviewed.*non-empty/i + ); + + snap.cross_reactivity.last_reviewed = 'review-marker'; + snap.cross_reactivity.reviewed_by = ''; + expect(() => parseCrossReactivitySnapshot(snap)).toThrow( + /reviewed_by.*non-empty/i + ); + + snap.cross_reactivity.reviewed_by = ' '; + expect(() => parseCrossReactivitySnapshot(snap)).toThrow( + /reviewed_by.*non-empty/i + ); + + snap.cross_reactivity.reviewed_by = 'Test reviewer'; + const result = parseCrossReactivitySnapshot(snap); + expect(result.governance.under_review).toBe(false); + expect(result.governance.last_reviewed).toBe('review-marker'); + expect(result.governance.reviewed_by).toBe('Test reviewer'); + }); + + it('preserves exact strings without mutating or trimming returned governance values', () => { + const snap = createValidSnapshot(); + snap.cross_reactivity.version = ' 1.0 '; + snap.cross_reactivity.provenance = ' Provenance string with whitespace '; + snap.cross_reactivity.under_review = false; + snap.cross_reactivity.last_reviewed = ' review-marker '; + snap.cross_reactivity.reviewed_by = ' Test reviewer '; + const result = parseCrossReactivitySnapshot(snap); + expect(result.governance.version).toBe(' 1.0 '); + expect(result.governance.provenance).toBe(' Provenance string with whitespace '); + expect(result.governance.last_reviewed).toBe(' review-marker '); + expect(result.governance.reviewed_by).toBe(' Test reviewer '); + }); + }); + + describe('items validation', () => { + it('throws if cross_reactivity.items is missing or not an array', () => { + const snap = createValidSnapshot(); + (snap.cross_reactivity as any).items = 'not an array'; + expect(() => parseCrossReactivitySnapshot(snap)).toThrow( + /items.*array/i + ); + }); + + it('throws if cross_reactivity.items is empty', () => { + const snap = createValidSnapshot(); + snap.cross_reactivity.items = []; + expect(() => parseCrossReactivitySnapshot(snap)).toThrow( + /items.*empty/i + ); + }); + + it('throws if an item is null or not an object', () => { + const snap = createValidSnapshot(); + (snap.cross_reactivity as any).items = [null]; + expect(() => parseCrossReactivitySnapshot(snap)).toThrow( + /expected an object/i + ); + }); + + it('throws if an item is missing required fields or has empty fields', () => { + const snapMissing = createValidSnapshot(); + snapMissing.cross_reactivity.items = [{ category: 'Test category', info: '' } as any]; + expect(() => parseCrossReactivitySnapshot(snapMissing)).toThrow( + /malformed item/i + ); + + const snapEmptyCat = createValidSnapshot(); + snapEmptyCat.cross_reactivity.items = [ + { category: ' ', info: 'Test information', alternatives: 'Test alternative' }, + ]; + expect(() => parseCrossReactivitySnapshot(snapEmptyCat)).toThrow( + /malformed item/i + ); + + const snapEmptyAlt = createValidSnapshot(); + snapEmptyAlt.cross_reactivity.items = [ + { category: 'Test category', info: 'Test information', alternatives: ' ' }, + ]; + expect(() => parseCrossReactivitySnapshot(snapEmptyAlt)).toThrow( + /malformed item/i + ); + }); + }); +}); diff --git a/src/shared/data/crossReactivity.ts b/src/shared/data/crossReactivity.ts new file mode 100644 index 0000000..be0133f --- /dev/null +++ b/src/shared/data/crossReactivity.ts @@ -0,0 +1,129 @@ +import rawSnapshot from './protocols.snapshot.json'; + +export interface CrossReactivityItem { + readonly category: string; + readonly info: string; + readonly alternatives: string; +} + +export interface CrossReactivityGovernance { + readonly version: string; + readonly last_reviewed: string; + readonly reviewed_by: string; + readonly under_review: boolean; + readonly provenance: string; +} + +export interface ParsedCrossReactivity { + readonly governance: CrossReactivityGovernance; + readonly items: readonly CrossReactivityItem[]; +} + +export function parseCrossReactivitySnapshot(snapshot: unknown): ParsedCrossReactivity { + if (!snapshot || typeof snapshot !== 'object') { + throw new Error('Missing or invalid cross_reactivity: expected a non-null snapshot object.'); + } + + const raw = snapshot as Record; + if (typeof raw.schema_version !== 'string' || raw.schema_version !== '1.1') { + throw new Error( + `Unsupported or missing schema_version "${String(raw.schema_version)}". Cross-reactivity requires schema_version "1.1".` + ); + } + + if (!raw.cross_reactivity || typeof raw.cross_reactivity !== 'object') { + throw new Error('Missing or invalid cross_reactivity object in protocols snapshot.'); + } + + const cr = raw.cross_reactivity as Record; + + if (typeof cr.under_review !== 'boolean') { + throw new Error('Malformed cross_reactivity: under_review must be a boolean.'); + } + + if (typeof cr.version !== 'string') { + throw new Error('Malformed cross_reactivity: version must be a string.'); + } + if (!cr.version.trim()) { + throw new Error('Malformed cross_reactivity: version must be a non-empty string.'); + } + + if (typeof cr.provenance !== 'string') { + throw new Error('Malformed cross_reactivity: provenance must be a string.'); + } + if (!cr.provenance.trim()) { + throw new Error('Malformed cross_reactivity: provenance must be a non-empty string.'); + } + + if (typeof cr.last_reviewed !== 'string') { + throw new Error('Malformed cross_reactivity: last_reviewed must be a string.'); + } + if (!cr.under_review && !cr.last_reviewed.trim()) { + throw new Error( + 'Malformed cross_reactivity: last_reviewed must be a non-empty string when under_review is false.' + ); + } + + if (typeof cr.reviewed_by !== 'string') { + throw new Error('Malformed cross_reactivity: reviewed_by must be a string.'); + } + if (!cr.under_review && !cr.reviewed_by.trim()) { + throw new Error( + 'Malformed cross_reactivity: reviewed_by must be a non-empty string when under_review is false.' + ); + } + + if (!Array.isArray(cr.items)) { + throw new Error('Malformed cross_reactivity: items must be an array.'); + } + + if (cr.items.length === 0) { + throw new Error('Malformed cross_reactivity: items array is empty.'); + } + + const parsedItems: CrossReactivityItem[] = cr.items.map((item, index) => { + if (!item || typeof item !== 'object') { + throw new Error(`Malformed item at index ${index}: expected an object.`); + } + + const { category, info, alternatives } = item as Record; + + if ( + typeof category !== 'string' || + !category.trim() || + typeof info !== 'string' || + !info.trim() || + typeof alternatives !== 'string' || + !alternatives.trim() + ) { + throw new Error( + `Malformed item at index ${index}: category, info, and alternatives must be non-empty strings.` + ); + } + + return { + category, + info, + alternatives, + }; + }); + + const governance: CrossReactivityGovernance = { + version: cr.version, + last_reviewed: cr.last_reviewed, + reviewed_by: cr.reviewed_by, + under_review: cr.under_review, + provenance: cr.provenance, + }; + + return { + governance, + items: parsedItems, + }; +} + +// Parse pinned snapshot at module load; fail loudly if missing or malformed +const parsed = parseCrossReactivitySnapshot(rawSnapshot); + +export const CROSS_REACTIVITY_ITEMS: readonly CrossReactivityItem[] = parsed.items; +export const CROSS_REACTIVITY_GOVERNANCE: Readonly = parsed.governance; diff --git a/src/shared/data/protocols.snapshot.json b/src/shared/data/protocols.snapshot.json index 469959a..1807879 100644 --- a/src/shared/data/protocols.snapshot.json +++ b/src/shared/data/protocols.snapshot.json @@ -1,7 +1,7 @@ { - "schema_version": "1.0", - "generated_at": "2026-08-20T11:45:36Z", - "source_commit": "254b399", + "schema_version": "1.1", + "generated_at": "2026-08-20T20:20:12Z", + "source_commit": "d9bc1e3", "drugs": [ { "slug": "cefazolin", @@ -255,5 +255,44 @@ } ] } - ] + ], + "cross_reactivity": { + "version": "1.0", + "last_reviewed": "", + "reviewed_by": "", + "under_review": true, + "provenance": "Transcribed unchanged from DREAM; awaits clinical sign-off.", + "items": [ + { + "category": "Muscle Relaxants", + "info": "Quaternary ammonium compounds are thought to be responsible for most reactions. Cross-reactivity between agents is common due to structural similarities.", + "alternatives": "Consider non-depolarising agents with different structures. Cisatracurium may have lower immunogenicity." + }, + { + "category": "Penicillins", + "info": "Cross-reactivity with cephalosporins depends on side chain similarity. First-generation cephalosporins have higher cross-reactivity (~2%).", + "alternatives": "Cephalosporins with dissimilar side chains, carbapenems (low cross-reactivity), or non-beta-lactams." + }, + { + "category": "Cephalosporins", + "info": "Cross-reactivity is more related to R1 side chain similarity than the beta-lactam ring. Later generations have different side chains.", + "alternatives": "Cephalosporins with different side chains, carbapenems, aztreonam (minimal cross-reactivity)." + }, + { + "category": "Local Anaesthetics", + "info": "True allergy is rare (<1%). Most reactions are vasovagal or due to adrenaline. Amide types rarely cross-react with each other.", + "alternatives": "Different amide local anaesthetic, or ester type if amide allergy confirmed." + }, + { + "category": "Opioids", + "info": "Can cause direct mast cell degranulation (non-IgE mediated). Morphine and codeine are most histamine-releasing.", + "alternatives": "Fentanyl or remifentanil (less histamine release), or non-opioid analgesia." + }, + { + "category": "Hypnotics", + "info": "Propofol reactions may be to the lipid emulsion or specific to propofol. Egg/soya allergy is not a contraindication.", + "alternatives": "Different induction agent (thiopentone, ketamine, etomidate)." + } + ] + } }