feat(meds): plumb v1.5 scheduling fields through POST + PUT routes

The Zod validators + the engine + the worker accept the new
v1.5 fields. The API route handlers didn't — they silently dropped
`startsOn` / `endsOn` / `oneShot` / `timesOfDay` / `rrule` /
`rollingIntervalDays` / `reminderGraceMinutes` before the Prisma
write. This commit threads them through.

### Route invariants

- One-shot consistency: `oneShot === true` ⇒ at most one schedule
  (422 otherwise), that schedule has neither `rrule` nor
  `rollingIntervalDays`, and `endsOn` is auto-normalised to equal
  `startsOn` on write.
- Recurring default: when `oneShot !== true` and the incoming
  schedule has no `rrule` / `rollingIntervalDays` / legacy
  `daysOfWeek`, the route stamps `rrule = "FREQ=DAILY"` so the
  worker always sees a clean shape.
- Backwards compat: empty / absent `timesOfDay` is server-side
  populated from `windowStart` so the engine always sees a
  non-empty array, and legacy iOS v0.6.x clients that still post
  the `windowStart` / `windowEnd` / `daysOfWeek` shape keep
  working unchanged.

### Tests

9 new cases across both route test files (7 POST + 3 PUT, one
POST as a 7b subcase): one-shot create persistence, RRULE
persistence, rollingIntervalDays persistence, one-shot +
recurrence → 422, one-shot + multiple schedules → 422, PUT
endsOn updates, legacy POST back-fills timesOfDay, legacy POST
with no recurrence hints stamps daily.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-28 08:22:17 +02:00
1 parent 2aa19fa770
commit 925138eccb
4 files changed
+440 -22

No files matched your search

