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", }, );