Files
HealthLog/src/lib/validations/medication/create-update.ts
T

383 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { z } from "zod/v4";
import {
MEDICATION_CATEGORY_VALUES,
MEDICATION_DELIVERY_FORM_VALUES,
MEDICATION_TREATMENT_CLASS_VALUES,
UNITS_PER_DOSE_MESSAGE,
atcCodeField,
injectionSiteEnum,
isSupportedUnitsPerDose,
INJECTION_SITE_VALUES,
rxNormCodeField,
} from "./base";
import { scheduleSchema } from "./schedule";
import { assertStableExternalId } from "@/lib/validations/external-id";
/**
* v1.5 — medication-level course window + one-shot flag. The fields
* are optional on the create path so the existing form (no wizard
* yet) keeps working. The route layer enforces the `oneShot` + at-
* least-one-schedule invariant for the wizard path.
*/
const courseWindowFields = {
/**
* Date the medication course begins. ISO YYYY-MM-DD. NULL = "from
* creation" (the legacy implicit anchor).
*/
startsOn: z.iso
.date()
.transform((s) => new Date(s))
.nullable()
.optional()
.describe(
"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). Required when `oneShot` is true.",
),
/**
* Date the medication course ends. ISO YYYY-MM-DD. NULL = chronic.
* Required to equal `startsOn` when `oneShot` is true (the route
* normalises this; the schema doesn't because they're sister
* fields and the cross-field refine is enforced at the route).
*/
endsOn: z.iso
.date()
.transform((s) => new Date(s))
.nullable()
.optional()
.describe(
"Date the medication course ends (ISO `YYYY-MM-DD`). NULL = no end date (chronic). When `oneShot` is true the route normalises `endsOn` to equal `startsOn`.",
),
/**
* Single-administration medication. Auto-deactivates after the
* single intake is logged.
*/
oneShot: z
.boolean()
.optional()
.describe(
"Single-administration medication (e.g. flu shot, post-op single dose). When true the medication has at most one schedule with no `rrule` / `rollingIntervalDays`, and `active` auto-flips to false once the dose is logged (non-skipped).",
),
/**
* v1.16.11 (#316) — as-needed (PRN) medication. No fixed schedule:
* never due, never reminded, excluded from compliance, active
* indefinitely. Intakes log ad-hoc and consume inventory. Carries
* ZERO schedules — a `schedules` array alongside `asNeeded: true` is
* a 422. Mutually exclusive with `oneShot`.
*/
asNeeded: z
.boolean()
.optional()
.describe(
"As-needed (PRN) medication (pain relief, rescue inhaler). When true the medication carries NO schedules (supplying any schedule entry is a 422): it is never due, never reminded, and excluded from compliance rates/streaks, but intakes still log (ad-hoc), inventory still consumes, and the history renders. Stays active indefinitely. Mutually exclusive with `oneShot`.",
),
};
/**
* v1.16.11 — shared as-needed cross-field refines for the create +
* update bodies: `asNeeded` and `oneShot` are mutually exclusive, and
* an as-needed medication cannot carry schedule entries.
*/
const AS_NEEDED_ONE_SHOT_MESSAGE =
"asNeeded and oneShot are mutually exclusive";
const AS_NEEDED_SCHEDULES_MESSAGE =
"An as-needed medication cannot carry schedules";
export const createMedicationSchema = z
.object({
name: z.string().min(1).max(100),
dose: z.string().min(1).max(50),
category: z.enum(MEDICATION_CATEGORY_VALUES).optional(),
/** v1.4.25 W4d — treatment-class discriminator (GENERIC | GLP1). */
treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(),
/** v1.4.25 W4d — doses per pen/vial for inventory tracking.
* v1.16.10 raises the cap to 1000 (large tablet packs). */
dosesPerUnit: z.number().int().min(1).max(1000).optional(),
/**
* v1.16.10 — inventory units one dose consumes (2 × 2 mg tablets
* for a 4 mg dose). Default 1. Dose-level readouts divide the
* unit counts by this factor.
*/
unitsPerDose: z
.number()
.refine(isSupportedUnitsPerDose, { message: UNITS_PER_DOSE_MESSAGE })
.optional()
.describe(
"Inventory units consumed per dose. A whole number 1–100 (e.g. 2 tablets of 2 mg for a 4 mg dose) or a supported fraction for a split pill (¼ / ⅓ / ½ / ⅔ / ¾). Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.",
),
/**
* v1.17.0 — optional per-medication reorder lead time in days. The
* low-stock alert widens its trigger by this lead plus one
* dose-interval so a sparse cadence is warned before its last dose.
* Omitted = inherit the user-level
* `notificationPrefs.medication.reorderLeadDays` default.
*/
reorderLeadDays: z
.number()
.int()
.min(0)
.max(60)
.optional()
.describe(
"Per-medication reorder lead time in days (0–60). The low-stock alert fires the supply early enough to reorder before the last dose. Omitted = inherit the user-level reorderLeadDays default (10).",
),
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
deliveryForm: z.enum(MEDICATION_DELIVERY_FORM_VALUES).optional(),
/**
* v1.8.5 — per-medication injection-site tracking opt-in. Default
* false. Only meaningful when `deliveryForm === "INJECTION"`.
*/
trackInjectionSites: z
.boolean()
.optional()
.describe(
"Per-medication injection-site tracking opt-in. Default false. Only meaningful for an INJECTION delivery form; when true the client prompts (skippably) for the site after a taken dose.",
),
/**
* v1.8.5 — per-medication allowed / preferred injection sites.
* Empty = no per-medication restriction. The effective pickable set
* subtracts the user's global exclusion (deny wins).
*/
allowedInjectionSites: z
.array(injectionSiteEnum)
.max(INJECTION_SITE_VALUES.length)
.optional()
.describe(
"Per-medication allowed / preferred injection sites. Empty array = no per-medication restriction (every site offered). The effective pickable set is this list minus the user's global exclusion.",
),
notificationsEnabled: z.boolean().optional(),
/** v1.7.0 — iOS Live Activity opt-in for this medication's reminders. */
liveActivityEnabled: z
.boolean()
.optional()
.describe(
"iOS Live Activity opt-in for this medication's reminders. Default false. The iOS client owns the ActivityKit lifecycle; the server only stores + echoes the flag.",
),
/** v1.7.0 — iOS 26 AlarmKit critical-reminder opt-in. */
criticalAlarmEnabled: z
.boolean()
.optional()
.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,
/**
* v1.28 — mirrored-medication provenance. Only APPLE_HEALTH is
* accepted here (the iOS 26+ HealthKit Medications sync); the other
* IntakeSource values stay server-owned. Both-or-neither with
* `externalId` (refine below).
*/
externalSource: z
.literal("APPLE_HEALTH")
.optional()
.describe(
"Marks the medication as MIRRORED from an external medication list. Only `APPLE_HEALTH` (the iOS 26+ HealthKit Medications sync) is accepted. Must be supplied together with `externalId`. A mirrored medication is source-exclusive: dose events from that source attach only to it, and the app's own dose-logging UI stays off it.",
),
/**
* v1.28 — the source's stable concept identifier (HealthKit
* `medicationConceptIdentifier`). Re-posting the same
* `(externalSource, externalId)` pair returns the existing
* medication (200) instead of creating a duplicate.
*/
externalId: z
.string()
.trim()
.min(1)
.max(128)
.optional()
.describe(
"Stable external identifier of the mirrored medication (the HealthKit `medicationConceptIdentifier`, max 128 chars). Must be supplied together with `externalSource`. Idempotent: re-posting the same pair returns the existing medication with status 200 instead of minting a duplicate. The value MUST be identical across app restarts: an object description or memory address (`<Class: 0x…>`, `0x12ab34cd`) is a new value on every launch, never matches its own earlier row, and is refused with 422 rather than minting a duplicate mirror.",
),
...courseWindowFields,
/**
* v1.16.11 — optional at the type level so an `asNeeded: true`
* create can omit it; the refine below keeps the legacy
* at-least-one-schedule contract for every scheduled medication.
*/
schedules: z.array(scheduleSchema).optional(),
})
.refine(
// v1.28 — mirrored-medication provenance is a pair: a source without
// its concept id (or vice versa) can neither dedup nor mirror.
(b) => (b.externalSource === undefined) === (b.externalId === undefined),
{
message: "externalSource and externalId must be supplied together",
path: ["externalId"],
},
)
// The concept id has to be STABLE across client launches or the
// idempotent-mirror pre-query above never matches its own earlier row
// and every sync sweep mints another phantom medication.
.superRefine(assertStableExternalId)
.refine((b) => b.oneShot !== true || !!b.startsOn, {
message: "startsOn is required when oneShot is true",
path: ["startsOn"],
})
.refine(
(b) =>
!b.startsOn || !b.endsOn || b.endsOn.getTime() >= b.startsOn.getTime(),
{
message: "endsOn must be on or after startsOn",
path: ["endsOn"],
},
)
.refine((b) => !(b.asNeeded === true && b.oneShot === true), {
message: AS_NEEDED_ONE_SHOT_MESSAGE,
path: ["asNeeded"],
})
.refine(
(b) =>
b.externalSource === "APPLE_HEALTH" ||
b.asNeeded === true ||
(!!b.schedules && b.schedules.length >= 1),
{
message: "Mindestens ein Zeitfenster",
path: ["schedules"],
},
)
.refine(
(b) => b.asNeeded !== true || !b.schedules || b.schedules.length === 0,
{
message: AS_NEEDED_SCHEDULES_MESSAGE,
path: ["schedules"],
},
)
.meta({
id: "CreateMedicationRequest",
description:
'Create-medication body. The route enforces the v1.5 cross-field invariants on top of the per-schedule `rrule_xor_rolling` Zod refine: a `oneShot:true` medication may carry at most one schedule and that schedule must not declare a recurrence; `endsOn` is normalised to equal `startsOn` for one-shot doses; a recurring schedule with no `rrule`, `rollingIntervalDays`, or legacy `daysOfWeek` defaults to `rrule = "FREQ=DAILY"`; and `timesOfDay` is dual-written from `windowStart` when the caller omits it. v1.16.11 — `asNeeded: true` creates a PRN medication with ZERO schedules (`schedules` must be absent or empty, 422 otherwise); a scheduled medication still requires at least one schedule entry. v1.28 — `externalSource` + `externalId` (both-or-neither) mark the medication as mirrored from Apple Health; re-posting the same pair is idempotent and returns the existing medication with status 200.',
});
export const updateMedicationSchema = z
.object({
name: z.string().min(1).max(100).optional(),
dose: z.string().min(1).max(50).optional(),
category: z.enum(MEDICATION_CATEGORY_VALUES).optional(),
treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(),
dosesPerUnit: z.number().int().min(1).max(1000).nullable().optional(),
/** v1.16.10 — inventory units one dose consumes. Default 1.
* v1.16.12 — whole number 1–100 or a curated fraction (½ / ⅓ / ¼ …). */
unitsPerDose: z
.number()
.refine(isSupportedUnitsPerDose, { message: UNITS_PER_DOSE_MESSAGE })
.optional()
.describe(
"Inventory units consumed per dose — a whole number 1–100 or a supported split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾). The intake consumption hook decrements this many units per taken dose; already-stamped intake events keep their recorded consumption.",
),
/**
* v1.17.0 — per-medication reorder lead time in days (0–60). `null`
* clears the override and reverts to the user-level default.
*/
reorderLeadDays: z
.number()
.int()
.min(0)
.max(60)
.nullable()
.optional()
.describe(
"Per-medication reorder lead time in days (0–60). null clears the override and reverts to the user-level reorderLeadDays default.",
),
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
deliveryForm: z.enum(MEDICATION_DELIVERY_FORM_VALUES).optional(),
/** v1.8.5 — per-medication injection-site tracking opt-in. */
trackInjectionSites: z
.boolean()
.optional()
.describe(
"Per-medication injection-site tracking opt-in. Only meaningful for an INJECTION delivery form. Set false to deactivate tracking.",
),
/** v1.8.5 — per-medication allowed / preferred injection sites. */
allowedInjectionSites: z
.array(injectionSiteEnum)
.max(INJECTION_SITE_VALUES.length)
.optional()
.describe(
"Per-medication allowed / preferred injection sites. Empty array clears the restriction. The effective pickable set subtracts the user's global exclusion (deny wins).",
),
active: z.boolean().optional(),
notificationsEnabled: z.boolean().optional(),
/** v1.7.0 — iOS Live Activity opt-in for this medication's reminders. */
liveActivityEnabled: z
.boolean()
.optional()
.describe(
"iOS Live Activity opt-in for this medication's reminders. The iOS client owns the ActivityKit lifecycle; the server only stores + echoes the flag.",
),
/** v1.7.0 — iOS 26 AlarmKit critical-reminder opt-in. */
criticalAlarmEnabled: z
.boolean()
.optional()
.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(),
/**
* v1.5.5 — top-level primary-schedule grace bridge. The detail
* page settings section saves the reminder-window in minutes for
* the medication's primary schedule without re-sending the full
* `schedules` array. The route normalises this value onto the
* primary schedule's `reminderGraceMinutes` before the Prisma
* update so the persisted shape stays per-schedule. NULL clears
* the override and falls back to the legacy `windowEnd -
* windowStart` span.
*/
reminderGraceMinutes: z
.number()
.int()
.min(0)
.max(24 * 60)
.nullable()
.optional()
.describe(
"Detail-page bridge: primary-schedule reminder-window in minutes. The route maps the value onto the primary schedule's `reminderGraceMinutes` field; ignored when a full `schedules` array is also supplied.",
),
})
.refine((b) => b.oneShot !== true || !!b.startsOn, {
message: "startsOn is required when oneShot is true",
path: ["startsOn"],
})
.refine(
(b) =>
!b.startsOn || !b.endsOn || b.endsOn.getTime() >= b.startsOn.getTime(),
{
message: "endsOn must be on or after startsOn",
path: ["endsOn"],
},
)
.refine((b) => !(b.asNeeded === true && b.oneShot === true), {
message: AS_NEEDED_ONE_SHOT_MESSAGE,
path: ["asNeeded"],
})
.refine(
(b) => b.asNeeded !== true || !b.schedules || b.schedules.length === 0,
{
message: AS_NEEDED_SCHEDULES_MESSAGE,
path: ["schedules"],
},
)
.refine(
// An empty replace-list is only legal on the as-needed path — for a
// scheduled medication it would wipe every schedule and strand the
// row in a state no surface can reason about.
(b) => b.asNeeded === true || !b.schedules || b.schedules.length >= 1,
{
message: "Mindestens ein Zeitfenster",
path: ["schedules"],
},
)
.meta({
id: "UpdateMedicationRequest",
description:
"Update-medication body. Every field is optional; omitted fields are left untouched. Supplying `schedules` REPLACES the medication's full schedule list (the route deletes existing rows before re-creating). Flipping `active` to false records the current timestamp on `pausedAt`; flipping back to true clears it. v1.5 invariants on the `schedules` array match the create path. v1.16.11 — `asNeeded: true` requires the medication to end schedule-less: supply `schedules: []` to clear an existing plan (any schedule entry is a 422); an empty `schedules` array without `asNeeded: true` is likewise a 422.",
});
export type CreateMedicationInput = z.infer<typeof createMedicationSchema>;