mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(medications): add optional ATC + RxNorm codes to the medication row
Add two nullable, additive columns to `Medication` (`atc_code`,
`rxnorm_code`) so a user or clinician can assert a drug-classification
code that the health-record FHIR export emits on the medication concept.
Codes are taken verbatim, never machine-guessed: a wrong code is a
clinical-safety problem, an absent one is merely a (conformant)
text-only concept.
- Migration 0103 — additive, nullable, no backfill. Pre-existing rows
read NULL, which the exporter already treats as "no code".
- Validate on write — ATC `^[A-Z]\d{2}[A-Z]{2}\d{2}$`, RxCUI `^\d+$`;
422 on a malformed value. Wired field-by-field through the create +
update routes (no mass assignment); `null` clears a code on update.
- Surface both codes in the medication advanced-settings sheet's Data
group with a self-saving input, and echo them on the GET/POST/PUT
response shape. OpenAPI regenerated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 2e0051d42826fa3f043c32e78a34dab7c8050165)
This commit is contained in:
1 parent
eff7b42348
commit
0a8c6e307c
18 files changed
+476
No files matched your search
@@ -2382,6 +2382,22 @@ components:
|
||||
description: iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute switch /
|
||||
Focus; the server stores the preference only and hangs no server-side behaviour off it.
|
||||
type: boolean
|
||||
atcCode:
|
||||
description: Optional WHO ATC classification code (active-substance class, 7 chars, e.g. `A10BX10`).
|
||||
User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under
|
||||
`http://www.whocc.no/atc`. NULL clears it; absent leaves it untouched.
|
||||
anyOf:
|
||||
- type: string
|
||||
pattern: ^[A-Z]\d{2}[A-Z]{2}\d{2}$
|
||||
- type: "null"
|
||||
rxNormCode:
|
||||
description: Optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary coding emitted under
|
||||
`http://www.nlm.nih.gov/research/umls/rxnorm` alongside any ATC code, never instead of the free-text name.
|
||||
NULL clears it; absent leaves it untouched.
|
||||
anyOf:
|
||||
- type: string
|
||||
pattern: ^\d+$
|
||||
- type: "null"
|
||||
startsOn:
|
||||
description: Date the medication course begins (ISO `YYYY-MM-DD`). Anchors RRULE BYDAY / BYMONTHDAY patterns and the
|
||||
rolling-interval countdown's first window. NULL = active from creation (the legacy implicit behaviour).
|
||||
@@ -2584,6 +2600,22 @@ components:
|
||||
description: iOS 26 AlarmKit critical-reminder opt-in. Critical alarms bypass the device mute switch / Focus; the server
|
||||
stores the preference only.
|
||||
type: boolean
|
||||
atcCode:
|
||||
description: Optional WHO ATC classification code (active-substance class, 7 chars, e.g. `A10BX10`).
|
||||
User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under
|
||||
`http://www.whocc.no/atc`. NULL clears it; absent leaves it untouched.
|
||||
anyOf:
|
||||
- type: string
|
||||
pattern: ^[A-Z]\d{2}[A-Z]{2}\d{2}$
|
||||
- type: "null"
|
||||
rxNormCode:
|
||||
description: Optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary coding emitted under
|
||||
`http://www.nlm.nih.gov/research/umls/rxnorm` alongside any ATC code, never instead of the free-text name.
|
||||
NULL clears it; absent leaves it untouched.
|
||||
anyOf:
|
||||
- type: string
|
||||
pattern: ^\d+$
|
||||
- type: "null"
|
||||
startsOn:
|
||||
description: Date the medication course begins (ISO `YYYY-MM-DD`). Anchors RRULE BYDAY / BYMONTHDAY patterns and the
|
||||
rolling-interval countdown's first window. NULL = active from creation (the legacy implicit behaviour).
|
||||
@@ -4189,6 +4221,19 @@ components:
|
||||
type: boolean
|
||||
description: v1.7.0 iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute
|
||||
switch / Focus; the server stores the preference only.
|
||||
atcCode:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: v1.9.0 optional WHO ATC classification code (active-substance class, e.g. `A10BX10`).
|
||||
User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under
|
||||
`http://www.whocc.no/atc`. NULL = no code captured.
|
||||
rxNormCode:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: v1.9.0 optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary FHIR coding under
|
||||
`http://www.nlm.nih.gov/research/umls/rxnorm`, alongside any ATC code. NULL = no code captured.
|
||||
pausedAt:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -4267,6 +4312,8 @@ components:
|
||||
- notificationsEnabled
|
||||
- liveActivityEnabled
|
||||
- criticalAlarmEnabled
|
||||
- atcCode
|
||||
- rxNormCode
|
||||
- pausedAt
|
||||
- snoozedUntil
|
||||
- nextDueAt
|
||||
@@ -4349,6 +4396,19 @@ components:
|
||||
type: boolean
|
||||
description: v1.7.0 iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute
|
||||
switch / Focus; the server stores the preference only.
|
||||
atcCode:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: v1.9.0 optional WHO ATC classification code (active-substance class, e.g. `A10BX10`).
|
||||
User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under
|
||||
`http://www.whocc.no/atc`. NULL = no code captured.
|
||||
rxNormCode:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: v1.9.0 optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary FHIR coding under
|
||||
`http://www.nlm.nih.gov/research/umls/rxnorm`, alongside any ATC code. NULL = no code captured.
|
||||
pausedAt:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -4414,6 +4474,8 @@ components:
|
||||
- notificationsEnabled
|
||||
- liveActivityEnabled
|
||||
- criticalAlarmEnabled
|
||||
- atcCode
|
||||
- rxNormCode
|
||||
- pausedAt
|
||||
- snoozedUntil
|
||||
- nextDueAt
|
||||
@@ -6803,6 +6865,19 @@ components:
|
||||
type: boolean
|
||||
description: v1.7.0 iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute
|
||||
switch / Focus; the server stores the preference only.
|
||||
atcCode:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: v1.9.0 optional WHO ATC classification code (active-substance class, e.g. `A10BX10`).
|
||||
User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under
|
||||
`http://www.whocc.no/atc`. NULL = no code captured.
|
||||
rxNormCode:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: v1.9.0 optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary FHIR coding under
|
||||
`http://www.nlm.nih.gov/research/umls/rxnorm`, alongside any ATC code. NULL = no code captured.
|
||||
pausedAt:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -6866,6 +6941,8 @@ components:
|
||||
- notificationsEnabled
|
||||
- liveActivityEnabled
|
||||
- criticalAlarmEnabled
|
||||
- atcCode
|
||||
- rxNormCode
|
||||
- pausedAt
|
||||
- snoozedUntil
|
||||
- nextDueAt
|
||||
|
||||
@@ -1214,6 +1214,14 @@
|
||||
"unit": "Minuten",
|
||||
"saved": "Erinnerungsfenster gespeichert",
|
||||
"failed": "Erinnerungsfenster konnte nicht gespeichert werden."
|
||||
},
|
||||
"codes": {
|
||||
"label": "Klinische Codes (ATC / RxNorm)",
|
||||
"note": "Optional. Werden beim FHIR-Export der Gesundheitsakte verwendet. Codes werden genau wie eingegeben übernommen — nie geraten.",
|
||||
"atcLabel": "ATC",
|
||||
"rxNormLabel": "RxNorm",
|
||||
"saved": "Klinische Codes gespeichert",
|
||||
"failed": "Klinische Codes konnten nicht gespeichert werden."
|
||||
}
|
||||
},
|
||||
"zone": {
|
||||
|
||||
@@ -1214,6 +1214,14 @@
|
||||
"unit": "minutes",
|
||||
"saved": "Reminder window saved",
|
||||
"failed": "Could not save the reminder window."
|
||||
},
|
||||
"codes": {
|
||||
"label": "Clinical codes (ATC / RxNorm)",
|
||||
"note": "Optional. Used on the FHIR health-record export. Codes are taken exactly as entered — never guessed.",
|
||||
"atcLabel": "ATC",
|
||||
"rxNormLabel": "RxNorm",
|
||||
"saved": "Clinical codes saved",
|
||||
"failed": "Could not save the clinical codes."
|
||||
}
|
||||
},
|
||||
"zone": {
|
||||
|
||||
@@ -1214,6 +1214,14 @@
|
||||
"unit": "minutes",
|
||||
"saved": "Reminder window saved",
|
||||
"failed": "Could not save the reminder window."
|
||||
},
|
||||
"codes": {
|
||||
"label": "Códigos clínicos (ATC / RxNorm)",
|
||||
"note": "Opcional. Se usan en la exportación FHIR del historial de salud. Los códigos se toman tal cual se introducen — nunca se adivinan.",
|
||||
"atcLabel": "ATC",
|
||||
"rxNormLabel": "RxNorm",
|
||||
"saved": "Códigos clínicos guardados",
|
||||
"failed": "No se pudieron guardar los códigos clínicos."
|
||||
}
|
||||
},
|
||||
"zone": {
|
||||
|
||||
@@ -1214,6 +1214,14 @@
|
||||
"unit": "minutes",
|
||||
"saved": "Reminder window saved",
|
||||
"failed": "Could not save the reminder window."
|
||||
},
|
||||
"codes": {
|
||||
"label": "Codes cliniques (ATC / RxNorm)",
|
||||
"note": "Facultatif. Utilisés lors de l'export FHIR du dossier de santé. Les codes sont repris tels qu'ils sont saisis — jamais devinés.",
|
||||
"atcLabel": "ATC",
|
||||
"rxNormLabel": "RxNorm",
|
||||
"saved": "Codes cliniques enregistrés",
|
||||
"failed": "Impossible d'enregistrer les codes cliniques."
|
||||
}
|
||||
},
|
||||
"zone": {
|
||||
|
||||
@@ -1214,6 +1214,14 @@
|
||||
"unit": "minutes",
|
||||
"saved": "Reminder window saved",
|
||||
"failed": "Could not save the reminder window."
|
||||
},
|
||||
"codes": {
|
||||
"label": "Codici clinici (ATC / RxNorm)",
|
||||
"note": "Facoltativo. Usati nell'esportazione FHIR della cartella sanitaria. I codici vengono presi esattamente come inseriti — mai indovinati.",
|
||||
"atcLabel": "ATC",
|
||||
"rxNormLabel": "RxNorm",
|
||||
"saved": "Codici clinici salvati",
|
||||
"failed": "Impossibile salvare i codici clinici."
|
||||
}
|
||||
},
|
||||
"zone": {
|
||||
|
||||
@@ -1214,6 +1214,14 @@
|
||||
"unit": "minutes",
|
||||
"saved": "Reminder window saved",
|
||||
"failed": "Could not save the reminder window."
|
||||
},
|
||||
"codes": {
|
||||
"label": "Kody kliniczne (ATC / RxNorm)",
|
||||
"note": "Opcjonalne. Używane w eksporcie FHIR dokumentacji zdrowotnej. Kody są zapisywane dokładnie tak, jak wprowadzone — nigdy nie są zgadywane.",
|
||||
"atcLabel": "ATC",
|
||||
"rxNormLabel": "RxNorm",
|
||||
"saved": "Kody kliniczne zapisane",
|
||||
"failed": "Nie udało się zapisać kodów klinicznych."
|
||||
}
|
||||
},
|
||||
"zone": {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
-- v1.9.0 — optional drug-classification codes on the medication row.
|
||||
--
|
||||
-- Two additive, nullable, NULL-defaulted columns on `medications`:
|
||||
--
|
||||
-- * `atc_code` — the WHO ATC active-substance class (e.g. "A10BX10").
|
||||
-- Emitted on the FHIR `MedicationStatement` /
|
||||
-- `MedicationAdministration` `medicationCodeableConcept`
|
||||
-- under the system URI `http://www.whocc.no/atc`.
|
||||
-- * `rxnorm_code` — the RxNorm RxCUI (US identifier). Secondary coding
|
||||
-- under `http://www.nlm.nih.gov/research/umls/rxnorm`.
|
||||
--
|
||||
-- Both are user/clinician-asserted, never machine-guessed: a wrong code
|
||||
-- is a clinical-safety problem, an absent code is merely a (conformant)
|
||||
-- text-only concept. The application validates the format on write
|
||||
-- (ATC `^[A-Z]\d{2}[A-Z]{2}\d{2}$`, RxCUI `^\d+$`) and 422s on a
|
||||
-- malformed value; the DB column is a plain nullable TEXT field.
|
||||
--
|
||||
-- No backfill — pre-v1.9.0 rows read NULL, which the exporter treats
|
||||
-- identically to an absent value (the medicationCodeableConcept stays
|
||||
-- text-only, the pre-v1.9.0 shape).
|
||||
--
|
||||
-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only.
|
||||
--
|
||||
-- Reversibility:
|
||||
-- ALTER TABLE "medications" DROP COLUMN IF EXISTS "atc_code";
|
||||
-- ALTER TABLE "medications" DROP COLUMN IF EXISTS "rxnorm_code";
|
||||
-- A roll-back loses the captured codes; the FHIR export falls back to the
|
||||
-- text-only medicationCodeableConcept and the application reads tolerate a
|
||||
-- missing column / NULL value.
|
||||
--
|
||||
-- No index — reads are per-user at request time (`WHERE user_id = $1`),
|
||||
-- matching the other display-identity columns on the same table.
|
||||
|
||||
ALTER TABLE "medications"
|
||||
ADD COLUMN IF NOT EXISTS "atc_code" TEXT;
|
||||
|
||||
ALTER TABLE "medications"
|
||||
ADD COLUMN IF NOT EXISTS "rxnorm_code" TEXT;
|
||||
@@ -957,6 +957,18 @@ model Medication {
|
||||
/// stores the preference only — it does not itself authorise anything
|
||||
/// server-side.
|
||||
criticalAlarmEnabled Boolean @default(false) @map("critical_alarm_enabled")
|
||||
/// v1.9.0 — optional ATC classification code (active-substance class,
|
||||
/// e.g. `A10BX10`). User/clinician-asserted; never machine-guessed on
|
||||
/// the export path. NULL = no code (the FHIR exporter emits a text-only
|
||||
/// `medicationCodeableConcept`, the pre-v1.9.0 behaviour). The WHO URI
|
||||
/// `http://www.whocc.no/atc` is the emitted system. Validated on write
|
||||
/// against `^[A-Z]\d{2}[A-Z]{2}\d{2}$`.
|
||||
atcCode String? @map("atc_code")
|
||||
/// v1.9.0 — optional RxNorm RxCUI (US; secondary coding). Same NULL
|
||||
/// semantics as `atcCode`. Validated on write against `^\d+$`. Emitted
|
||||
/// under `http://www.nlm.nih.gov/research/umls/rxnorm` alongside any
|
||||
/// ATC code, never instead of the free-text `.text` anchor.
|
||||
rxNormCode String? @map("rxnorm_code")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
|
||||
@@ -136,6 +136,8 @@ export const PUT = apiHandler(
|
||||
notificationsEnabled,
|
||||
liveActivityEnabled,
|
||||
criticalAlarmEnabled,
|
||||
atcCode,
|
||||
rxNormCode,
|
||||
schedules,
|
||||
startsOn,
|
||||
endsOn,
|
||||
@@ -234,6 +236,10 @@ export const PUT = apiHandler(
|
||||
// v1.7.0 — iOS reminder flags, field-by-field.
|
||||
...(liveActivityEnabled !== undefined && { liveActivityEnabled }),
|
||||
...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }),
|
||||
// v1.9.0 — optional drug-classification codes (ATC / RxNorm).
|
||||
// Field-by-field; `null` is a valid explicit value (clears the code).
|
||||
...(atcCode !== undefined && { atcCode }),
|
||||
...(rxNormCode !== undefined && { rxNormCode }),
|
||||
// v1.5 scheduling primitives — pass-through when supplied.
|
||||
// `startsOn` / `endsOn` are `Date | null | undefined` (the
|
||||
// schema lets the user clear them explicitly with null).
|
||||
|
||||
@@ -160,6 +160,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
notificationsEnabled,
|
||||
liveActivityEnabled,
|
||||
criticalAlarmEnabled,
|
||||
atcCode,
|
||||
rxNormCode,
|
||||
schedules,
|
||||
startsOn,
|
||||
endsOn,
|
||||
@@ -228,6 +230,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// v1.7.0 — iOS reminder flags; Prisma defaults both to false.
|
||||
...(liveActivityEnabled !== undefined && { liveActivityEnabled }),
|
||||
...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }),
|
||||
// v1.9.0 — optional drug-classification codes (ATC / RxNorm).
|
||||
// Validated for format by Zod; stored verbatim when present.
|
||||
...(atcCode !== undefined && { atcCode }),
|
||||
...(rxNormCode !== undefined && { rxNormCode }),
|
||||
// v1.5 — wizard's reminders toggle now ships through the create
|
||||
// payload (was orphaned in the initial diff). Prisma defaults to
|
||||
// true when omitted, matching the legacy form's behaviour.
|
||||
|
||||
@@ -64,6 +64,10 @@ interface Medication {
|
||||
endsOn?: string | null;
|
||||
/** v1.5 — single-administration medication. */
|
||||
oneShot?: boolean;
|
||||
/** v1.9.0 — optional WHO ATC classification code for the FHIR export. */
|
||||
atcCode?: string | null;
|
||||
/** v1.9.0 — optional RxNorm RxCUI (secondary FHIR coding). */
|
||||
rxNormCode?: string | null;
|
||||
schedules: Schedule[];
|
||||
}
|
||||
|
||||
@@ -357,6 +361,8 @@ export default function MedicationsPage() {
|
||||
advancedMed.schedules[0]?.reminderGraceMinutes ?? null
|
||||
}
|
||||
intakeCount={advancedIntakeCount ?? 0}
|
||||
atcCode={advancedMed.atcCode}
|
||||
rxNormCode={advancedMed.rxNormCode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -54,6 +54,7 @@ vi.mock("@/components/medications/sections/notifications-section", () => ({
|
||||
|
||||
vi.mock("@/components/medications/sections/settings-section", () => ({
|
||||
GraceRow: () => <div data-slot="mock-grace" />,
|
||||
DrugCodingRow: () => <div data-slot="mock-drug-coding" />,
|
||||
PhasesRow: ({
|
||||
onRequestPhaseSheet,
|
||||
}: {
|
||||
|
||||
@@ -41,6 +41,7 @@ import { ApiTokensRow } from "@/components/medications/sections/api-tokens-row";
|
||||
import { DataPortabilityRow } from "@/components/medications/sections/data-portability-row";
|
||||
import { NotificationsBody } from "@/components/medications/sections/notifications-section";
|
||||
import {
|
||||
DrugCodingRow,
|
||||
GraceRow,
|
||||
PhasesRow,
|
||||
} from "@/components/medications/sections/settings-section";
|
||||
@@ -62,6 +63,9 @@ export interface AdvancedSettingsSheetProps {
|
||||
notificationsEnabled: boolean;
|
||||
reminderGraceMinutes: number | null;
|
||||
intakeCount: number;
|
||||
/** v1.9.0 — optional drug-classification codes for the FHIR export. */
|
||||
atcCode?: string | null;
|
||||
rxNormCode?: string | null;
|
||||
/**
|
||||
* Sibling-swap the phase sheet: the parent closes this sheet and
|
||||
* opens `<PhaseConfigSheet>` so the two never stack (G-1 §5).
|
||||
@@ -86,6 +90,8 @@ export function AdvancedSettingsSheet({
|
||||
notificationsEnabled,
|
||||
reminderGraceMinutes,
|
||||
intakeCount,
|
||||
atcCode,
|
||||
rxNormCode,
|
||||
onRequestPhaseSheet,
|
||||
onOpenImport,
|
||||
}: AdvancedSettingsSheetProps) {
|
||||
@@ -177,6 +183,13 @@ export function AdvancedSettingsSheet({
|
||||
medicationName={medicationName}
|
||||
/>
|
||||
</div>
|
||||
<div className="py-3">
|
||||
<DrugCodingRow
|
||||
medicationId={medicationId}
|
||||
atcCode={atcCode}
|
||||
rxNormCode={rxNormCode}
|
||||
/>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* DANGER ZONE — purge + delete. Set off by the destructive-
|
||||
|
||||
@@ -131,6 +131,111 @@ export function GraceRow({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.9.0 — optional drug-classification codes (ATC / RxNorm) row.
|
||||
* Self-saves via `PUT /api/medications/[id]` with the two top-level
|
||||
* `atcCode` / `rxNormCode` fields. The codes are user/clinician-asserted
|
||||
* and surface on the FHIR health-record export's
|
||||
* `medicationCodeableConcept` (ATC primary, RxNorm secondary); the app
|
||||
* never machine-guesses a code. An empty input clears the stored code.
|
||||
*/
|
||||
export function DrugCodingRow({
|
||||
medicationId,
|
||||
atcCode,
|
||||
rxNormCode,
|
||||
}: {
|
||||
medicationId: string;
|
||||
atcCode?: string | null;
|
||||
rxNormCode?: string | null;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [atcValue, setAtcValue] = useState(atcCode ?? "");
|
||||
const [rxValue, setRxValue] = useState(rxNormCode ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function save() {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
// Empty input clears the column (null); a non-empty value is sent
|
||||
// verbatim for the server to validate (422 on a malformed code).
|
||||
const res = await fetch(`/api/medications/${medicationId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
atcCode: atcValue.trim() === "" ? null : atcValue.trim(),
|
||||
rxNormCode: rxValue.trim() === "" ? null : rxValue.trim(),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
toast.error(t("medications.detail.settings.codes.failed"));
|
||||
return;
|
||||
}
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
toast.success(t("medications.detail.settings.codes.saved"));
|
||||
} catch {
|
||||
toast.error(t("medications.detail.settings.codes.failed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-slot="medication-detail-drug-coding-row">
|
||||
<Label className="text-foreground text-sm font-medium">
|
||||
{t("medications.detail.settings.codes.label")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.detail.settings.codes.note")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
htmlFor="medication-detail-atc-input"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("medications.detail.settings.codes.atcLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="medication-detail-atc-input"
|
||||
value={atcValue}
|
||||
onChange={(e) => setAtcValue(e.target.value.toUpperCase())}
|
||||
placeholder="A10BX10"
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
htmlFor="medication-detail-rxnorm-input"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("medications.detail.settings.codes.rxNormLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="medication-detail-rxnorm-input"
|
||||
inputMode="numeric"
|
||||
value={rxValue}
|
||||
onChange={(e) => setRxValue(e.target.value)}
|
||||
placeholder="2601723"
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void save()}
|
||||
disabled={busy}
|
||||
aria-busy={busy || undefined}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.7.0 — phases / course-window row. GLP-1 only; the button mounts
|
||||
* once a course window is set, otherwise an italic hint. When
|
||||
|
||||
@@ -945,6 +945,18 @@ const medicationResource = z
|
||||
.describe(
|
||||
"v1.7.0 iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute switch / Focus; the server stores the preference only.",
|
||||
),
|
||||
atcCode: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"v1.9.0 optional WHO ATC classification code (active-substance class, e.g. `A10BX10`). User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under `http://www.whocc.no/atc`. NULL = no code captured.",
|
||||
),
|
||||
rxNormCode: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"v1.9.0 optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary FHIR coding under `http://www.nlm.nih.gov/research/umls/rxnorm`, alongside any ATC code. NULL = no code captured.",
|
||||
),
|
||||
pausedAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
snoozedUntil: z.iso.datetime({ offset: true }).nullable(),
|
||||
nextDueAt: z.iso
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
createMedicationSchema,
|
||||
updateMedicationSchema,
|
||||
} from "@/lib/validations/medication";
|
||||
|
||||
/**
|
||||
* v1.9.0 — validation coverage for the optional drug-classification
|
||||
* codes (`atcCode` / `rxNormCode`) on the medication create + update
|
||||
* bodies. ATC format `^[A-Z]\d{2}[A-Z]{2}\d{2}$`, RxCUI `^\d+$`.
|
||||
*/
|
||||
|
||||
const baseCreate = {
|
||||
name: "Mounjaro",
|
||||
dose: "10mg",
|
||||
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
|
||||
};
|
||||
|
||||
describe("medication drug-code validation", () => {
|
||||
describe("createMedicationSchema", () => {
|
||||
it("accepts a well-formed ATC + RxNorm code", () => {
|
||||
const parsed = createMedicationSchema.safeParse({
|
||||
...baseCreate,
|
||||
atcCode: "A10BX10",
|
||||
rxNormCode: "2601723",
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
if (parsed.success) {
|
||||
expect(parsed.data.atcCode).toBe("A10BX10");
|
||||
expect(parsed.data.rxNormCode).toBe("2601723");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a medication with no codes (the pre-v1.9.0 shape)", () => {
|
||||
const parsed = createMedicationSchema.safeParse(baseCreate);
|
||||
expect(parsed.success).toBe(true);
|
||||
if (parsed.success) {
|
||||
expect(parsed.data.atcCode).toBeUndefined();
|
||||
expect(parsed.data.rxNormCode).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a malformed ATC code", () => {
|
||||
// Too short (anatomical-group prefix, not a leaf substance class).
|
||||
expect(
|
||||
createMedicationSchema.safeParse({ ...baseCreate, atcCode: "A10B" })
|
||||
.success,
|
||||
).toBe(false);
|
||||
// Lowercase.
|
||||
expect(
|
||||
createMedicationSchema.safeParse({ ...baseCreate, atcCode: "a10bx10" })
|
||||
.success,
|
||||
).toBe(false);
|
||||
// Wrong digit/letter layout.
|
||||
expect(
|
||||
createMedicationSchema.safeParse({ ...baseCreate, atcCode: "AA0BX10" })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a non-numeric RxNorm code", () => {
|
||||
expect(
|
||||
createMedicationSchema.safeParse({
|
||||
...baseCreate,
|
||||
rxNormCode: "rx-260",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
createMedicationSchema.safeParse({ ...baseCreate, rxNormCode: "" })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateMedicationSchema", () => {
|
||||
it("accepts null to clear a code", () => {
|
||||
const parsed = updateMedicationSchema.safeParse({
|
||||
atcCode: null,
|
||||
rxNormCode: null,
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
if (parsed.success) {
|
||||
expect(parsed.data.atcCode).toBeNull();
|
||||
expect(parsed.data.rxNormCode).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a well-formed code on update", () => {
|
||||
const parsed = updateMedicationSchema.safeParse({ atcCode: "C09AA05" });
|
||||
expect(parsed.success).toBe(true);
|
||||
if (parsed.success) expect(parsed.data.atcCode).toBe("C09AA05");
|
||||
});
|
||||
|
||||
it("rejects a malformed code on update", () => {
|
||||
expect(
|
||||
updateMedicationSchema.safeParse({ atcCode: "not-a-code" }).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
updateMedicationSchema.safeParse({ rxNormCode: "12.3" }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,46 @@ export type InjectionSiteValue = (typeof INJECTION_SITE_VALUES)[number];
|
||||
export const injectionSiteEnum = z.enum(INJECTION_SITE_VALUES);
|
||||
|
||||
const timeRegex = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||
|
||||
/**
|
||||
* v1.9.0 — drug-classification code formats.
|
||||
*
|
||||
* `ATC_CODE_REGEX` — the 7-character WHO ATC code (one letter, two
|
||||
* digits, two letters, two digits, e.g. `A10BX10`). This is the
|
||||
* full leaf-level substance class; the shorter anatomical-group
|
||||
* prefixes (`A`, `A10`, `A10B`) are deliberately NOT accepted — the
|
||||
* exporter emits a substance-class coding, not a group.
|
||||
*
|
||||
* `RXCUI_REGEX` — the RxNorm RxCUI is a bare positive integer string
|
||||
* (e.g. `2601723`).
|
||||
*
|
||||
* Both fields are user/clinician-asserted and never machine-guessed;
|
||||
* a malformed value is rejected with 422 rather than silently stored.
|
||||
*/
|
||||
const ATC_CODE_REGEX = /^[A-Z]\d{2}[A-Z]{2}\d{2}$/;
|
||||
const RXCUI_REGEX = /^\d+$/;
|
||||
|
||||
/**
|
||||
* v1.9.0 — reusable nullable/optional code-field validators shared by
|
||||
* the create + update medication schemas. `null` clears the column;
|
||||
* `undefined` leaves it untouched on update.
|
||||
*/
|
||||
const atcCodeField = z
|
||||
.string()
|
||||
.regex(ATC_CODE_REGEX, "Invalid ATC code (expected e.g. A10BX10)")
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional WHO ATC classification code (active-substance class, 7 chars, e.g. `A10BX10`). User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under `http://www.whocc.no/atc`. NULL clears it; absent leaves it untouched.",
|
||||
);
|
||||
const rxNormCodeField = z
|
||||
.string()
|
||||
.regex(RXCUI_REGEX, "Invalid RxNorm code (expected a numeric RxCUI)")
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary coding emitted under `http://www.nlm.nih.gov/research/umls/rxnorm` alongside any ATC code, never instead of the free-text name. NULL clears it; absent leaves it untouched.",
|
||||
);
|
||||
/**
|
||||
* Clinical-category values stored in the `medication_categories` side-
|
||||
* table (TEXT column). v1.5.4 adds `DIABETES` and `ANTIBIOTIC` so the
|
||||
@@ -344,6 +384,10 @@ export const createMedicationSchema = z
|
||||
.describe(
|
||||
"iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute switch / Focus; the server stores the preference only and hangs no server-side behaviour off it.",
|
||||
),
|
||||
/** v1.9.0 — optional WHO ATC classification code. */
|
||||
atcCode: atcCodeField,
|
||||
/** v1.9.0 — optional RxNorm RxCUI (secondary, US). */
|
||||
rxNormCode: rxNormCodeField,
|
||||
...courseWindowFields,
|
||||
schedules: z.array(scheduleSchema).min(1, "Mindestens ein Zeitfenster"),
|
||||
})
|
||||
@@ -405,6 +449,10 @@ export const updateMedicationSchema = z
|
||||
.describe(
|
||||
"iOS 26 AlarmKit critical-reminder opt-in. Critical alarms bypass the device mute switch / Focus; the server stores the preference only.",
|
||||
),
|
||||
/** v1.9.0 — optional WHO ATC classification code. */
|
||||
atcCode: atcCodeField,
|
||||
/** v1.9.0 — optional RxNorm RxCUI (secondary, US). */
|
||||
rxNormCode: rxNormCodeField,
|
||||
...courseWindowFields,
|
||||
schedules: z.array(scheduleSchema).optional(),
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user