Files
HealthLog/src/lib/validations/medication/base.ts
T
Marc-André Bombeck a224875ed9 feat(medications): add mental health as a medication category
Antidepressants, anxiolytics, mood stabilisers and ADHD medication had no
category of their own and ended up under "Other". MENTAL_HEALTH joins the
clinical category list: the add and edit wizard offers it as its own row
with a brain icon, the card badge names it, and it is translated in all
seven languages.

The category is a text value, so no migration is needed. Clients that
read the category as a string show the new value; an app that does not
know it yet falls back to its own label for it.
2026-09-27 13:51:39 +02:00

197 lines
8.0 KiB
TypeScript

import { z } from "zod/v4";
import { INJECTION_SITE_KEYS } from "@/lib/medications/injection-sites";
import { validateEntryInstant } from "@/lib/validations/entry-instant";
import { hhmmToMinutes } from "@/lib/medications/scheduling/hhmm";
/**
* v1.8.5 — the eight injection-site enum values, mirrored from the
* Prisma `InjectionSite` enum via the shared `INJECTION_SITE_KEYS`
* tuple. Reused by the intake + bulk-intake request schemas (the
* `injectionSite` field) and the per-medication allowed-sites editor.
*/
export const INJECTION_SITE_VALUES = INJECTION_SITE_KEYS;
export type InjectionSiteValue = (typeof INJECTION_SITE_VALUES)[number];
/** Zod enum over the eight injection sites. */
export const injectionSiteEnum = z.enum(INJECTION_SITE_VALUES);
export 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.
*/
export 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.",
);
export const rxNormCodeField = z
.string()
.regex(RXCUI_REGEX, "Invalid RxNorm code (expected a numeric RxCUI)")
// Defence-in-depth: the `^\d+$` regex is otherwise unbounded. Real
// RxCUIs are at most 7 digits today; 20 is a generous ceiling that
// still bounds the column write tightly. (`atcCode` needs no max —
// its regex already fixes the length at 7.)
.max(20, "RxNorm code is too long")
.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
* wizard's Step 2 taxonomy can write a first-class bucket for those
* two rows instead of collapsing them into `OTHER`. The column is a
* plain TEXT field — no Prisma enum exists to migrate; the Zod values
* list is the only enforcement layer. v1.39.4 adds `MENTAL_HEALTH`
* (antidepressants, anxiolytics, mood stabilisers, ADHD medication), again
* without a migration.
*/
export const MEDICATION_CATEGORY_VALUES = [
"BLOOD_PRESSURE",
"VITAMIN",
"SUPPLEMENT",
"PAIN_RELIEF",
"ALLERGY",
"DIGESTIVE",
"THYROID",
"HORMONE",
"SKIN",
"SLEEP_AID",
"DIABETES",
"ANTIBIOTIC",
"MENTAL_HEALTH",
"OTHER",
] as const;
export type MedicationCategoryValue =
(typeof MEDICATION_CATEGORY_VALUES)[number];
/**
* v1.4.25 W4d — Prisma-level treatment class. Orthogonal to
* `MEDICATION_CATEGORY_VALUES` (the clinical taxonomy that lives in the
* `medication_categories` side-table). GLP1 turns on the GLP-1
* specialist surfaces — injection-site picker, titration history, pen
* inventory, GLP-1-aware Coach replies. Future treatment classes drop
* into this list (INSULIN, BIOLOGIC, FERTILITY, …).
*/
export const MEDICATION_TREATMENT_CLASS_VALUES = ["GENERIC", "GLP1"] as const;
export type MedicationTreatmentClass =
(typeof MEDICATION_TREATMENT_CLASS_VALUES)[number];
/**
* v1.6.0 — route of administration. Decoupled from `treatmentClass`:
* the injection-site picker surfaces for any `INJECTION` dose, and a
* one-time injection is `oneShot: true` + `deliveryForm: "INJECTION"`.
* ORAL is the default the migration backfilled onto every existing row.
*/
export const MEDICATION_DELIVERY_FORM_VALUES = [
"ORAL",
"INJECTION",
"OTHER",
] as const;
export type MedicationDeliveryForm =
(typeof MEDICATION_DELIVERY_FORM_VALUES)[number];
/**
* v1.5 — RRULE string shape check.
*
* Not a full RFC 5545 parser (that lives in the `rrule` npm package on
* the server side). The regex catches the subset HealthLog mints from
* the wizard / form: a `FREQ=` line with optional `INTERVAL=`,
* `BYDAY=`, `BYMONTHDAY=`, `BYMONTH=`, `COUNT=`, `UNTIL=` properties
* separated by `;`. Any pathological-looking shape (the `FREQ=SECONDLY`
* DoS surface, `;COUNT=999999999`) is filtered at the server route
* layer with a hard cap on emitted occurrences.
*/
export const RRULE_PROPS =
/^FREQ=(?:DAILY|WEEKLY|MONTHLY|YEARLY)(?:;(?:INTERVAL=\d{1,3}|BYDAY=(?:MO|TU|WE|TH|FR|SA|SU)(?:,(?:MO|TU|WE|TH|FR|SA|SU))*|BYMONTHDAY=-?\d{1,2}(?:,-?\d{1,2})*|BYMONTH=\d{1,2}(?:,\d{1,2})*|COUNT=\d{1,4}|UNTIL=\d{8}T\d{6}Z))*$/;
/**
* v1.15.18 — one explicit per-dose on-time window. `timeOfDay` keys the dose
* the window applies to; `start`/`end` are the HH:mm on-time bounds in the
* user's wall clock. `start <= end` within the day (an overnight window is not
* a configurable on-time band — the late tail owns the cross-midnight tail).
*/
export const doseWindowEntrySchema = z
.object({
timeOfDay: z
.string()
.regex(timeRegex, "Format: HH:mm")
.describe(
"The dose time this window applies to (matches a `timesOfDay` entry).",
),
start: z
.string()
.regex(timeRegex, "Format: HH:mm")
.describe("On-time band lower bound (HH:mm, user local)."),
end: z
.string()
.regex(timeRegex, "Format: HH:mm")
.describe(
"On-time band upper bound (HH:mm, user local). Must be >= `start`.",
),
})
.refine((w) => hhmmToMinutes(w.start) <= hhmmToMinutes(w.end), {
message: "Window start must be on or before end (same day)",
path: ["end"],
})
.meta({
id: "DoseWindowEntry",
description:
"One explicit per-dose on-time intake window. `timeOfDay` matches a schedule dose time; `[start, end]` (HH:mm, user local, `start <= end`) is the on-time band. Outside it the cadence-derived late tail applies, then ad-hoc.",
});
/**
* v1.15.19 introduced `takenAt` plausibility bounds on the EDIT path
* (audit P0-4); v1.16.9 lifts them onto every CREATE path too (the
* per-medication intake POST, the bulk endpoint, the external ingest) so
* the retro-add dialog and a hand-rolled API call can't insert a row a
* decade in the past or hours in the future. Future allowance covers
* client clock skew; the 5-year floor matches the GLP-1 dose-change
* validator's window. Slot-distance / medication-start checks stay in
* the routes — the schema cannot see the medication.
*/
export const TAKEN_AT_MAX_AGE_MS = 5 * 365 * 24 * 60 * 60 * 1000;
/**
* ISO `takenAt` → `Date` with the plausibility bounds applied.
*
* v1.17 W1b — delegates to the shared `validateEntryInstant` helper (the
* same bound now guarding `measuredAt` + `moodLoggedAt`), passing the
* five-year window so a stale backdated dose is rejected well before the
* 1900 floor. The 5-min clock-skew tolerance is the shared default.
*/
export const boundedTakenAtSchema = validateEntryInstant(
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
{
maxAgeMs: TAKEN_AT_MAX_AGE_MS,
// Preserve the established wording so the iOS contract + tests stay stable.
pastMessage: "takenAt must be within the last 5 years",
},
);