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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-29 00:53:23 +02:00
1 parent 8c740fd146
commit 8adf1b55e8
4 files changed
+158

No files matched your search

+8
View File
@@ -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
@@ -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();
});
});
+50
View File
@@ -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)
+20
View File
@@ -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",