@@ -10,6 +10,12 @@ vi.mock("@/lib/db", () => ({
findUnique: vi.fn(),
update: vi.fn(),
},
medicationSchedule: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
apiToken: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
$transaction: vi.fn(),
},
}));
@@ -42,6 +48,8 @@ vi.mock("next/headers", () => ({
import { PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { getMedicationCategories } from "@/lib/medication-category";
import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -100,3 +108,68 @@ describe("PUT /api/medications/[id] — 422 multi-issue (v1.4.43 W6)", () => {
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
describe("PUT /api/medications/[id] — v1.5 scheduling primitives", () => {
function lastUpdateCall(): {
data: Record<string, unknown>;
} {
const calls = vi.mocked(prisma.medication.update).mock.calls;
expect(calls.length).toBeGreaterThan(0);
return calls[calls.length - 1][0] as never;
}
it("(6) PATCH endsOn updates the field", async () => {
vi.mocked(prisma.medication.update).mockResolvedValue({
id: "m1",
userId: "user-1",
endsOn: new Date("2026-12-31"),
schedules: [],
} as never);
// Re-stub deps the outer beforeEach's resetAllMocks cleared.
vi.mocked(getMedicationCategories).mockResolvedValue({});
vi.mocked(auditLog).mockResolvedValue(undefined);
const res = await PUT(
putReq({ endsOn: "2026-12-31" }),
ROUTE_CTX,
);
expect(res.status).toBe(200);
const call = lastUpdateCall();
expect(call.data.endsOn).toBeInstanceOf(Date);
expect((call.data.endsOn as Date).toISOString()).toBe(
new Date("2026-12-31").toISOString(),
);
});
it("(4-PUT) 422 when oneShot=true + schedule carries rrule", async () => {
const res = await PUT(
putReq({
oneShot: true,
startsOn: "2026-10-15",
schedules: [
{
windowStart: "10:00",
windowEnd: "10:30",
rrule: "FREQ=DAILY",
},
],
}),
ROUTE_CTX,
);
expect(res.status).toBe(422);
});
it("(5-PUT) 422 when oneShot=true + multiple schedules", async () => {
const res = await PUT(
putReq({
oneShot: true,
startsOn: "2026-10-15",
schedules: [
{ windowStart: "08:00", windowEnd: "08:30" },
{ windowStart: "20:00", windowEnd: "20:30" },
],
}),
ROUTE_CTX,
);
expect(res.status).toBe(422);
});
});
+77 -10
View File
@@ -83,8 +83,42 @@ export const PUT = apiHandler(
active,
notificationsEnabled,
schedules,
startsOn,
endsOn,
oneShot,
} = parsed.data;
// ── v1.5 route invariants for the new scheduling primitives ─────
//
// Mirrors the POST route: oneShot ⇒ at-most-one schedule + no
// recurrence on it; endsOn normalised to startsOn for one-shot;
// recurring default = FREQ=DAILY when nothing else is set;
// timesOfDay dual-write from windowStart when absent.
//
// The PUT replaces all schedules wholesale, so the invariants run
// against the incoming `schedules` array (if provided) the same
// way they do on create.
if (oneShot === true && schedules) {
if (schedules.length > 1) {
return apiError(
"A one-shot medication can have at most one schedule",
422,
);
}
const s = schedules[0];
if (s && (s.rrule !== undefined || s.rollingIntervalDays !== undefined)) {
return apiError(
"A one-shot medication cannot have a recurrence (rrule or rollingIntervalDays)",
422,
);
}
}
// Normalise endsOn for one-shot. `oneShot === true` + `startsOn`
// means the dose is the start date; endsOn auto-matches.
const normalisedEndsOn =
oneShot === true && startsOn ? startsOn : endsOn;
const pausedAtPatch =
active === undefined
? {}
@@ -108,18 +142,51 @@ export const PUT = apiHandler(
...(dosesPerUnit !== undefined && { dosesPerUnit }),
...(active !== undefined && { active }),
...(notificationsEnabled !== undefined && { notificationsEnabled }),
// v1.5 scheduling primitives — pass-through when supplied.
// `startsOn` / `endsOn` are `Date | null | undefined` (the
// schema lets the user clear them explicitly with null).
...(startsOn !== undefined && { startsOn }),
...(normalisedEndsOn !== undefined && { endsOn: normalisedEndsOn }),
...(oneShot !== undefined && { oneShot }),
...(schedules && {
schedules: {
create: schedules.map((s) => ({
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label ?? null,
dose: s.dose ?? null,
daysOfWeek: serializeScheduleRecurrence({
daysOfWeek: s.daysOfWeek ?? [],
intervalWeeks: s.intervalWeeks ?? 1,
}),
})),
create: schedules.map((s) => {
// Invariant 2 — default to FREQ=DAILY when nothing else is set.
const hasLegacyDays = (s.daysOfWeek?.length ?? 0) > 0;
const defaultedRrule =
oneShot !== true &&
s.rrule === undefined &&
s.rollingIntervalDays === undefined &&
!hasLegacyDays
? "FREQ=DAILY"
: s.rrule;
// Invariant 3 — dual-write timesOfDay from windowStart.
const effectiveTimesOfDay =
s.timesOfDay && s.timesOfDay.length > 0
? s.timesOfDay
: [s.windowStart];
return {
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label ?? null,
dose: s.dose ?? null,
daysOfWeek: serializeScheduleRecurrence({
daysOfWeek: s.daysOfWeek ?? [],
intervalWeeks: s.intervalWeeks ?? 1,
}),
// v1.5 first-class times-of-day.
timesOfDay: effectiveTimesOfDay,
...(s.reminderGraceMinutes !== undefined && {
reminderGraceMinutes: s.reminderGraceMinutes,
}),
...(defaultedRrule !== undefined && { rrule: defaultedRrule }),
...(s.rollingIntervalDays !== undefined && {
rollingIntervalDays: s.rollingIntervalDays,
}),
};
}),
},
}),
};
@@ -13,6 +13,9 @@ vi.mock("@/lib/db", () => ({
medicationCategoryAssignment: {
findMany: vi.fn().mockResolvedValue([]),
},
medicationSchedule: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
$transaction: vi.fn(),
},
}));
@@ -54,6 +57,7 @@ vi.mock("next/headers", () => ({
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
@@ -72,6 +76,31 @@ function postReq(body: unknown): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// Default happy-path stub: return the data shape the caller passed in
// so test assertions can inspect what the route actually forwarded
// to Prisma without re-implementing the create-returning DB.
vi.mocked(prisma.medication.create).mockImplementation((async (
args: { data: Record<string, unknown> },
) => ({
id: "med-1",
userId: "user-1",
...args.data,
// Reflect the nested `schedules.create` as a populated array so
// the include returns the same shape as Prisma.
schedules: Array.isArray(
(args.data as { schedules?: { create?: unknown[] } }).schedules?.create,
)
? (
args.data as {
schedules: { create: Record<string, unknown>[] };
}
).schedules.create.map((s, i) => ({
id: `sched-${i}`,
medicationId: "med-1",
...s,
}))
: [],
})) as never);
});
describe("POST /api/medications — 422 multi-issue (v1.4.43 W6)", () => {
@@ -106,3 +135,166 @@ describe("POST /api/medications — 422 multi-issue (v1.4.43 W6)", () => {
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
describe("POST /api/medications — v1.5 scheduling primitives", () => {
/**
* Helper to extract the `data.schedules.create[i]` payload the route
* forwarded to Prisma.create — that's the single point of truth this
* test suite cares about.
*/
function lastCreateCall(): {
data: Record<string, unknown> & {
schedules: { create: Record<string, unknown>[] };
};
} {
const calls = vi.mocked(prisma.medication.create).mock.calls;
expect(calls.length).toBeGreaterThan(0);
return calls[calls.length - 1][0] as never;
}
it("(1) one-shot create writes oneShot=true + single timesOfDay schedule", async () => {
const res = await POST(
postReq({
name: "Flu shot",
dose: "1 shot",
oneShot: true,
startsOn: "2026-10-15",
schedules: [
{ windowStart: "10:00", windowEnd: "10:30", timesOfDay: ["10:00"] },
],
}),
);
expect(res.status).toBe(201);
const call = lastCreateCall();
expect(call.data.oneShot).toBe(true);
// endsOn auto-normalised to startsOn for one-shot.
expect(call.data.endsOn).toBeInstanceOf(Date);
expect((call.data.endsOn as Date).toISOString()).toEqual(
(call.data.startsOn as Date).toISOString(),
);
const s = call.data.schedules.create[0];
expect(s.timesOfDay).toEqual(["10:00"]);
expect(s.rrule).toBeUndefined();
expect(s.rollingIntervalDays).toBeUndefined();
});
it("(2) RRULE schedule is persisted verbatim", async () => {
const res = await POST(
postReq({
name: "Monthly med",
dose: "1 tab",
schedules: [
{
windowStart: "08:00",
windowEnd: "08:30",
rrule: "FREQ=MONTHLY;BYMONTHDAY=1",
timesOfDay: ["08:00"],
},
],
}),
);
expect(res.status).toBe(201);
const s = lastCreateCall().data.schedules.create[0];
expect(s.rrule).toBe("FREQ=MONTHLY;BYMONTHDAY=1");
expect(s.timesOfDay).toEqual(["08:00"]);
});
it("(3) rolling schedule is persisted with rollingIntervalDays", async () => {
const res = await POST(
postReq({
name: "Mounjaro",
dose: "5mg",
schedules: [
{
windowStart: "09:00",
windowEnd: "09:30",
rollingIntervalDays: 7,
timesOfDay: ["09:00"],
},
],
}),
);
expect(res.status).toBe(201);
const s = lastCreateCall().data.schedules.create[0];
expect(s.rollingIntervalDays).toBe(7);
expect(s.rrule).toBeUndefined();
expect(s.timesOfDay).toEqual(["09:00"]);
});
it("(4) 422 when oneShot=true AND schedule carries recurrence", async () => {
const res = await POST(
postReq({
name: "Bad one-shot",
dose: "1",
oneShot: true,
startsOn: "2026-10-15",
schedules: [
{
windowStart: "10:00",
windowEnd: "10:30",
rrule: "FREQ=DAILY",
},
],
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as { error: string };
expect(body.error).toMatch(/one-shot.*recurrence/i);
});
it("(5) 422 when oneShot=true AND multiple schedules", async () => {
const res = await POST(
postReq({
name: "Bad one-shot",
dose: "1",
oneShot: true,
startsOn: "2026-10-15",
schedules: [
{ windowStart: "08:00", windowEnd: "08:30" },
{ windowStart: "20:00", windowEnd: "20:30" },
],
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as { error: string };
expect(body.error).toMatch(/one-shot.*one schedule/i);
});
it("(7) legacy POST without new fields back-fills timesOfDay from windowStart", async () => {
const res = await POST(
postReq({
name: "Daily vitamin",
dose: "1 tab",
schedules: [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: [1, 2, 3, 4, 5],
},
],
}),
);
expect(res.status).toBe(201);
const s = lastCreateCall().data.schedules.create[0];
// timesOfDay populated server-side from windowStart.
expect(s.timesOfDay).toEqual(["08:00"]);
// Legacy daysOfWeek serialised; new rrule not stamped because
// legacy days were supplied.
expect(s.daysOfWeek).toBe("1,2,3,4,5");
expect(s.rrule).toBeUndefined();
});
it("(7b) legacy POST with no daysOfWeek, no rrule, no rolling → defaults rrule=FREQ=DAILY", async () => {
const res = await POST(
postReq({
name: "Plain daily",
dose: "1 tab",
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
}),
);
expect(res.status).toBe(201);
const s = lastCreateCall().data.schedules.create[0];
expect(s.rrule).toBe("FREQ=DAILY");
expect(s.timesOfDay).toEqual(["08:00"]);
});
});
+98 -12
View File
@@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
@@ -114,8 +115,61 @@ export const POST = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error, 422);
}
const { name, dose, category, treatmentClass, dosesPerUnit, schedules } =
parsed.data;
const {
name,
dose,
category,
treatmentClass,
dosesPerUnit,
schedules,
startsOn,
endsOn,
oneShot,
} = parsed.data;
// ── v1.5 route invariants for the new scheduling primitives ───────
//
// The Zod layer already enforces `rrule` xor `rollingIntervalDays`
// (scheduleSchema.refine). The route layer enforces the cross-field
// invariants that span medication + schedules:
//
// 1. **One-shot consistency** — when `oneShot === true`:
// a. The medication has at most ONE schedule.
// b. The single schedule carries neither `rrule` nor
// `rollingIntervalDays` (a one-shot dose IS the schedule).
// c. `endsOn` is normalised to equal `startsOn` on write so the
// worker's date-range cap matches the one-and-only slot.
//
// 2. **Recurring default** — when `oneShot !== true` AND the
// schedule has no `rrule`, no `rollingIntervalDays`, and no
// legacy `daysOfWeek`, the route stamps `rrule = "FREQ=DAILY"`
// so the canonical recurrence engine has a clean shape going
// forward instead of falling through to the legacy parser.
//
// 3. **iOS contract dual-write** — when `timesOfDay` is absent or
// empty, the route stamps `[windowStart]` so the new engine
// always sees a populated `timesOfDay` while legacy iOS clients
// (v0.6.x) keep encoding the `windowStart` / `windowEnd` /
// `daysOfWeek` shape. Both shapes coexist through v1.5.x.
if (oneShot === true) {
if (schedules.length > 1) {
return apiError(
"A one-shot medication can have at most one schedule",
422,
);
}
const s = schedules[0];
if (s.rrule !== undefined || s.rollingIntervalDays !== undefined) {
return apiError(
"A one-shot medication cannot have a recurrence (rrule or rollingIntervalDays)",
422,
);
}
}
// Normalise endsOn for one-shot. Zod gives us `Date | null | undefined`.
const normalisedEndsOn =
oneShot === true && startsOn ? startsOn : (endsOn ?? undefined);
const medication = await prisma.medication.create({
data: {
@@ -126,17 +180,49 @@ export const POST = apiHandler(async (request: NextRequest) => {
// wire schema; Prisma fills the default GENERIC when omitted.
...(treatmentClass !== undefined && { treatmentClass }),
...(dosesPerUnit !== undefined && { dosesPerUnit }),
// v1.5 scheduling primitives — pass-through when supplied.
...(startsOn !== undefined && { startsOn }),
...(normalisedEndsOn !== undefined && { endsOn: normalisedEndsOn }),
...(oneShot !== undefined && { oneShot }),
schedules: {
create: schedules.map((s) => ({
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label ?? null,
dose: s.dose ?? null,
daysOfWeek: serializeScheduleRecurrence({
daysOfWeek: s.daysOfWeek ?? [],
intervalWeeks: s.intervalWeeks ?? 1,
}),
})),
create: schedules.map((s) => {
// Invariant 2 — default to FREQ=DAILY when nothing else is set.
const hasLegacyDays = (s.daysOfWeek?.length ?? 0) > 0;
const defaultedRrule =
!oneShot &&
s.rrule === undefined &&
s.rollingIntervalDays === undefined &&
!hasLegacyDays
? "FREQ=DAILY"
: s.rrule;
// Invariant 3 — dual-write `timesOfDay` from windowStart when
// the legacy-shape iOS client doesn't send the new field.
const effectiveTimesOfDay =
s.timesOfDay && s.timesOfDay.length > 0
? s.timesOfDay
: [s.windowStart];
return {
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label ?? null,
dose: s.dose ?? null,
daysOfWeek: serializeScheduleRecurrence({
daysOfWeek: s.daysOfWeek ?? [],
intervalWeeks: s.intervalWeeks ?? 1,
}),
// v1.5 first-class times-of-day.
timesOfDay: effectiveTimesOfDay,
...(s.reminderGraceMinutes !== undefined && {
reminderGraceMinutes: s.reminderGraceMinutes,
}),
...(defaultedRrule !== undefined && { rrule: defaultedRrule }),
...(s.rollingIntervalDays !== undefined && {
rollingIntervalDays: s.rollingIntervalDays,
}),
};
}),
},
},
include: { schedules: true },