From e523a3ae006d582575f45af13aa9bd2c7b0b0805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 8 Jun 2026 18:34:37 +0200 Subject: [PATCH] feat(medications): validate per-dose dose windows on schedule writes Accept an optional doseWindows array on the schedule create/update routes: each entry pins an explicit on-time range [start,end] to one of the schedule's times-of-day, defaulting to the symmetric grace band when absent. Reject malformed ranges (bad HH:mm, a timeOfDay outside the schedule, an inverted span) with a 422 so the persisted window can never feed the band minter a degenerate range. Regenerates the OpenAPI contract for the additive field (iOS-safe). --- docs/api/openapi.yaml | 31 +++++ src/app/api/medications/[id]/route.ts | 4 + src/app/api/medications/route.ts | 4 + .../__tests__/medication-dose-windows.test.ts | 125 ++++++++++++++++++ src/lib/validations/medication.ts | 84 ++++++++++++ 5 files changed, 248 insertions(+) create mode 100644 src/lib/validations/__tests__/medication-dose-windows.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 02da19549..c7fd6600b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4307,6 +4307,15 @@ components: type: integer minimum: 0 maximum: 52 + doseWindows: + description: Per-dose on-time intake windows. Each `{ timeOfDay, start, end }` HH:mm triple sets the explicit on-time + band for the matching dose time; a dose time with no entry keeps the symmetric ±1h default. `timeOfDay` must + match one of `timesOfDay` (or `windowStart`); `start <= end`. Up to 8 entries. Absent leaves every slot on + the default derivation. The late tail stays cadence-derived. + maxItems: 8 + type: array + items: + $ref: "#/components/schemas/DoseWindowEntry" required: - windowStart - windowEnd @@ -4315,6 +4324,28 @@ components: preserved through the v1.5.x line for backwards compatibility. **`rrule` and `rollingIntervalDays` are mutually exclusive** — supplying both fails 422 (`rrule_xor_rolling`). The DB enforces the same invariant via a CHECK constraint. + DoseWindowEntry: + type: object + properties: + timeOfDay: + type: string + pattern: ^([01]\d|2[0-3]):([0-5]\d)$ + description: The dose time this window applies to (matches a `timesOfDay` entry). + start: + type: string + pattern: ^([01]\d|2[0-3]):([0-5]\d)$ + description: On-time band lower bound (HH:mm, user local). + end: + type: string + pattern: ^([01]\d|2[0-3]):([0-5]\d)$ + description: On-time band upper bound (HH:mm, user local). Must be >= `start`. + required: + - timeOfDay + - start + - end + 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. UpdateMedicationRequest: type: object properties: diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 8c33a61fb..2817baf87 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -318,6 +318,10 @@ export const PUT = apiHandler( ...(s.cyclicOffWeeks !== undefined && { cyclicOffWeeks: s.cyclicOffWeeks, }), + // v1.15.18 — per-dose configurable on-time windows. A `schedules` + // replace re-creates the rows, so the column is re-written each + // time; absent leaves it NULL (default ±1h derivation). + ...(s.doseWindows !== undefined && { doseWindows: s.doseWindows }), }; }), }, diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 4a470f920..8e4f77835 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -326,6 +326,10 @@ export const POST = apiHandler(async (request: NextRequest) => { ...(s.cyclicOffWeeks !== undefined && { cyclicOffWeeks: s.cyclicOffWeeks, }), + // v1.15.18 — per-dose configurable on-time windows. Stored as the + // validated `{ timeOfDay, start, end }[]` JSON; absent leaves the + // column NULL (every slot on the default ±1h derivation). + ...(s.doseWindows !== undefined && { doseWindows: s.doseWindows }), }; }), }, diff --git a/src/lib/validations/__tests__/medication-dose-windows.test.ts b/src/lib/validations/__tests__/medication-dose-windows.test.ts new file mode 100644 index 000000000..239aa5fd2 --- /dev/null +++ b/src/lib/validations/__tests__/medication-dose-windows.test.ts @@ -0,0 +1,125 @@ +/** + * v1.15.18 — per-dose configurable on-time window (`doseWindows`) validation. + * + * The schedule schema accepts an optional `[{ timeOfDay, start, end }]` array. + * Each entry must be well-formed HH:mm with `start <= end`, name a real dose + * time of the schedule, and no `timeOfDay` may repeat. Absent leaves the + * default ±1h behaviour unchanged. + */ +import { describe, it, expect } from "vitest"; + +import { doseWindowEntrySchema, scheduleSchema } from "../medication"; + +function baseSchedule(over: Record = {}) { + return { + windowStart: "07:00", + windowEnd: "09:00", + timesOfDay: ["07:00", "19:00"], + rrule: "FREQ=DAILY", + ...over, + }; +} + +describe("doseWindowEntrySchema", () => { + it("accepts a well-formed entry", () => { + expect( + doseWindowEntrySchema.safeParse({ + timeOfDay: "07:00", + start: "07:00", + end: "09:00", + }).success, + ).toBe(true); + }); + + it("rejects start > end", () => { + expect( + doseWindowEntrySchema.safeParse({ + timeOfDay: "07:00", + start: "12:00", + end: "07:00", + }).success, + ).toBe(false); + }); + + it("rejects a malformed HH:mm", () => { + expect( + doseWindowEntrySchema.safeParse({ + timeOfDay: "7:00", + start: "07:00", + end: "09:00", + }).success, + ).toBe(false); + expect( + doseWindowEntrySchema.safeParse({ + timeOfDay: "07:00", + start: "07:00", + end: "25:00", + }).success, + ).toBe(false); + }); + + it("allows a zero-width window (start === end)", () => { + expect( + doseWindowEntrySchema.safeParse({ + timeOfDay: "07:00", + start: "08:00", + end: "08:00", + }).success, + ).toBe(true); + }); +}); + +describe("scheduleSchema doseWindows", () => { + it("is optional — a schedule without it validates", () => { + expect(scheduleSchema.safeParse(baseSchedule()).success).toBe(true); + }); + + it("accepts a window for a real dose time", () => { + const r = scheduleSchema.safeParse( + baseSchedule({ + doseWindows: [{ timeOfDay: "07:00", start: "07:00", end: "09:00" }], + }), + ); + expect(r.success).toBe(true); + }); + + it("rejects a window whose timeOfDay is not a scheduled dose time", () => { + const r = scheduleSchema.safeParse( + baseSchedule({ + doseWindows: [{ timeOfDay: "13:00", start: "13:00", end: "14:00" }], + }), + ); + expect(r.success).toBe(false); + }); + + it("rejects a duplicate timeOfDay", () => { + const r = scheduleSchema.safeParse( + baseSchedule({ + doseWindows: [ + { timeOfDay: "07:00", start: "07:00", end: "08:00" }, + { timeOfDay: "07:00", start: "08:00", end: "09:00" }, + ], + }), + ); + expect(r.success).toBe(false); + }); + + it("rejects a per-entry start > end", () => { + const r = scheduleSchema.safeParse( + baseSchedule({ + doseWindows: [{ timeOfDay: "07:00", start: "12:00", end: "07:00" }], + }), + ); + expect(r.success).toBe(false); + }); + + it("matches a legacy single-time schedule against windowStart", () => { + const r = scheduleSchema.safeParse({ + windowStart: "08:00", + windowEnd: "10:00", + // no timesOfDay → effective dose time is windowStart + doseWindows: [{ timeOfDay: "08:00", start: "08:00", end: "11:00" }], + }); + expect(r.success).toBe(true); + }); +}); diff --git a/src/lib/validations/medication.ts b/src/lib/validations/medication.ts index a4d6d0640..fb7721321 100644 --- a/src/lib/validations/medication.ts +++ b/src/lib/validations/medication.ts @@ -126,6 +126,43 @@ export type MedicationDeliveryForm = 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.", + }); + +/** Minutes-since-midnight for an `HH:mm` literal (already regex-validated). */ +function hhmmToMinutes(hhmm: string): number { + const [h, m] = hhmm.split(":"); + return Number(h) * 60 + Number(m); +} + export const scheduleSchema = z .object({ windowStart: z @@ -251,6 +288,21 @@ export const scheduleSchema = z .describe( "Cyclic \"off\" weeks (0..52). Required when `scheduleType` is CYCLIC; ignored otherwise.", ), + /** + * v1.15.18 — per-dose configurable on-time intake window (Marc's + * "07:00–09:00" lever). One entry per dose time the user wants an explicit + * range for; a `timeOfDay` with no entry keeps the symmetric ±1h default. + * Each `timeOfDay` MUST match one of the schedule's `timesOfDay` (or the + * legacy `windowStart`), and `start <= end` within the day. Absent → every + * slot uses the default derivation (unchanged behaviour). + */ + doseWindows: z + .array(doseWindowEntrySchema) + .max(8) + .optional() + .describe( + "Per-dose on-time intake windows. Each `{ timeOfDay, start, end }` HH:mm triple sets the explicit on-time band for the matching dose time; a dose time with no entry keeps the symmetric ±1h default. `timeOfDay` must match one of `timesOfDay` (or `windowStart`); `start <= end`. Up to 8 entries. Absent leaves every slot on the default derivation. The late tail stays cadence-derived.", + ), }) .refine( (s) => !(s.rrule && s.rollingIntervalDays), @@ -290,6 +342,38 @@ export const scheduleSchema = z path: ["timesOfDay"], }, ) + .refine( + (s) => { + // Every per-dose window must name a real dose time. The effective dose + // times are `timesOfDay` when set, else the single legacy `windowStart` + // (mirrors the engine's `effectiveTimesOfDay`). + if (!s.doseWindows || s.doseWindows.length === 0) return true; + const times = new Set( + s.timesOfDay && s.timesOfDay.length > 0 ? s.timesOfDay : [s.windowStart], + ); + return s.doseWindows.every((w) => times.has(w.timeOfDay)); + }, + { + message: "Each doseWindows.timeOfDay must match one of the schedule's timesOfDay", + path: ["doseWindows"], + }, + ) + .refine( + (s) => { + // A dose time may carry at most one explicit window. + if (!s.doseWindows || s.doseWindows.length === 0) return true; + const seen = new Set(); + for (const w of s.doseWindows) { + if (seen.has(w.timeOfDay)) return false; + seen.add(w.timeOfDay); + } + return true; + }, + { + message: "doseWindows must not repeat a timeOfDay", + path: ["doseWindows"], + }, + ) .meta({ id: "MedicationScheduleInput", description: