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).
This commit is contained in:
Marc-André Bombeck committed 2026-06-08 18:34:37 +02:00
1 parent 6526780f3b
commit e523a3ae00
5 files changed
+248

No files matched your search

+31
View File
@@ -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:
+4
View File
@@ -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 }),
};
}),
},
+4
View File
@@ -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 }),
};
}),
},
@@ -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<string, unknown> = {}) {
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);
});
});
+84
View File
@@ -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<string>();
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: