fix(medications): evict supply caches so the card reflects stock at once

The /medications card and table render the dose-derived stock
(stockUnitsRemaining / stockDosesRemaining) straight from the
GET /api/medications list payload, which reads through cachedSwr on the
60s-fresh + 10-min-stale medications bucket. Registering, adjusting, or
deleting an inventory container changes that stock, but the inventory
routes never invalidated any cache — so the list served the pre-write
stock for the rest of the stale window. On the client the add / adjust /
delete supply mutations only invalidated the per-medication inventory
query, never the medications-list query that the card reads, so a mounted
list never refetched even once the server caught up.

Hard-evict the per-user medications + compliance buckets from every
supply-affecting write (inventory POST, the per-item PATCH / DELETE, and
the deprecated glp1 POST dose-change + ledger branches) so the next read
reflects the change rather than a stale SWR value. On the client,
centralise the supply mutations on a shared invalidateSupplyQueries helper
that drops both the medications-list key and the per-medication inventory
key. The card's neutral styling is unchanged.
This commit is contained in:
Marc-André Bombeck committed 2026-06-19 21:08:35 +02:00
1 parent 572ff3502e
commit 6609ce0dc7
6 files changed
+130 -10

No files matched your search

@@ -38,6 +38,7 @@ import {
resolveLowStockRunwayDays,
resolveReorderLeadDays,
} from "@/lib/validations/notification-prefs";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
@@ -285,6 +286,11 @@ export const POST = apiHandler(
meta: { medication_id: id, doseValue, doseUnit },
});
// A dose change shifts the daily-dose estimate the list payload's
// runway derivation uses. Hard-evict the medications + compliance
// buckets so the card reflects the new runway on the next read.
invalidateUserMedications(user.id, { evict: true });
return apiSuccess({ doseChange: created }, 201);
}
@@ -324,6 +330,11 @@ export const POST = apiHandler(
meta: { medication_id: id, delta },
});
// A legacy ledger delta changes the stock the list payload reports
// for a medication with no per-item containers. Hard-evict so the
// card reflects it on the next read.
invalidateUserMedications(user.id, { evict: true });
return apiSuccess({ inventory: created }, 201);
},
);
@@ -36,6 +36,7 @@ import {
computeInventoryState,
} from "@/lib/medications/inventory/state-machine";
import { serializeInventoryItem } from "@/lib/medications/inventory/service";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
type RouteParams = { params: Promise<{ id: string; itemId: string }> };
@@ -170,6 +171,12 @@ export const PATCH = apiHandler(
meta: { medication_id: id, prev: existing.state, next: updated.state },
});
// A stock correction / first-use / used-up flip changes the
// dose-derived stock the medications-list payload carries, which the
// card and table render. Hard-evict so the supply shows on the next
// read instead of after the `cachedSwr` stale window.
invalidateUserMedications(user.id, { evict: true });
return apiSuccess(serializeInventoryItem(updated));
},
);
@@ -204,6 +211,11 @@ export const DELETE = apiHandler(
meta: { medication_id: id },
});
// Removing a container changes the dose-derived stock the
// medications-list payload carries. Hard-evict so the card / table
// reflect the drop on the very next read.
invalidateUserMedications(user.id, { evict: true });
return apiSuccess({ id: itemId, deleted: true });
},
);
@@ -31,6 +31,10 @@ vi.mock("@/lib/rate-limit", () => ({
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: vi.fn(),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
@@ -49,6 +53,7 @@ import { PATCH, DELETE } from "../[itemId]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -173,6 +178,13 @@ describe("POST /api/medications/[id]/inventory", () => {
expiresAt: new Date(printed),
}),
});
// Staleness regression: the dose-derived stock the medications-list
// payload carries must hard-evict on a registration so the card / table
// reflect the new supply on the very next read (not after the SWR
// stale window). Mark-stale would serve the pre-write stock.
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
evict: true,
});
});
it("returns 429 when the rate limit is exceeded", async () => {
@@ -271,6 +283,11 @@ describe("PATCH /api/medications/[id]/inventory/[itemId]", () => {
state: "USED_UP",
unitsRemaining: 0,
});
// Staleness regression: a stock-affecting correction must hard-evict
// the medications-list bucket so the card reflects the change next read.
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
evict: true,
});
});
it("flips ACTIVE → EXPIRED when markAsFirstUseAt is more than 30 days back-dated", async () => {
@@ -352,6 +369,11 @@ describe("DELETE /api/medications/[id]/inventory/[itemId]", () => {
expect(prisma.medicationInventoryItem.delete).toHaveBeenCalledWith({
where: { id: "inv-1" },
});
// Staleness regression: removing a container drops the dose-derived
// stock the list payload reports — hard-evict so it reflects next read.
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
evict: true,
});
});
});
@@ -33,6 +33,7 @@ import {
serializeInventoryItem,
} from "@/lib/medications/inventory/service";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
type RouteParams = { params: Promise<{ id: string }> };
@@ -134,6 +135,14 @@ export const POST = apiHandler(
meta: { medication_id: id },
});
// A registered container changes the dose-derived stock the
// medications-list payload carries (`stockUnitsRemaining` /
// `stockDosesRemaining`), which the card and table render. Hard-evict
// the per-user medications + compliance buckets so the supply shows on
// the very next read — a mark-stale would let the `cachedSwr` list
// serve the pre-write stock for the rest of the stale window.
invalidateUserMedications(user.id, { evict: true });
return apiSuccess(serializeInventoryItem(created), 201);
},
);