mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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);
|
||||
},
|
||||
);
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user