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 `+ {t("medications.detail.settings.codes.note")} +
+