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

@@ -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")),
);
});
});
@@ -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<void> {
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 {