From 8adf1b55e8c8a58211a39076364c38847310268e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 29 May 2026 00:26:05 +0200 Subject: [PATCH] fix(meds): grace-row save lands on the primary schedule The detail page settings section PUTs reminderGraceMinutes at the top level, but the schema only accepted it per-schedule, returning 422 on every save. The route now maps a top-level grace value onto the primary schedule's reminderGraceMinutes before the Prisma update; the schema declares the top-level field with a description noting the normalisation. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/api/openapi.yaml | 8 ++ .../medications/[id]/__tests__/route.test.ts | 80 +++++++++++++++++++ src/app/api/medications/[id]/route.ts | 50 ++++++++++++ src/lib/validations/medication.ts | 20 +++++ 4 files changed, 158 insertions(+) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9ef425aa1..40bfda4e7 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1999,6 +1999,14 @@ components: type: array items: $ref: "#/components/schemas/MedicationScheduleInput" + reminderGraceMinutes: + description: "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." + anyOf: + - type: integer + minimum: 0 + maximum: 1440 + - type: "null" 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 diff --git a/src/app/api/medications/[id]/__tests__/route.test.ts b/src/app/api/medications/[id]/__tests__/route.test.ts index a5a742ade..a4ad7410f 100644 --- a/src/app/api/medications/[id]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/__tests__/route.test.ts @@ -12,6 +12,8 @@ vi.mock("@/lib/db", () => ({ }, medicationSchedule: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + findFirst: vi.fn(), + update: vi.fn(), }, apiToken: { updateMany: vi.fn().mockResolvedValue({ count: 0 }), @@ -173,3 +175,81 @@ describe("PUT /api/medications/[id] — v1.5 scheduling primitives", () => { expect(res.status).toBe(422); }); }); + +describe("PUT /api/medications/[id] — primary-schedule grace bridge", () => { + beforeEach(() => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "m1", + userId: "user-1", + active: true, + schedules: [ + { + id: "sched-primary", + windowStart: "08:00", + windowEnd: "08:30", + }, + ], + } as never); + vi.mocked(prisma.medication.update).mockResolvedValue({ + id: "m1", + userId: "user-1", + schedules: [ + { + id: "sched-primary", + windowStart: "08:00", + windowEnd: "08:30", + }, + ], + } as never); + vi.mocked(getMedicationCategories).mockResolvedValue({}); + vi.mocked(auditLog).mockResolvedValue(undefined); + vi.mocked(prisma.medicationSchedule.findFirst).mockResolvedValue({ + id: "sched-primary", + } as never); + vi.mocked(prisma.medicationSchedule.update).mockResolvedValue({ + id: "sched-primary", + reminderGraceMinutes: 45, + } as never); + }); + + it("lands a top-level reminderGraceMinutes value on the primary schedule", async () => { + const res = await PUT(putReq({ reminderGraceMinutes: 45 }), ROUTE_CTX); + expect(res.status).toBe(200); + expect(prisma.medicationSchedule.findFirst).toHaveBeenCalledWith({ + where: { medicationId: "m1" }, + orderBy: { windowStart: "asc" }, + select: { id: true }, + }); + expect(prisma.medicationSchedule.update).toHaveBeenCalledWith({ + where: { id: "sched-primary" }, + data: { reminderGraceMinutes: 45 }, + }); + }); + + it("clears the override when the user passes null", async () => { + vi.mocked(prisma.medicationSchedule.update).mockResolvedValue({ + id: "sched-primary", + reminderGraceMinutes: null, + } as never); + const res = await PUT(putReq({ reminderGraceMinutes: null }), ROUTE_CTX); + expect(res.status).toBe(200); + expect(prisma.medicationSchedule.update).toHaveBeenCalledWith({ + where: { id: "sched-primary" }, + data: { reminderGraceMinutes: null }, + }); + }); + + it("skips the schedule bridge when the body carries a full schedules array", async () => { + const res = await PUT( + putReq({ + reminderGraceMinutes: 45, + schedules: [{ windowStart: "08:00", windowEnd: "08:30" }], + }), + ROUTE_CTX, + ); + expect(res.status).toBe(200); + expect(prisma.medicationSchedule.findFirst).not.toHaveBeenCalled(); + expect(prisma.medicationSchedule.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index fa45ef740..3a7f39384 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -97,8 +97,37 @@ export const PUT = apiHandler( startsOn, endsOn, oneShot, + reminderGraceMinutes: topLevelGraceMinutes, } = parsed.data; + // ── v1.5.5 — primary-schedule grace bridge ────────────────────── + // + // The detail-page settings section saves the reminder-window in + // one PUT carrying only `{ reminderGraceMinutes }` at the top + // level (no full `schedules` array). Map it onto the medication's + // primary schedule before the Prisma update so the persisted + // shape stays per-schedule and the engine reads it from the same + // column as the wizard write-path. A full `schedules` array on + // the same request takes precedence — the wizard already declares + // the grace per row and we never want to overwrite that intent. + let primaryScheduleGracePatch: { + scheduleId: string; + reminderGraceMinutes: number | null; + } | null = null; + if (topLevelGraceMinutes !== undefined && !schedules) { + const primary = await prisma.medicationSchedule.findFirst({ + where: { medicationId: id }, + orderBy: { windowStart: "asc" }, + select: { id: true }, + }); + if (primary) { + primaryScheduleGracePatch = { + scheduleId: primary.id, + reminderGraceMinutes: topLevelGraceMinutes, + }; + } + } + // ── v1.5 route invariants for the new scheduling primitives ───── // // Mirrors the POST route: oneShot ⇒ at-most-one schedule + no @@ -239,6 +268,27 @@ export const PUT = apiHandler( } if (!medication) throw lastUpdateErr; + // Apply the v1.5.5 primary-schedule grace bridge after the + // medication update so a failure here doesn't roll the medication + // edit back. Re-read the row so the response shape carries the + // converged grace value. + if (primaryScheduleGracePatch) { + await prisma.medicationSchedule.update({ + where: { id: primaryScheduleGracePatch.scheduleId }, + data: { + reminderGraceMinutes: + primaryScheduleGracePatch.reminderGraceMinutes, + }, + }); + const refreshed = await prisma.medication.findUnique({ + where: { id }, + include: { schedules: true }, + }); + if (refreshed) { + medication = refreshed; + } + } + const normalizedCategory = category !== undefined ? await setMedicationCategory(id, category) diff --git a/src/lib/validations/medication.ts b/src/lib/validations/medication.ts index f2668925c..8f075549f 100644 --- a/src/lib/validations/medication.ts +++ b/src/lib/validations/medication.ts @@ -258,6 +258,26 @@ export const updateMedicationSchema = z notificationsEnabled: z.boolean().optional(), ...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",