feat(medications): an api tab, a stockable inventory and a next-due that tells the truth

The detail page gains a dedicated API tab, the full-edit button becomes
a lifecycle row that opens the editor, the danger zone drops its red
wash in favour of the shared card chrome, and the card menu's edit
entry leads to the detail page it describes. The inventory tab finally
records stock — packs and pens with optional expiry, plus corrections —
instead of pointing at an API. The card's next-dose line reads from the
canonical band model instead of a stale schedule window, which also
showed old dose times after a schedule change; the repair script learns
to reconcile drifted windows and tombstone pending rows parked on
abandoned anchors.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 20:48:46 +02:00
1 parent a2524b6d70
commit 258e2fd365
22 files changed
+1373 -221

No files matched your search

@@ -16,6 +16,7 @@ vi.mock("@/lib/db", () => ({
update: vi.fn(),
},
medicationIntakeEvent: {
findMany: vi.fn().mockResolvedValue([]),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
apiToken: {
@@ -32,6 +33,13 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: vi.fn(),
}));
// v1.16.1 — the schedule-replace branch recomputes the compliance
// rollups of the tombstoned slots; stub the rollup module so the route
// test never reaches `$executeRaw`.
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
dayKeyForScheduledFor: vi.fn(() => "2026-06-10"),
recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/medication-category", () => ({
deleteMedicationCategory: vi.fn().mockResolvedValue(undefined),
getMedicationCategories: vi.fn().mockResolvedValue({}),
@@ -55,6 +63,10 @@ import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { getMedicationCategories } from "@/lib/medication-category";
import { auditLog } from "@/lib/auth/audit";
import {
dayKeyForScheduledFor,
recomputeMedicationComplianceForDay,
} from "@/lib/rollups/medication-compliance-rollups";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -78,6 +90,17 @@ beforeEach(() => {
id: "m1",
userId: "user-1",
} as never);
// `resetAllMocks` clears the factory-level resolved values, so the
// schedule-replace reads need fresh defaults each test.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({
count: 0,
} as never);
vi.mocked(prisma.medicationSchedule.deleteMany).mockResolvedValue({
count: 0,
} as never);
});
describe("PUT /api/medications/[id] — 422 multi-issue (v1.4.43 W6)", () => {
@@ -230,6 +253,40 @@ describe("PUT /api/medications/[id] — schedule replace migrates open slots", (
expect(arg.data.syncVersion).toEqual({ increment: 1 });
});
it("recomputes the compliance rollups of the tombstoned days (v1.16.1)", async () => {
// Two pendings on the same local day + one on another day → exactly
// two distinct day-key recomputes, not three.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{ scheduledFor: new Date("2026-06-10T06:00:00Z") },
{ scheduledFor: new Date("2026-06-10T18:00:00Z") },
{ scheduledFor: new Date("2026-06-11T06:00:00Z") },
] as never);
vi.mocked(dayKeyForScheduledFor).mockImplementation(
(scheduledFor: Date) => scheduledFor.toISOString().slice(0, 10),
);
const res = await PUT(
putReq({
schedules: [{ windowStart: "20:00", windowEnd: "20:30" }],
}),
ROUTE_CTX,
);
expect(res.status).toBe(200);
expect(recomputeMedicationComplianceForDay).toHaveBeenCalledTimes(2);
expect(recomputeMedicationComplianceForDay).toHaveBeenCalledWith(
"user-1",
"m1",
"2026-06-10",
"Europe/Berlin",
);
expect(recomputeMedicationComplianceForDay).toHaveBeenCalledWith(
"user-1",
"m1",
"2026-06-11",
"Europe/Berlin",
);
});
it("does NOT touch intake rows when the body carries no schedules array", async () => {
const res = await PUT(putReq({ name: "Renamed" }), ROUTE_CTX);
expect(res.status).toBe(200);
@@ -439,7 +439,9 @@ describe("GET /api/medications/[id]/compliance — caching and limits", () => {
expect(vi.mocked(cached)).toHaveBeenCalledTimes(1);
const [, key] = vi.mocked(cached).mock.calls[0];
// `${userId}|...` prefix is what `invalidateUserMedications` sweeps.
expect(key).toBe("user-1|med-1|compliance");
// The trailing tz segment (v1.16.1) makes a timezone change miss the
// old entry instead of serving day buckets computed for the prior zone.
expect(key).toBe("user-1|med-1|compliance|UTC");
});
it("bounds the intake-event read to a trailing window and the consumed columns", async () => {
@@ -258,10 +258,14 @@ export const GET = apiHandler(
// Read-through the per-user compliance cache (15 min TTL). Every
// intake / medication write flushes the `${userId}|` prefix via
// `invalidateUserMedications`, so a warm entry can only be stale
// relative to wall-clock drift, never to a user action.
// relative to wall-clock drift, never to a user action. The key
// carries the timezone the payload was bucketed in: day keys, the
// heatmap, and the overdue cut all derive from `userTz`, so a
// timezone change must miss the old entry instead of serving day
// buckets computed for the previous zone until the TTL lapses.
const payload = await cached(
caches.medicationCompliance as ServerCache<CompliancePayload>,
`${user.id}|${id}|compliance`,
`${user.id}|${id}|compliance|${userTz}`,
() => buildCompliancePayload(medication, user.id, userTz),
annotate,
);
@@ -1,6 +1,10 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on PATCH
* /api/medications/[id]/inventory/[itemId].
*
* v1.16.1 — stock-correction contract: `dosesRemaining` sets the count
* absolutely, clamps to `dosesTotal`, and the canonical state machine
* derives the next state (0 ⇒ USED_UP).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
@@ -124,3 +128,55 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — 422 multi-issue (v1
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
describe("PATCH /api/medications/[id]/inventory/[itemId] — dosesRemaining stock correction (v1.16.1)", () => {
beforeEach(() => {
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
id: "i1",
medicationId: "m1",
userId: "user-1",
firstUseAt: null,
state: "IN_USE",
dosesTotal: 4,
dosesRemaining: 3,
printedExpiry: null,
notes: null,
} as never);
vi.mocked(prisma.medicationInventoryItem.update).mockImplementation(
(async (args: { data: Record<string, unknown> }) => ({
id: "i1",
...args.data,
})) as never,
);
});
it("sets the remaining count absolutely", async () => {
const res = await PATCH(patchReq({ dosesRemaining: 1 }), ROUTE_CTX);
expect(res.status).toBe(200);
const update = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0][0] as { data: { dosesRemaining: number } };
expect(update.data.dosesRemaining).toBe(1);
});
it("derives USED_UP when corrected to zero", async () => {
const res = await PATCH(patchReq({ dosesRemaining: 0 }), ROUTE_CTX);
expect(res.status).toBe(200);
const update = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0][0] as { data: { dosesRemaining: number; state: string } };
expect(update.data.dosesRemaining).toBe(0);
expect(update.data.state).toBe("USED_UP");
});
it("clamps a raise above the item's capacity to dosesTotal", async () => {
const res = await PATCH(patchReq({ dosesRemaining: 99 }), ROUTE_CTX);
expect(res.status).toBe(200);
const update = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0][0] as { data: { dosesRemaining: number } };
expect(update.data.dosesRemaining).toBe(4);
});
it("rejects a negative correction with 422", async () => {
const res = await PATCH(patchReq({ dosesRemaining: -1 }), ROUTE_CTX);
expect(res.status).toBe(422);
});
});
@@ -6,6 +6,9 @@
* - mark-as-used-up (terminal — operator override when a pen is
* physically discarded but the dose ledger hasn't reached zero)
* - update printed expiry (e.g. carton label correction)
* - stock correction via `dosesRemaining` (v1.16.1 — the Bestand
* tab's adjust / withdraw flow; clamped to `dosesTotal`, state
* re-derived by the canonical state machine)
* - update notes
*
* DELETE /api/medications/[id]/inventory/[itemId]
@@ -67,8 +70,13 @@ export const PATCH = apiHandler(
return returnAllZodIssues(parsed.error, 422);
}
const { markAsFirstUseAt, markAsUsedUp, printedExpiry, notes } =
parsed.data;
const {
markAsFirstUseAt,
markAsUsedUp,
printedExpiry,
dosesRemaining,
notes,
} = parsed.data;
// Compose the next-row shape. Each mutation field is optional and
// commutative — applying them in any order produces the same row.
@@ -88,6 +96,14 @@ export const PATCH = apiHandler(
nextPrintedExpiry = printedExpiry;
}
if (dosesRemaining !== undefined) {
// v1.16.1 — stock correction (Bestand tab adjust / withdraw).
// Clamp to the item's capacity; the state-machine re-run below
// owns every state consequence (0 ⇒ USED_UP, a raise out of 0
// re-evaluates against the expiry clocks).
nextDosesRemaining = Math.min(dosesRemaining, existing.dosesTotal);
}
if (markAsUsedUp === true) {
nextDosesRemaining = 0;
nextState = "USED_UP";
+45 -13
View File
@@ -18,6 +18,10 @@ import {
} from "@/lib/medication-category";
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
import { computeNextDueAt } from "@/lib/medications/scheduling/next-due";
import {
dayKeyForScheduledFor,
recomputeMedicationComplianceForDay,
} from "@/lib/rollups/medication-compliance-rollups";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { getUserTodayBounds } from "@/lib/timezone";
import { NextRequest } from "next/server";
@@ -287,22 +291,50 @@ export const PUT = apiHandler(
// `syncVersion` so delta-sync clients drop them. The projector and
// the reminder worker re-mint the anchors for the new times on their
// next pass — the route does not pre-create them.
const { start: todayStart } = getUserTodayBounds(
new Date(),
user.timezone || "Europe/Berlin",
);
const userTz = user.timezone || "Europe/Berlin";
const { start: todayStart } = getUserTodayBounds(new Date(), userTz);
const tombstoneWhere = {
userId: user.id,
medicationId: id,
deletedAt: null,
takenAt: null,
skipped: false,
autoMissed: false,
scheduledFor: { gte: todayStart },
} as const;
// Capture the affected slots BEFORE the tombstone so the compliance
// rollups for those days can be recomputed immediately below —
// `updateMany` does not return the touched rows. Without the
// recompute a tombstoned pending kept counting as `scheduled` in
// the rollup row until the next event-driven write for that day,
// depressing the rate the cards read in the meantime.
const tombstoned = await prisma.medicationIntakeEvent.findMany({
where: tombstoneWhere,
select: { scheduledFor: true },
});
await prisma.medicationIntakeEvent.updateMany({
where: {
userId: user.id,
medicationId: id,
deletedAt: null,
takenAt: null,
skipped: false,
autoMissed: false,
scheduledFor: { gte: todayStart },
},
where: tombstoneWhere,
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
// Recompute each affected rollup day (deduped via the user-tz day
// key). Best-effort like every other rollup write-hook: a failure
// is annotated, never blocks the schedule replace.
const staleDayKeys = new Set(
tombstoned.map((row) => dayKeyForScheduledFor(row.scheduledFor, userTz)),
);
for (const dayKey of staleDayKeys) {
try {
await recomputeMedicationComplianceForDay(user.id, id, dayKey, userTz);
} catch (err) {
annotate({
meta: {
medication_compliance_rollup_failed: true,
medication_compliance_rollup_error:
err instanceof Error ? err.message : String(err),
},
});
}
}
await prisma.medicationSchedule.deleteMany({
where: { medicationId: id },
});