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:
Marc-André Bombeck committed 2026-06-02 16:45:23 +02:00
1 parent eff7b42348
commit 0a8c6e307c
18 files changed
+476

No files matched your search

+77
View File
@@ -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
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+8
View File
@@ -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;
+12
View File
@@ -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")
+6
View File
@@ -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).
+6
View File
@@ -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.
+6
View File
@@ -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
+12
View File
@@ -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);
});
});
});
+48
View File
@@ -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(),
/**