mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
1 parent
8c740fd146
commit
8adf1b55e8
4 files changed
+158
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user