From 925138eccbf68509e7179f407f5b078bb17e4be9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 28 May 2026 08:22:17 +0200 Subject: [PATCH] feat(meds): plumb v1.5 scheduling fields through POST + PUT routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../medications/[id]/__tests__/route.test.ts | 73 +++++++ src/app/api/medications/[id]/route.ts | 87 +++++++- .../api/medications/__tests__/route.test.ts | 192 ++++++++++++++++++ src/app/api/medications/route.ts | 110 ++++++++-- 4 files changed, 440 insertions(+), 22 deletions(-) diff --git a/src/app/api/medications/[id]/__tests__/route.test.ts b/src/app/api/medications/[id]/__tests__/route.test.ts index 4d89fbaa3..a5a742ade 100644 --- a/src/app/api/medications/[id]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/__tests__/route.test.ts @@ -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; + } { + 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); + }); +}); diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 5e7ef43d5..5e40076a4 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -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, + }), + }; + }), }, }), }; diff --git a/src/app/api/medications/__tests__/route.test.ts b/src/app/api/medications/__tests__/route.test.ts index 7e0569ee1..f8c6983af 100644 --- a/src/app/api/medications/__tests__/route.test.ts +++ b/src/app/api/medications/__tests__/route.test.ts @@ -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 }, + ) => ({ + 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[] }; + } + ).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 & { + schedules: { create: Record[] }; + }; + } { + 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"]); + }); +}); diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 80fa19930..cc0dfdce8 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -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 },