feat: add medication enrichment lookup to the medication editor

* feat: add medication enrichment lookup

* fix: avoid double unescape in enrichment sanitization

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Daniel Volz
2026-03-20 20:39:38 +01:00
committed by GitHub
parent e1b47e82b2
commit b796e03bcb
16 changed files with 3510 additions and 2 deletions
+349 -1
View File
@@ -11,13 +11,23 @@ import {
FormNumberStepper,
Lightbox,
MedicationAvatar,
MedicationEnrichmentSection,
MobileEditModal,
ReportModal,
} from "../components";
import { useAuth } from "../components/Auth";
import { useAppContext, useUnsavedChanges } from "../context";
import { useMedicationForm, useModalHistory, useUnsavedChangesWarning } from "../hooks";
import type { DoseUnit, FormState, Medication, PackageType } from "../types";
import type {
DoseUnit,
FormState,
Medication,
MedicationEnrichmentEnrichResponse,
MedicationEnrichmentSearchResponse,
MedicationEnrichmentSearchResult,
MedicationEnrichmentStrengthOption,
PackageType,
} from "../types";
import {
allowsPillFormSelection,
DOSE_UNITS,
@@ -49,6 +59,113 @@ function userStorageKey(userId: number | undefined, key: string): string {
}
const OBSOLETE_SECTION_STORAGE_KEY = "medicationsShowObsolete";
const MEDICATION_ENRICHMENT_INITIAL_LIMIT = 6;
const MEDICATION_ENRICHMENT_LIMIT_STEP = 6;
const MEDICATION_ENRICHMENT_MAX_LIMIT = 20;
type MedicationEnrichmentState = {
query: string;
results: MedicationEnrichmentSearchResult[];
hasMoreResults: boolean;
resultLimit: number;
isSearching: boolean;
hasSearched: boolean;
searchError: string | null;
applyingCode: string | null;
activeResultCode: string | null;
appliedSelection: MedicationEnrichmentEnrichResponse["selection"] | null;
enrichError: string | null;
meta: MedicationEnrichmentEnrichResponse["meta"] | null;
strengthOptions: MedicationEnrichmentStrengthOption[];
appliedStrengthLabel: string | null;
};
function createMedicationEnrichmentState(
query = "",
resultLimit = MEDICATION_ENRICHMENT_INITIAL_LIMIT
): MedicationEnrichmentState {
return {
query,
results: [],
hasMoreResults: false,
resultLimit,
isSearching: false,
hasSearched: false,
searchError: null,
applyingCode: null,
activeResultCode: null,
appliedSelection: null,
enrichError: null,
meta: null,
strengthOptions: [],
appliedStrengthLabel: null,
};
}
function normalizeMedicationEnrichmentDoseUnit(unit: MedicationEnrichmentStrengthOption["doseUnit"]): DoseUnit | null {
if (unit === "IU") return "units";
if (unit === "mg" || unit === "g" || unit === "mcg" || unit === "ml" || unit === "units") return unit;
return null;
}
function applyMedicationEnrichmentSuggestions(
form: FormState,
suggestions: MedicationEnrichmentEnrichResponse["suggestions"]
): FormState {
const nextForm: FormState = {
...form,
name: suggestions.name,
genericName: suggestions.genericName ?? "",
};
if (suggestions.medicationForm === "tablet" || suggestions.medicationForm === "capsule") {
return {
...nextForm,
medicationForm: suggestions.medicationForm,
pillForm: suggestions.medicationForm,
};
}
if (suggestions.medicationForm === "liquid" || suggestions.medicationForm === "topical") {
return {
...nextForm,
medicationForm: suggestions.medicationForm,
};
}
return nextForm;
}
function applyMedicationEnrichmentStrength(
form: FormState,
option: MedicationEnrichmentStrengthOption
): FormState | null {
if (option.pillWeightMg === null) return null;
const doseUnit = normalizeMedicationEnrichmentDoseUnit(option.doseUnit);
if (!doseUnit) return null;
return {
...form,
pillWeightMg: `${option.pillWeightMg}`,
doseUnit,
};
}
async function getMedicationEnrichmentErrorMessage(response: Response, fallback: string): Promise<string> {
try {
const errorBody = (await response.json()) as { error?: string; message?: string };
if (typeof errorBody?.error === "string" && errorBody.error.trim().length > 0) {
return errorBody.error;
}
if (typeof errorBody?.message === "string" && errorBody.message.trim().length > 0) {
return errorBody.message;
}
} catch {
// keep translated fallback
}
return fallback;
}
export function MedicationsPage() {
const [searchParams, setSearchParams] = useSearchParams();
@@ -162,6 +279,88 @@ export function MedicationsPage() {
const [obsoleteCandidate, setObsoleteCandidate] = useState<Medication | null>(null);
const [allMeds, setAllMeds] = useState<Medication[]>(meds);
const [imageUploadError, setImageUploadError] = useState<string | null>(null);
const [medicationEnrichment, setMedicationEnrichment] = useState<MedicationEnrichmentState>(() =>
createMedicationEnrichmentState()
);
const resetMedicationEnrichment = useCallback((query = "") => {
setMedicationEnrichment(createMedicationEnrichmentState(query));
}, []);
const handleMedicationEnrichmentQueryChange = useCallback((value: string) => {
setMedicationEnrichment((previous) => ({
...previous,
query: value,
}));
}, []);
const performMedicationEnrichmentSearch = useCallback(
async (requestedLimit: number, preserveExistingResults = false) => {
const trimmedQuery = medicationEnrichment.query.trim();
if (!trimmedQuery) return;
const limit = Math.min(requestedLimit, MEDICATION_ENRICHMENT_MAX_LIMIT);
setMedicationEnrichment((previous) => ({
...previous,
query: trimmedQuery,
results: preserveExistingResults ? previous.results : [],
hasMoreResults: false,
resultLimit: limit,
isSearching: true,
hasSearched: preserveExistingResults ? previous.hasSearched : false,
searchError: null,
applyingCode: null,
...(preserveExistingResults
? {}
: {
activeResultCode: null,
appliedSelection: null,
enrichError: null,
meta: null,
strengthOptions: [],
appliedStrengthLabel: null,
}),
}));
try {
const params = new URLSearchParams({ q: trimmedQuery, limit: String(limit) });
const response = await fetch(`/api/medication-enrichment/search?${params.toString()}`, {
credentials: "include",
});
if (!response.ok) {
throw new Error(await getMedicationEnrichmentErrorMessage(response, t("form.enrichment.searchError")));
}
const data = (await response.json()) as MedicationEnrichmentSearchResponse;
setMedicationEnrichment((previous) => ({
...previous,
query: data.query,
results: Array.isArray(data.results) ? data.results : [],
hasMoreResults: Boolean(data.hasMore),
resultLimit: limit,
isSearching: false,
hasSearched: true,
searchError: null,
}));
} catch (error) {
const message =
error instanceof Error && error.message.trim().length > 0 ? error.message : t("form.enrichment.searchError");
setMedicationEnrichment((previous) => ({
...previous,
results: preserveExistingResults ? previous.results : [],
hasMoreResults: false,
resultLimit: limit,
isSearching: false,
hasSearched: true,
searchError: message,
}));
}
},
[medicationEnrichment.query, t]
);
const handlePendingMedicationImageSelection = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
@@ -258,6 +457,126 @@ export function MedicationsPage() {
[deleteMedImage, loadAllMeds]
);
const applicableMedicationEnrichmentStrengthOptions = useMemo(() => {
if (!allowsPillFormSelection(form.packageType)) return [];
return medicationEnrichment.strengthOptions.filter(
(option) => option.pillWeightMg !== null && normalizeMedicationEnrichmentDoseUnit(option.doseUnit) !== null
);
}, [form.packageType, medicationEnrichment.strengthOptions]);
const handleMedicationEnrichmentSearch = useCallback(async () => {
await performMedicationEnrichmentSearch(MEDICATION_ENRICHMENT_INITIAL_LIMIT);
}, [performMedicationEnrichmentSearch]);
const handleMedicationEnrichmentLoadMore = useCallback(async () => {
if (medicationEnrichment.isSearching || !medicationEnrichment.hasMoreResults) return;
await performMedicationEnrichmentSearch(
Math.min(medicationEnrichment.resultLimit + MEDICATION_ENRICHMENT_LIMIT_STEP, MEDICATION_ENRICHMENT_MAX_LIMIT),
true
);
}, [
medicationEnrichment.hasMoreResults,
medicationEnrichment.isSearching,
medicationEnrichment.resultLimit,
performMedicationEnrichmentSearch,
]);
const handleMedicationEnrichmentApply = useCallback(
async (result: MedicationEnrichmentSearchResult) => {
setMedicationEnrichment((previous) => ({
...previous,
applyingCode: result.code,
activeResultCode: result.code,
enrichError: null,
appliedSelection: null,
meta: null,
strengthOptions: [],
appliedStrengthLabel: null,
}));
try {
const response = await fetch("/api/medication-enrichment/enrich", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: medicationEnrichment.query.trim() || result.name,
name: result.name,
genericName: result.genericName ?? null,
code: result.code,
source: result.source,
}),
credentials: "include",
});
if (!response.ok) {
throw new Error(await getMedicationEnrichmentErrorMessage(response, t("form.enrichment.applyError")));
}
const data = (await response.json()) as MedicationEnrichmentEnrichResponse;
let nextForm = applyMedicationEnrichmentSuggestions(form, data.suggestions);
let appliedStrengthLabel: string | null = null;
if (allowsPillFormSelection(nextForm.packageType) && data.suggestions.strengthOptions.length === 1) {
const autoAppliedForm = applyMedicationEnrichmentStrength(nextForm, data.suggestions.strengthOptions[0]);
if (autoAppliedForm) {
nextForm = autoAppliedForm;
appliedStrengthLabel = data.suggestions.strengthOptions[0].label;
}
}
setForm(nextForm);
setMedicationEnrichment((previous) => ({
...previous,
applyingCode: null,
activeResultCode: result.code,
appliedSelection: data.selection,
enrichError: null,
meta: data.meta,
strengthOptions: data.suggestions.strengthOptions,
appliedStrengthLabel,
}));
} catch (error) {
const message =
error instanceof Error && error.message.trim().length > 0 ? error.message : t("form.enrichment.applyError");
setMedicationEnrichment((previous) => ({
...previous,
applyingCode: null,
activeResultCode: null,
appliedSelection: null,
enrichError: message,
meta: null,
strengthOptions: [],
appliedStrengthLabel: null,
}));
}
},
[form, medicationEnrichment.query, setForm, t]
);
const handleMedicationEnrichmentStrengthApply = useCallback(
(option: MedicationEnrichmentStrengthOption) => {
setForm((currentForm) => {
const nextForm = applyMedicationEnrichmentStrength(currentForm, option);
return nextForm ?? currentForm;
});
setMedicationEnrichment((previous) => ({
...previous,
appliedStrengthLabel: option.label,
}));
},
[setForm]
);
const medicationEnrichmentViewModel = useMemo(
() => ({
...medicationEnrichment,
strengthOptions: applicableMedicationEnrichmentStrengthOptions,
}),
[applicableMedicationEnrichmentStrengthOptions, medicationEnrichment]
);
// Calculate total tablets
const totalTablets = useMemo(() => {
if (isAmountBasedPackageType(form.packageType)) {
@@ -416,12 +735,14 @@ export function MedicationsPage() {
if (pendingAction) {
// There's a pending action (e.g. switching to another medication) — reset and run it
resetForm();
resetMedicationEnrichment();
setReadOnlyView(false);
pendingAction();
} else if (source === "mobile-edit" && showEditModal) {
clearEditMedIdParam();
setShowEditModal(false);
resetForm();
resetMedicationEnrichment();
setReadOnlyView(false);
window.history.back();
} else {
@@ -449,6 +770,7 @@ export function MedicationsPage() {
window.history.back();
}
resetForm();
resetMedicationEnrichment();
setShowNameValidation(false);
setActiveTab("general");
setReadOnlyView(false);
@@ -710,11 +1032,13 @@ export function MedicationsPage() {
setActiveTab("general");
setViewMode("grid");
resetForm();
resetMedicationEnrichment();
window.history.back();
setSaving(false);
return;
}
resetForm();
resetMedicationEnrichment();
setViewMode("grid");
} else {
// Update originalForm so formChanged becomes false
@@ -758,6 +1082,7 @@ export function MedicationsPage() {
if (showEditModal) {
setShowEditModal(false);
resetForm();
resetMedicationEnrichment();
}
return;
}
@@ -780,6 +1105,7 @@ export function MedicationsPage() {
clearEditMedIdParam();
setShowEditModal(false);
resetForm();
resetMedicationEnrichment();
return;
}
@@ -793,6 +1119,7 @@ export function MedicationsPage() {
}
hasDesktopFormHistoryState.current = false;
resetForm();
resetMedicationEnrichment();
setShowNameValidation(false);
setActiveTab("general");
setReadOnlyView(false);
@@ -836,6 +1163,7 @@ export function MedicationsPage() {
pendingActionRef.current = () => {
setShowNameValidation(false);
setReadOnlyView(false);
resetMedicationEnrichment(med.name || med.genericName || "");
startEdit(med, openEditModal);
setViewMode("form");
scrollToTopForDesktopEdit();
@@ -847,6 +1175,7 @@ export function MedicationsPage() {
setShowNameValidation(false);
setReadOnlyView(false);
setActiveTab("general");
resetMedicationEnrichment(med.name || med.genericName || "");
startEdit(med, openEditModal);
setViewMode("form");
scrollToTopForDesktopEdit();
@@ -857,6 +1186,7 @@ export function MedicationsPage() {
pendingActionRef.current = () => {
setShowNameValidation(false);
setReadOnlyView(true);
resetMedicationEnrichment(med.name || med.genericName || "");
startEdit(med, openEditModal);
setViewMode("form");
scrollToTopForDesktopEdit();
@@ -868,6 +1198,7 @@ export function MedicationsPage() {
setShowNameValidation(false);
setReadOnlyView(true);
setActiveTab("general");
resetMedicationEnrichment(med.name || med.genericName || "");
startEdit(med, openEditModal);
setViewMode("form");
scrollToTopForDesktopEdit();
@@ -877,6 +1208,7 @@ export function MedicationsPage() {
if (formChanged) {
pendingActionRef.current = () => {
resetForm();
resetMedicationEnrichment();
setShowNameValidation(false);
setReadOnlyView(false);
if (window.innerWidth <= 768) {
@@ -890,6 +1222,7 @@ export function MedicationsPage() {
return;
}
resetForm();
resetMedicationEnrichment();
setShowNameValidation(false);
setReadOnlyView(false);
if (window.innerWidth <= 768) {
@@ -932,6 +1265,7 @@ export function MedicationsPage() {
setShowNameValidation(false);
setReadOnlyView(false);
setActiveTab("general");
resetMedicationEnrichment(medicationToEdit.name || medicationToEdit.genericName || "");
startEdit(medicationToEdit, openEditModal);
setViewMode("form");
scrollToTopForDesktopEdit();
@@ -1280,6 +1614,14 @@ export function MedicationsPage() {
<span className="field-error">{fieldErrors.genericName}</span>
)}
</label>
<MedicationEnrichmentSection
state={medicationEnrichmentViewModel}
onQueryChange={handleMedicationEnrichmentQueryChange}
onSearch={handleMedicationEnrichmentSearch}
onLoadMoreResults={handleMedicationEnrichmentLoadMore}
onApplyResult={handleMedicationEnrichmentApply}
onApplyStrength={handleMedicationEnrichmentStrengthApply}
/>
<div className="full date-pair-group">
<label className="date-pair-field">
{t("form.medicationStartDate")}
@@ -1938,6 +2280,12 @@ export function MedicationsPage() {
editingId={editingId}
form={form}
onFormChange={setForm}
medicationEnrichment={medicationEnrichmentViewModel}
onMedicationEnrichmentQueryChange={handleMedicationEnrichmentQueryChange}
onMedicationEnrichmentSearch={handleMedicationEnrichmentSearch}
onMedicationEnrichmentLoadMore={handleMedicationEnrichmentLoadMore}
onMedicationEnrichmentApply={handleMedicationEnrichmentApply}
onMedicationEnrichmentStrengthApply={handleMedicationEnrichmentStrengthApply}
fieldErrors={fieldErrors}
saving={saving}
formSaved={formSaved}