mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
255 lines
10 KiB
TypeScript
255 lines
10 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";
|
|
import { isoDateTime } from "@/lib/validations/datetime";
|
|
|
|
/**
|
|
* 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` table
|
|
* (TEXT column, the `MedicationCategoryAssignment` model). 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.40 (#1041) — a category the person named themselves is addressed by the
|
|
* key of its `MedicationCategoryLabel` row, `custom:<uuid>`. The `category`
|
|
* field on the medication wire is a built-in value or such a key.
|
|
*/
|
|
export const CUSTOM_MEDICATION_CATEGORY_PREFIX = "custom:";
|
|
|
|
/** Per-account ceiling on custom medication categories (hidden ones count). */
|
|
export const MAX_CUSTOM_MEDICATION_CATEGORIES = 20;
|
|
|
|
const CUSTOM_MEDICATION_CATEGORY_KEY = /^custom:[0-9a-f-]{36}$/;
|
|
|
|
/** Is `value` a custom-category key rather than a built-in value? */
|
|
export function isCustomMedicationCategoryKey(value: string): boolean {
|
|
return value.startsWith(CUSTOM_MEDICATION_CATEGORY_PREFIX);
|
|
}
|
|
|
|
/**
|
|
* The `category` a create or update body may carry: a built-in value or a
|
|
* well-formed custom key. Ownership of a custom key is checked by the route
|
|
* (422 `medications.category.unknown`), not here.
|
|
*/
|
|
export const medicationCategoryInputSchema = z
|
|
.union([
|
|
z.enum(MEDICATION_CATEGORY_VALUES),
|
|
z.string().regex(CUSTOM_MEDICATION_CATEGORY_KEY, "Unknown category"),
|
|
])
|
|
.describe(
|
|
"A built-in clinical category (BLOOD_PRESSURE, VITAMIN, SUPPLEMENT, PAIN_RELIEF, ALLERGY, DIGESTIVE, THYROID, HORMONE, SKIN, SLEEP_AID, DIABETES, ANTIBIOTIC, MENTAL_HEALTH, OTHER) or `custom:<uuid>`, the key of one of the caller's own categories.",
|
|
);
|
|
|
|
const customCategoryLabelSchema = z
|
|
.string()
|
|
.trim()
|
|
.min(1, "Label must not be empty")
|
|
.max(40, "Label must be at most 40 characters");
|
|
|
|
/** `POST /api/medications/categories` body. */
|
|
export const createMedicationCategoryLabelSchema = z.object({
|
|
label: customCategoryLabelSchema,
|
|
});
|
|
|
|
/** `PATCH /api/medications/categories/{key}` body: at least one field. */
|
|
export const updateMedicationCategoryLabelSchema = z
|
|
.object({
|
|
label: customCategoryLabelSchema.optional(),
|
|
sortOrder: z.number().int().min(0).max(1000).optional(),
|
|
isActive: z.boolean().optional(),
|
|
})
|
|
.refine(
|
|
(v) =>
|
|
v.label !== undefined ||
|
|
v.sortOrder !== undefined ||
|
|
v.isActive !== undefined,
|
|
"At least one field is required",
|
|
);
|
|
|
|
/**
|
|
* 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(
|
|
isoDateTime().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",
|
|
},
|
|
);
|