diff --git a/src/app/api/medications/[id]/glp1/route.ts b/src/app/api/medications/[id]/glp1/route.ts index b6523afd9..ab3de40cb 100644 --- a/src/app/api/medications/[id]/glp1/route.ts +++ b/src/app/api/medications/[id]/glp1/route.ts @@ -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); }, ); diff --git a/src/app/api/medications/[id]/inventory/[itemId]/route.ts b/src/app/api/medications/[id]/inventory/[itemId]/route.ts index 99ce9346d..f8da4a14e 100644 --- a/src/app/api/medications/[id]/inventory/[itemId]/route.ts +++ b/src/app/api/medications/[id]/inventory/[itemId]/route.ts @@ -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 }); }, ); diff --git a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts index d27144f42..1b0bea45e 100644 --- a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts @@ -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, + }); }); }); diff --git a/src/app/api/medications/[id]/inventory/route.ts b/src/app/api/medications/[id]/inventory/route.ts index 79bbc9b95..68978f3c1 100644 --- a/src/app/api/medications/[id]/inventory/route.ts +++ b/src/app/api/medications/[id]/inventory/route.ts @@ -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); }, ); diff --git a/src/components/medications/sections/__tests__/inventory-section.test.tsx b/src/components/medications/sections/__tests__/inventory-section.test.tsx new file mode 100644 index 000000000..e5f106c55 --- /dev/null +++ b/src/components/medications/sections/__tests__/inventory-section.test.tsx @@ -0,0 +1,43 @@ +/** + * Supply-staleness regression — the medications-list card / table read + * the dose-derived stock (`stockUnitsRemaining` / `stockDosesRemaining`) + * straight from the `GET /api/medications` list payload. The add / adjust / + * delete supply mutations used to invalidate only the per-medication + * inventory query, leaving the list query (`queryKeys.medications()`) + * serving the pre-write stock — so the card kept showing the old supply + * until an unrelated refetch landed. + * + * `invalidateSupplyQueries` is the single helper all three mutations call; + * this pins that it drops BOTH the list key (the card / table source) and + * the per-medication inventory key. The repo's component-test convention + * avoids `@testing-library/react` DOM interaction, so the mutation's + * invalidation contract is asserted through the extracted helper against a + * minimal QueryClient stand-in. + */ + +import { describe, expect, it, vi } from "vitest"; + +import { invalidateSupplyQueries } from "@/components/medications/sections/inventory-section"; +import { queryKeys } from "@/lib/query-keys"; + +describe("invalidateSupplyQueries — supply staleness regression", () => { + it("invalidates the medications LIST key AND the per-medication inventory key", async () => { + const invalidateQueries = vi.fn().mockResolvedValue(undefined); + const queryClient = { invalidateQueries } as unknown as Parameters< + typeof invalidateSupplyQueries + >[0]; + + await invalidateSupplyQueries(queryClient, "med-1"); + + const invalidatedKeys = invalidateQueries.mock.calls.map((call) => + JSON.stringify((call[0] as { queryKey: unknown }).queryKey), + ); + + // The card / table source — the regression was this key being absent. + expect(invalidatedKeys).toContain(JSON.stringify(queryKeys.medications())); + // The per-medication supply tab still refetches too. + expect(invalidatedKeys).toContain( + JSON.stringify(queryKeys.medicationInventory("med-1")), + ); + }); +}); diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx index 28a769047..9018ea169 100644 --- a/src/components/medications/sections/inventory-section.tsx +++ b/src/components/medications/sections/inventory-section.tsx @@ -37,7 +37,11 @@ import { useState, type FormEvent } from "react"; import Link from "next/link"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + useQuery, + useQueryClient, + type QueryClient, +} from "@tanstack/react-query"; import { toast } from "sonner"; import { Loader2, PackageOpen, Plus } from "lucide-react"; @@ -74,6 +78,31 @@ import { } from "@/lib/api/api-fetch"; import { summariseSupply } from "@/lib/medications/inventory/summary"; +/** + * Invalidate every read key whose payload reflects a medication's supply + * after a container write (register / adjust / delete). + * + * The per-medication inventory read (`medicationInventory`) is the supply + * tab's own list. The medications LIST read (`medications`) carries the + * dose-derived stock (`stockUnitsRemaining` / `stockDosesRemaining`) that + * the card and table render — without invalidating it, the card kept + * showing the pre-write stock until an unrelated refetch landed (the + * supply-staleness bug). Both keys must drop together. + */ +export async function invalidateSupplyQueries( + queryClient: QueryClient, + medicationId: string, +): Promise { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: queryKeys.medicationInventory(medicationId), + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.medications(), + }), + ]); +} + type InventoryState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP"; const CONTAINER_TYPES = [ @@ -138,9 +167,7 @@ export function InventorySection({ async function deleteItem(item: InventoryItem) { try { await apiDelete(`/api/medications/${medicationId}/inventory/${item.id}`); - await queryClient.invalidateQueries({ - queryKey: queryKeys.medicationInventory(medicationId), - }); + await invalidateSupplyQueries(queryClient, medicationId); toast.success(t("medications.detail.bestand.deleteSuccess")); } catch { toast.error(t("medications.detail.bestand.deleteFailed")); @@ -492,9 +519,7 @@ export function AddInventoryDialog({ ? new Date(`${expiry}T00:00:00`).toISOString() : null, }); - await queryClient.invalidateQueries({ - queryKey: queryKeys.medicationInventory(medicationId), - }); + await invalidateSupplyQueries(queryClient, medicationId); toast.success(t("medications.detail.bestand.addSuccess")); onClose(); } catch { @@ -694,9 +719,7 @@ function AdjustInventoryDialog({ // The wire field carries UNITS (v1.16.10 symmetric naming). unitsRemaining: parsed, }); - await queryClient.invalidateQueries({ - queryKey: queryKeys.medicationInventory(medicationId), - }); + await invalidateSupplyQueries(queryClient, medicationId); toast.success(t("medications.detail.bestand.adjustSuccess")); onClose(); } catch {