mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
fix(medications): release-QA findings on the inventory overhaul
- The legacy GLP-1 ledger reads again: GET and the Coach snapshot fall back to ledger-derived pen/dose figures when a medication has no inventory items; items always win once they exist. The delta branch on the POST is marked deprecated in favour of the inventory endpoints. - Expired containers count as available nowhere: one shared supply summary (ACTIVE/IN_USE with units) feeds the overview row, the Bestand summary, the list payload, the GLP-1 card, and the runway estimate; expired units surface as a muted suffix instead. - The inventory endpoints speak one wire dialect: request fields renamed to unitsTotal/unitsRemaining to match the responses, OpenAPI regenerated. - The reorder dialog mirrors the page's grouping: active and inactive sections, each sortable within itself, so a saved order can never interleave them. - Polish strings avoid the 2-4 plural trap, sub-dose quantities render as below one dose instead of approximately zero, and the table's name sort collates by the active locale instead of hardcoded German.
This commit is contained in:
1 parent
b765febc97
commit
9dc0ccd03a
28 files changed
+1017
-180
No files matched your search
@@ -405,6 +405,7 @@ describe("GET /api/medications/[id]/glp1 — ownership helper (F-1 C-4)", () =>
|
||||
userId: "user-1",
|
||||
doseChanges: [],
|
||||
inventoryItems: [],
|
||||
inventoryEvents: [],
|
||||
intakeEvents: [],
|
||||
schedules: [],
|
||||
dosesPerUnit: null,
|
||||
@@ -455,6 +456,9 @@ describe("GET /api/medications/[id]/glp1 — v1.16.10 item-backed inventory", ()
|
||||
// Not usable: expired stock is not supply.
|
||||
{ state: "EXPIRED", unitsTotal: 4, unitsRemaining: 4 },
|
||||
],
|
||||
// Items exist, so the legacy ledger below must be IGNORED — a
|
||||
// stale delta history cannot override the per-item truth.
|
||||
inventoryEvents: [{ delta: 9 }],
|
||||
} as never);
|
||||
|
||||
const res = await GET(getReq(), {
|
||||
@@ -480,7 +484,7 @@ describe("GET /api/medications/[id]/glp1 — v1.16.10 item-backed inventory", ()
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a null inventory block when no items exist", async () => {
|
||||
it("returns a null inventory block when no items and no ledger rows exist", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
@@ -491,6 +495,7 @@ describe("GET /api/medications/[id]/glp1 — v1.16.10 item-backed inventory", ()
|
||||
dosesPerUnit: null,
|
||||
unitsPerDose: 1,
|
||||
inventoryItems: [],
|
||||
inventoryEvents: [],
|
||||
} as never);
|
||||
|
||||
const res = await GET(getReq(), {
|
||||
@@ -500,4 +505,68 @@ describe("GET /api/medications/[id]/glp1 — v1.16.10 item-backed inventory", ()
|
||||
const body = (await res.json()) as { data: { inventory: unknown } };
|
||||
expect(body.data.inventory).toBeNull();
|
||||
});
|
||||
|
||||
// W1 — the legacy delta ledger the POST below still accepts must stay
|
||||
// readable: an account that only ever posted `{inventory: {delta}}`
|
||||
// sees its pen count on GET instead of a silent null.
|
||||
it("falls back to the legacy ledger when the user has zero inventory items", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
userId: "user-1",
|
||||
doseChanges: [],
|
||||
intakeEvents: [],
|
||||
schedules: [],
|
||||
dosesPerUnit: 4,
|
||||
unitsPerDose: 1,
|
||||
inventoryItems: [],
|
||||
// Running sum: +2 purchased, −1 used ⇒ 1 pen ⇒ 4 doses.
|
||||
inventoryEvents: [{ delta: 2 }, { delta: -1 }],
|
||||
} as never);
|
||||
|
||||
const res = await GET(getReq(), {
|
||||
params: Promise.resolve({ id: "med-1" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
inventory: {
|
||||
pensRemaining: number;
|
||||
dosesRemaining: number;
|
||||
weeksOfSupply: number;
|
||||
lowStock: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(body.data.inventory).toEqual({
|
||||
pensRemaining: 1,
|
||||
dosesRemaining: 4,
|
||||
weeksOfSupply: 4,
|
||||
lowStock: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores the ledger fallback when dosesPerUnit is not configured", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
userId: "user-1",
|
||||
doseChanges: [],
|
||||
intakeEvents: [],
|
||||
schedules: [],
|
||||
dosesPerUnit: null,
|
||||
unitsPerDose: 1,
|
||||
inventoryItems: [],
|
||||
inventoryEvents: [{ delta: 2 }],
|
||||
} as never);
|
||||
|
||||
const res = await GET(getReq(), {
|
||||
params: Promise.resolve({ id: "med-1" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { inventory: unknown } };
|
||||
// Pre-item contract: without `dosesPerUnit` the ledger never
|
||||
// produced an inventory block either.
|
||||
expect(body.data.inventory).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -56,9 +56,12 @@ export const GET = apiHandler(
|
||||
doseChanges: { orderBy: { effectiveFrom: "asc" } },
|
||||
// v1.16.10 — the inventory block reads the per-item entities
|
||||
// (the same rows the Bestand tab and the intake consumption
|
||||
// hook move), replacing the legacy MedicationInventoryEvent
|
||||
// running-sum ledger that no write path feeds any more.
|
||||
// hook move). The legacy MedicationInventoryEvent running-sum
|
||||
// ledger stays as a READ fallback for accounts that only ever
|
||||
// posted deltas: when zero items exist, the ledger-derived
|
||||
// numbers surface instead of a silent null.
|
||||
inventoryItems: { orderBy: { createdAt: "asc" } },
|
||||
inventoryEvents: { orderBy: { occurredAt: "asc" } },
|
||||
intakeEvents: {
|
||||
where: { takenAt: { not: null } },
|
||||
orderBy: { takenAt: "desc" },
|
||||
@@ -107,6 +110,30 @@ export const GET = apiHandler(
|
||||
weeksOfSupply,
|
||||
lowStock,
|
||||
};
|
||||
} else if (
|
||||
medication.dosesPerUnit &&
|
||||
medication.inventoryEvents.length > 0
|
||||
) {
|
||||
// Read-side fallback for ledger-only accounts (pre-v1.16.10 the
|
||||
// POST below was the only inventory writer). Items win whenever
|
||||
// any exist — the ledger never overrides them and no item rows
|
||||
// are fabricated from it. Math is the pre-item contract verbatim:
|
||||
// running sum of deltas counts pens, `dosesPerUnit` maps pens to
|
||||
// doses, weekly cadence approximates the supply horizon.
|
||||
const pens = medication.inventoryEvents.reduce(
|
||||
(sum, ev) => sum + ev.delta,
|
||||
0,
|
||||
);
|
||||
const pensRemaining = Math.max(0, pens);
|
||||
const dosesRemaining = pensRemaining * medication.dosesPerUnit;
|
||||
const weeksOfSupply = dosesRemaining;
|
||||
const lowStock = dosesRemaining < LOW_STOCK_DOSE_THRESHOLD;
|
||||
inventory = {
|
||||
pensRemaining,
|
||||
dosesRemaining,
|
||||
weeksOfSupply,
|
||||
lowStock,
|
||||
};
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
@@ -213,6 +240,14 @@ export const POST = apiHandler(
|
||||
|
||||
// The schema's XOR refinement guarantees `inventory` is present
|
||||
// when `doseChange` is not, so the non-null assertion is safe.
|
||||
//
|
||||
// DEPRECATED branch (v1.16.10): this writes the legacy
|
||||
// MedicationInventoryEvent running-sum ledger. The per-item
|
||||
// endpoints (`POST /api/medications/[id]/inventory`,
|
||||
// `PATCH /api/medications/[id]/inventory/[itemId]`) replaced it;
|
||||
// GET above and the Coach snapshot read the ledger only while the
|
||||
// medication has zero inventory items. New callers must register
|
||||
// containers instead of posting deltas.
|
||||
const { delta, reason } = parsed.data.inventory!;
|
||||
const created = await prisma.medicationInventoryEvent.create({
|
||||
data: { medicationId: id, delta, reason },
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* 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
|
||||
* v1.16.1 — stock-correction contract: `unitsRemaining` sets the count
|
||||
* absolutely, clamps to `unitsTotal`, and the canonical state machine
|
||||
* derives the next state (0 ⇒ USED_UP).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -83,7 +83,7 @@ beforeEach(() => {
|
||||
userId: "user-1",
|
||||
firstUseAt: null,
|
||||
state: "ACTIVE",
|
||||
dosesRemaining: 4,
|
||||
unitsRemaining: 4,
|
||||
printedExpiry: null,
|
||||
} as never);
|
||||
});
|
||||
@@ -129,7 +129,7 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — 422 multi-issue (v1
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/medications/[id]/inventory/[itemId] — dosesRemaining stock correction (v1.16.1)", () => {
|
||||
describe("PATCH /api/medications/[id]/inventory/[itemId] — unitsRemaining stock correction (v1.16.1)", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
|
||||
id: "i1",
|
||||
@@ -151,7 +151,7 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — dosesRemaining stoc
|
||||
});
|
||||
|
||||
it("sets the remaining count absolutely", async () => {
|
||||
const res = await PATCH(patchReq({ dosesRemaining: 1 }), ROUTE_CTX);
|
||||
const res = await PATCH(patchReq({ unitsRemaining: 1 }), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
const update = vi.mocked(prisma.medicationInventoryItem.update).mock
|
||||
.calls[0][0] as unknown as { data: { unitsRemaining: number } };
|
||||
@@ -159,7 +159,7 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — dosesRemaining stoc
|
||||
});
|
||||
|
||||
it("derives USED_UP when corrected to zero", async () => {
|
||||
const res = await PATCH(patchReq({ dosesRemaining: 0 }), ROUTE_CTX);
|
||||
const res = await PATCH(patchReq({ unitsRemaining: 0 }), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
const update = vi.mocked(prisma.medicationInventoryItem.update).mock
|
||||
.calls[0][0] as unknown as { data: { unitsRemaining: number; state: string } };
|
||||
@@ -168,7 +168,7 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — dosesRemaining stoc
|
||||
});
|
||||
|
||||
it("clamps a raise above the item's capacity to unitsTotal", async () => {
|
||||
const res = await PATCH(patchReq({ dosesRemaining: 99 }), ROUTE_CTX);
|
||||
const res = await PATCH(patchReq({ unitsRemaining: 99 }), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
const update = vi.mocked(prisma.medicationInventoryItem.update).mock
|
||||
.calls[0][0] as unknown as { data: { unitsRemaining: number } };
|
||||
@@ -176,7 +176,7 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — dosesRemaining stoc
|
||||
});
|
||||
|
||||
it("rejects a negative correction with 422", async () => {
|
||||
const res = await PATCH(patchReq({ dosesRemaining: -1 }), ROUTE_CTX);
|
||||
const res = await PATCH(patchReq({ unitsRemaining: -1 }), ROUTE_CTX);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -6,9 +6,10 @@
|
||||
* - 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)
|
||||
* - stock correction via `unitsRemaining` (v1.16.1 — the Bestand
|
||||
* tab's adjust / withdraw flow; clamped to `unitsTotal`, state
|
||||
* re-derived by the canonical state machine; v1.16.10 renamed
|
||||
* the request field from `dosesRemaining` to match the response)
|
||||
* - update notes
|
||||
*
|
||||
* DELETE /api/medications/[id]/inventory/[itemId]
|
||||
@@ -74,7 +75,7 @@ export const PATCH = apiHandler(
|
||||
markAsFirstUseAt,
|
||||
markAsUsedUp,
|
||||
printedExpiry,
|
||||
dosesRemaining,
|
||||
unitsRemaining,
|
||||
notes,
|
||||
} = parsed.data;
|
||||
|
||||
@@ -96,13 +97,13 @@ export const PATCH = apiHandler(
|
||||
nextPrintedExpiry = printedExpiry;
|
||||
}
|
||||
|
||||
if (dosesRemaining !== undefined) {
|
||||
if (unitsRemaining !== undefined) {
|
||||
// v1.16.1 — stock correction (Bestand tab adjust / withdraw).
|
||||
// The wire field counts UNITS. 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).
|
||||
nextUnitsRemaining = Math.min(dosesRemaining, existing.unitsTotal);
|
||||
nextUnitsRemaining = Math.min(unitsRemaining, existing.unitsTotal);
|
||||
}
|
||||
|
||||
if (markAsUsedUp === true) {
|
||||
@@ -120,7 +121,7 @@ export const PATCH = apiHandler(
|
||||
// pure and idempotent — running it once more with the composed view
|
||||
// collapses every edge case onto the same decision tree the intake
|
||||
// hook and the daily expire cron already share. USED_UP is terminal
|
||||
// (dosesRemaining === 0 ⇒ USED_UP wins at clause 1), so the manual
|
||||
// (unitsRemaining === 0 ⇒ USED_UP wins at clause 1), so the manual
|
||||
// override remains sticky.
|
||||
nextState = computeInventoryState(
|
||||
{
|
||||
|
||||
@@ -124,19 +124,19 @@ describe("POST /api/medications/[id]/inventory", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/medications/med-1/inventory", {
|
||||
dosesTotal: 4,
|
||||
unitsTotal: 4,
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 422 when dosesTotal is missing or invalid", async () => {
|
||||
it("returns 422 when unitsTotal is missing or invalid", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/medications/med-1/inventory", {
|
||||
dosesTotal: 0,
|
||||
unitsTotal: 0,
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
@@ -154,7 +154,7 @@ describe("POST /api/medications/[id]/inventory", () => {
|
||||
const printed = "2027-06-01T00:00:00Z";
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/medications/med-1/inventory", {
|
||||
dosesTotal: 4,
|
||||
unitsTotal: 4,
|
||||
printedExpiry: printed,
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
@@ -185,7 +185,7 @@ describe("POST /api/medications/[id]/inventory", () => {
|
||||
});
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/medications/med-1/inventory", {
|
||||
dosesTotal: 4,
|
||||
unitsTotal: 4,
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
@@ -357,7 +357,7 @@ describe("POST /api/medications/[id]/inventory — 422 multi-issue (v1.4.43 W6)"
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/medications/med-1/inventory", {
|
||||
dosesTotal: "string",
|
||||
unitsTotal: "string",
|
||||
printedExpiry: "not-iso",
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
@@ -383,7 +383,7 @@ describe("POST /api/medications/[id]/inventory — 422 multi-issue (v1.4.43 W6)"
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/medications/med-1/inventory", {
|
||||
dosesTotal: "string",
|
||||
unitsTotal: "string",
|
||||
printedExpiry: "not-iso",
|
||||
purchasedAt: "also-not-iso",
|
||||
notes: 123,
|
||||
|
||||
@@ -90,16 +90,17 @@ export const POST = apiHandler(
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { dosesTotal, containerType, printedExpiry, purchasedAt, notes } =
|
||||
const { unitsTotal, containerType, printedExpiry, purchasedAt, notes } =
|
||||
parsed.data;
|
||||
|
||||
const created = await prisma.medicationInventoryItem.create({
|
||||
// The wire field `dosesTotal` counts UNITS (tablets / ampoules /
|
||||
// puffs) — the v1.16.10 rename to `unitsTotal` happens here.
|
||||
// The wire field `unitsTotal` counts UNITS (tablets / ampoules /
|
||||
// puffs) — v1.16.10 renamed the request field to match the
|
||||
// response side.
|
||||
data: buildCreateInventoryInput({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
unitsTotal: dosesTotal,
|
||||
unitsTotal,
|
||||
containerType: containerType ?? "OTHER",
|
||||
printedExpiry: printedExpiry ?? null,
|
||||
purchasedAt: purchasedAt ?? null,
|
||||
@@ -113,7 +114,7 @@ export const POST = apiHandler(
|
||||
details: {
|
||||
medicationId: id,
|
||||
itemId: created.id,
|
||||
unitsTotal: dosesTotal,
|
||||
unitsTotal,
|
||||
containerType: created.containerType,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user