diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3b14061c0..b784f0af7 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/messages/de.json b/messages/de.json index 0f7157efd..13b90e269 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index a178a67c6..fbb92ebb0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index d52467ffa..975cad5c0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 41e99c492..03d5475b5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index bbae9bf67..4b0aa045f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index 47ef01bbd..66fa2289a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/prisma/migrations/0103_v190_medication_drug_codes/migration.sql b/prisma/migrations/0103_v190_medication_drug_codes/migration.sql new file mode 100644 index 000000000..67c46d0cf --- /dev/null +++ b/prisma/migrations/0103_v190_medication_drug_codes/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 971c85626..2881102b3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index c1abfc1bf..ae5837a94 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -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). diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 8ab1cf5e6..844535a7b 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -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. diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index 10d81def0..f6bdb4177 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -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} /> )} diff --git a/src/components/medications/__tests__/advanced-settings-sheet.test.tsx b/src/components/medications/__tests__/advanced-settings-sheet.test.tsx index 08d0355ba..7611cbb7b 100644 --- a/src/components/medications/__tests__/advanced-settings-sheet.test.tsx +++ b/src/components/medications/__tests__/advanced-settings-sheet.test.tsx @@ -54,6 +54,7 @@ vi.mock("@/components/medications/sections/notifications-section", () => ({ vi.mock("@/components/medications/sections/settings-section", () => ({ GraceRow: () =>
, + DrugCodingRow: () =>
, PhasesRow: ({ onRequestPhaseSheet, }: { diff --git a/src/components/medications/advanced-settings-sheet.tsx b/src/components/medications/advanced-settings-sheet.tsx index 171346be1..232570b1e 100644 --- a/src/components/medications/advanced-settings-sheet.tsx +++ b/src/components/medications/advanced-settings-sheet.tsx @@ -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 `` 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} />
+
+ +
{/* DANGER ZONE — purge + delete. Set off by the destructive- diff --git a/src/components/medications/sections/settings-section.tsx b/src/components/medications/sections/settings-section.tsx index 70036478c..a1da81bab 100644 --- a/src/components/medications/sections/settings-section.tsx +++ b/src/components/medications/sections/settings-section.tsx @@ -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 ( +
+ +

+ {t("medications.detail.settings.codes.note")} +

+
+
+ + setAtcValue(e.target.value.toUpperCase())} + placeholder="A10BX10" + className="w-32" + /> +
+
+ + setRxValue(e.target.value)} + placeholder="2601723" + className="w-32" + /> +
+ +
+
+ ); +} + /** * v1.7.0 — phases / course-window row. GLP-1 only; the button mounts * once a course window is set, otherwise an italic hint. When diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 6c433d7c2..b93fded35 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -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 diff --git a/src/lib/validations/__tests__/medication-drug-codes.test.ts b/src/lib/validations/__tests__/medication-drug-codes.test.ts new file mode 100644 index 000000000..a1c1c0756 --- /dev/null +++ b/src/lib/validations/__tests__/medication-drug-codes.test.ts @@ -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); + }); + }); +}); diff --git a/src/lib/validations/medication.ts b/src/lib/validations/medication.ts index a15e8f958..478838bcd 100644 --- a/src/lib/validations/medication.ts +++ b/src/lib/validations/medication.ts @@ -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(), /**