mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
fix(medications): every taken dose moves the stock, in units that match the product
Stock only decremented on the web card's take button — the status toggle, the phone sync, the Telegram buttons and the token API all recorded the dose and left the inventory untouched, which is why a fresh pack kept reading full. Consumption now stamps the intake row itself: every path that turns a row taken consumes through one module — open container first, then earliest expiry with auto-open, spilling into the next pack and flooring at zero — and every path that takes it back (undo, delete, a take turned skip, a converged duplicate) refunds exactly what the stamp recorded. Replays and pending sync echoes can never double-charge, and the nightly convergence pass refunds the rows it tombstones. A medication can declare units per dose, so two 2 mg tablets book as one 4 mg dose and stock reads in doses with the unit count alongside; containers carry a named type including tablet packs, hold up to a thousand units for inhalers, and the GLP-1 card reads the same stock as the inventory tab instead of a separate ledger. The inventory endpoints join the documented API.
This commit is contained in:
1 parent
dacdcccfd2
commit
7d3557a3fd
53 files changed
+4118
-641
No files matched your search
@@ -40,6 +40,10 @@ vi.mock("@/lib/app-settings", () => ({
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/medications/inventory/consumption", () => ({
|
||||
consumeForIntake: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMedications: vi.fn(),
|
||||
}));
|
||||
@@ -303,4 +307,49 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16.
|
||||
expect(create.data.scheduledFor.getTime()).toBe(offWindow.getTime());
|
||||
expect(create.data.takenAt.getTime()).toBe(offWindow.getTime());
|
||||
});
|
||||
|
||||
// ── v1.16.10 — inventory consumption seam ───────────────────────────
|
||||
|
||||
it("consumes inventory exactly once on the landed row", async () => {
|
||||
const { consumeForIntake } = await import(
|
||||
"@/lib/medications/inventory/consumption"
|
||||
);
|
||||
wireHappyPath();
|
||||
const res = await POST(
|
||||
postReq({
|
||||
medicationName: "Metformin",
|
||||
takenAt: TAKEN_0842_BERLIN.toISOString(),
|
||||
idempotencyKey: "ha-ingest-inv-1",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(consumeForIntake).toHaveBeenCalledTimes(1);
|
||||
expect(consumeForIntake).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
eventId: "evt-pending-0900",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("an idempotency replay returns the original row without consuming", async () => {
|
||||
const { consumeForIntake } = await import(
|
||||
"@/lib/medications/inventory/consumption"
|
||||
);
|
||||
wireHappyPath();
|
||||
// The replay probe finds the original event.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
|
||||
id: "evt-original",
|
||||
} as never);
|
||||
const res = await POST(
|
||||
postReq({
|
||||
medicationName: "Metformin",
|
||||
takenAt: TAKEN_0842_BERLIN.toISOString(),
|
||||
idempotencyKey: "ha-ingest-inv-1",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(consumeForIntake).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
applyCanonicalSlotWrite,
|
||||
resolveSlotForWriteByBand,
|
||||
} from "@/lib/medications/scheduling/slot-upsert";
|
||||
import { consumeForIntake } from "@/lib/medications/inventory/consumption";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
@@ -224,6 +225,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
// v1.16.10 — the external ingest records a take; consume inventory
|
||||
// units. The stamp on the row keeps a retried POST exactly-once (the
|
||||
// idempotency probe above already short-circuits the common replay).
|
||||
await consumeForIntake({
|
||||
client: prisma,
|
||||
userId: apiToken.userId,
|
||||
medicationId: medication.id,
|
||||
eventId: event.id,
|
||||
intakeAt: effectiveTakenAt,
|
||||
});
|
||||
|
||||
await auditLog("medication.ingest.external", {
|
||||
userId: apiToken.userId,
|
||||
ipAddress: ip,
|
||||
|
||||
@@ -82,6 +82,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
orderBy: { takenAt: "desc" },
|
||||
take: 50,
|
||||
},
|
||||
// v1.16.10 divergence note: the stock READOUTS (glp1 details
|
||||
// endpoint, Coach snapshot) moved to the per-item
|
||||
// MedicationInventoryItem entities, but this timeline renders the
|
||||
// append-only MedicationInventoryEvent LEDGER as point-in-time
|
||||
// entries ("+2 pens purchased") — a per-item swap is not
|
||||
// mechanical (items carry no per-mutation history). The ledger
|
||||
// only grows through the legacy `POST /api/medications/[id]/glp1`
|
||||
// inventory branch, so timelines on item-tracked medications show
|
||||
// no new inventory entries.
|
||||
inventoryEvents: { orderBy: { occurredAt: "desc" }, take: 20 },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -404,10 +404,11 @@ describe("GET /api/medications/[id]/glp1 — ownership helper (F-1 C-4)", () =>
|
||||
id: "med-1",
|
||||
userId: "user-1",
|
||||
doseChanges: [],
|
||||
inventoryEvents: [],
|
||||
inventoryItems: [],
|
||||
intakeEvents: [],
|
||||
schedules: [],
|
||||
dosesPerUnit: null,
|
||||
unitsPerDose: 1,
|
||||
} as never);
|
||||
const res = await GET(getReq(), {
|
||||
params: Promise.resolve({ id: "med-1" }),
|
||||
@@ -428,3 +429,75 @@ describe("GET /api/medications/[id]/glp1 — ownership helper (F-1 C-4)", () =>
|
||||
expect(prisma.medication.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/medications/[id]/glp1 — v1.16.10 item-backed inventory", () => {
|
||||
function getReq(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/medications/med-1/glp1");
|
||||
}
|
||||
|
||||
it("sums usable inventory items and derives doses via unitsPerDose", 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: 2,
|
||||
inventoryItems: [
|
||||
// Usable: open container with 3 units.
|
||||
{ state: "IN_USE", unitsTotal: 4, unitsRemaining: 3 },
|
||||
// Usable: unopened container with 4 units.
|
||||
{ state: "ACTIVE", unitsTotal: 4, unitsRemaining: 4 },
|
||||
// Not usable: drained.
|
||||
{ state: "USED_UP", unitsTotal: 4, unitsRemaining: 0 },
|
||||
// Not usable: expired stock is not supply.
|
||||
{ state: "EXPIRED", unitsTotal: 4, unitsRemaining: 4 },
|
||||
],
|
||||
} 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;
|
||||
};
|
||||
};
|
||||
};
|
||||
// 2 usable containers; 7 pooled units / 2 units per dose = 3 doses.
|
||||
expect(body.data.inventory).toEqual({
|
||||
pensRemaining: 2,
|
||||
dosesRemaining: 3,
|
||||
weeksOfSupply: 3,
|
||||
lowStock: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a null inventory block when no items exist", 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: [],
|
||||
} 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 } };
|
||||
expect(body.data.inventory).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -54,7 +54,11 @@ export const GET = apiHandler(
|
||||
where: { id },
|
||||
include: {
|
||||
doseChanges: { orderBy: { effectiveFrom: "asc" } },
|
||||
inventoryEvents: { orderBy: { occurredAt: "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.
|
||||
inventoryItems: { orderBy: { createdAt: "asc" } },
|
||||
intakeEvents: {
|
||||
where: { takenAt: { not: null } },
|
||||
orderBy: { takenAt: "desc" },
|
||||
@@ -69,25 +73,32 @@ export const GET = apiHandler(
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
// Inventory math: running sum of every inventory event. Negative
|
||||
// when more doses were consumed than purchased — surfaces as a
|
||||
// low-stock warning rather than a hard error so the UI keeps
|
||||
// working when the user backdates an inventory event.
|
||||
// Inventory math over the per-item entities. The response shape is
|
||||
// locked for the iOS client: `pensRemaining` = count of usable
|
||||
// containers (ACTIVE / IN_USE with units left), `dosesRemaining` =
|
||||
// pooled units divided by `unitsPerDose` (floored — consumption
|
||||
// spills across containers), `weeksOfSupply` keeps the weekly-
|
||||
// cadence approximation (the canonical GLP-1 case).
|
||||
let inventory: {
|
||||
pensRemaining: number | null;
|
||||
dosesRemaining: number | null;
|
||||
weeksOfSupply: number | null;
|
||||
lowStock: boolean;
|
||||
} | null = null;
|
||||
if (medication.dosesPerUnit && medication.inventoryEvents.length > 0) {
|
||||
const pens = medication.inventoryEvents.reduce(
|
||||
(sum, ev) => sum + ev.delta,
|
||||
if (medication.inventoryItems.length > 0) {
|
||||
const usable = medication.inventoryItems.filter(
|
||||
(item) =>
|
||||
(item.state === "ACTIVE" || item.state === "IN_USE") &&
|
||||
item.unitsRemaining > 0,
|
||||
);
|
||||
const unitsRemaining = usable.reduce(
|
||||
(sum, item) => sum + item.unitsRemaining,
|
||||
0,
|
||||
);
|
||||
const pensRemaining = Math.max(0, pens);
|
||||
const dosesRemaining = pensRemaining * medication.dosesPerUnit;
|
||||
// Approximate weeks-of-supply assuming weekly cadence (the
|
||||
// canonical GLP-1 case). The Coach snapshot does the same.
|
||||
const pensRemaining = usable.length;
|
||||
const dosesRemaining = Math.floor(
|
||||
unitsRemaining / Math.max(1, medication.unitsPerDose),
|
||||
);
|
||||
const weeksOfSupply = dosesRemaining;
|
||||
const lowStock = dosesRemaining < LOW_STOCK_DOSE_THRESHOLD;
|
||||
inventory = {
|
||||
|
||||
@@ -33,6 +33,10 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMedications: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/medications/inventory/consumption", () => ({
|
||||
consumeForIntake: vi.fn().mockResolvedValue(undefined),
|
||||
restoreForIntake: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
@@ -567,3 +571,173 @@ describe("PUT — v1.15.18 band re-attribution", () => {
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// v1.16.10 — inventory consumption seams on PUT / DELETE
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("PUT / DELETE — v1.16.10 inventory consumption", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_TZ as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
oneShot: false,
|
||||
active: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.update).mockResolvedValue({} as never);
|
||||
stubTwiceDailyMed();
|
||||
});
|
||||
|
||||
it("an edit into taken consumes on the row (no slot move)", async () => {
|
||||
const { consumeForIntake, restoreForIntake } = await import(
|
||||
"@/lib/medications/inventory/consumption"
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst)
|
||||
// PUT lookup — pending row anchored on the 07:00 slot.
|
||||
.mockResolvedValueOnce({
|
||||
id: "e1",
|
||||
userId: "user-1",
|
||||
medicationId: "m1",
|
||||
scheduledFor: at(7, 0),
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
} as never)
|
||||
// lifecycle liveIntake probe
|
||||
.mockResolvedValue(null as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({
|
||||
id: "e1",
|
||||
scheduledFor: at(7, 0),
|
||||
takenAt: at(7, 5),
|
||||
skipped: false,
|
||||
} as never);
|
||||
|
||||
const res = await PUT(putReq({ takenAt: at(7, 5).toISOString() }), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
expect(consumeForIntake).toHaveBeenCalledTimes(1);
|
||||
expect(consumeForIntake).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
medicationId: "m1",
|
||||
eventId: "e1",
|
||||
}),
|
||||
);
|
||||
expect(restoreForIntake).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a taken → skipped edit restores the row's stamp and never consumes", async () => {
|
||||
const { consumeForIntake, restoreForIntake } = await import(
|
||||
"@/lib/medications/inventory/consumption"
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst)
|
||||
.mockResolvedValueOnce({
|
||||
id: "e1",
|
||||
userId: "user-1",
|
||||
medicationId: "m1",
|
||||
scheduledFor: at(7, 0),
|
||||
takenAt: at(7, 5),
|
||||
skipped: false,
|
||||
} as never)
|
||||
.mockResolvedValue(null as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({
|
||||
id: "e1",
|
||||
scheduledFor: at(7, 0),
|
||||
takenAt: null,
|
||||
skipped: true,
|
||||
} as never);
|
||||
|
||||
const res = await PUT(putReq({ skipped: true, takenAt: null }), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
expect(restoreForIntake).toHaveBeenCalledTimes(1);
|
||||
expect(restoreForIntake).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: "user-1", eventId: "e1" }),
|
||||
);
|
||||
expect(consumeForIntake).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a slot-moving taken edit refunds the old row before tombstoning and consumes the converged row — net one", async () => {
|
||||
const { consumeForIntake, restoreForIntake } = await import(
|
||||
"@/lib/medications/inventory/consumption"
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst)
|
||||
.mockResolvedValueOnce({
|
||||
id: "e1",
|
||||
userId: "user-1",
|
||||
medicationId: "m1",
|
||||
scheduledFor: at(7, 0),
|
||||
takenAt: at(7, 5),
|
||||
skipped: false,
|
||||
} as never)
|
||||
.mockResolvedValue(null as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue(
|
||||
{} as never,
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({
|
||||
id: "e2",
|
||||
userId: "user-1",
|
||||
medicationId: "m1",
|
||||
scheduledFor: at(19, 0),
|
||||
takenAt: at(19, 5),
|
||||
skipped: false,
|
||||
} as never);
|
||||
|
||||
const res = await PUT(
|
||||
putReq({ takenAt: at(19, 5).toISOString() }),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(restoreForIntake).toHaveBeenCalledTimes(1);
|
||||
expect(restoreForIntake).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ eventId: "e1" }),
|
||||
);
|
||||
expect(consumeForIntake).toHaveBeenCalledTimes(1);
|
||||
expect(consumeForIntake).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ eventId: "e2" }),
|
||||
);
|
||||
// The refund runs BEFORE the tombstone write.
|
||||
const restoreOrder = vi.mocked(restoreForIntake).mock
|
||||
.invocationCallOrder[0];
|
||||
const tombstoneOrder = vi.mocked(prisma.medicationIntakeEvent.update)
|
||||
.mock.invocationCallOrder[0];
|
||||
expect(restoreOrder).toBeLessThan(tombstoneOrder);
|
||||
});
|
||||
|
||||
it("DELETE refunds the row's stamp before the tombstone", async () => {
|
||||
const { consumeForIntake, restoreForIntake } = await import(
|
||||
"@/lib/medications/inventory/consumption"
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValue({
|
||||
id: "e1",
|
||||
userId: "user-1",
|
||||
medicationId: "m1",
|
||||
scheduledFor: at(7, 0),
|
||||
takenAt: at(7, 5),
|
||||
skipped: false,
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue(
|
||||
{} as never,
|
||||
);
|
||||
|
||||
const res = await DELETE(
|
||||
new NextRequest("http://localhost/api/medications/m1/intake/e1", {
|
||||
method: "DELETE",
|
||||
}),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(restoreForIntake).toHaveBeenCalledTimes(1);
|
||||
expect(restoreForIntake).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: "user-1", eventId: "e1" }),
|
||||
);
|
||||
expect(consumeForIntake).not.toHaveBeenCalled();
|
||||
const restoreOrder = vi.mocked(restoreForIntake).mock
|
||||
.invocationCallOrder[0];
|
||||
const tombstoneOrder = vi.mocked(prisma.medicationIntakeEvent.update)
|
||||
.mock.invocationCallOrder[0];
|
||||
expect(restoreOrder).toBeLessThan(tombstoneOrder);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { updateIntakeEventSchema } from "@/lib/validations/medication";
|
||||
import {
|
||||
consumeForIntake,
|
||||
restoreForIntake,
|
||||
} from "@/lib/medications/inventory/consumption";
|
||||
import { reconcileOneShotState } from "@/lib/medications/lifecycle";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
|
||||
@@ -241,6 +245,26 @@ export const PUT = apiHandler(
|
||||
syncVersion: { increment: 1 },
|
||||
},
|
||||
});
|
||||
// v1.16.10 — inventory follows the post-edit state: a row that is
|
||||
// (now) taken consumes (the stamp keeps an already-consumed row
|
||||
// untouched — the stamp freezes what the original take pulled,
|
||||
// even when `unitsPerDose` changed since); a row edited out of
|
||||
// taken (skip flip, takenAt cleared) refunds its stamp.
|
||||
if (nextTakenAt !== null && !nextSkipped) {
|
||||
await consumeForIntake({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
eventId,
|
||||
intakeAt: nextTakenAt,
|
||||
});
|
||||
} else {
|
||||
await restoreForIntake({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
eventId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// The edit moved the dose to a different slot. Tombstone the original
|
||||
// row (the iOS "a correction is a tombstone + re-insert" model the
|
||||
@@ -248,6 +272,15 @@ export const PUT = apiHandler(
|
||||
// shared canonical-slot upsert, which converges onto any row already at
|
||||
// the target slot (a pending REMINDER row) rather than bare-updating
|
||||
// into an occupied slot and risking a P2002 (audit HIGH-4).
|
||||
//
|
||||
// v1.16.10 — refund the source row's consumption stamp BEFORE the
|
||||
// tombstone; the consume on the converged row below nets the move
|
||||
// to exactly one consumption.
|
||||
await restoreForIntake({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
eventId,
|
||||
});
|
||||
await prisma.medicationIntakeEvent.update({
|
||||
where: { id: eventId },
|
||||
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
|
||||
@@ -271,6 +304,15 @@ export const PUT = apiHandler(
|
||||
doseTaken: event.doseTaken,
|
||||
});
|
||||
updated = applied.row;
|
||||
if (nextTakenAt !== null && !nextSkipped) {
|
||||
await consumeForIntake({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
eventId: applied.row.id,
|
||||
intakeAt: nextTakenAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await auditLog("medication.intake.update", {
|
||||
@@ -335,6 +377,14 @@ export const DELETE = apiHandler(
|
||||
return apiError("Intake not found", 404);
|
||||
}
|
||||
|
||||
// v1.16.10 — a deleted dose record refunds its consumption stamp
|
||||
// (no-op for a never-consumed row; a re-delete finds it cleared).
|
||||
await restoreForIntake({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
eventId,
|
||||
});
|
||||
|
||||
// v1.7.0 sync — soft-delete instead of a hard `delete`. An intake is
|
||||
// an immutable fact, so a "correction" is a tombstone + re-insert
|
||||
// (never an in-place edit). Setting `deletedAt` (+ bumping
|
||||
|
||||
@@ -53,8 +53,9 @@ vi.mock("@/lib/idempotency", () => ({
|
||||
fn(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/medications/inventory/service", () => ({
|
||||
consumeOneDose: vi.fn().mockResolvedValue(null),
|
||||
vi.mock("@/lib/medications/inventory/consumption", () => ({
|
||||
consumeForIntake: vi.fn().mockResolvedValue(undefined),
|
||||
restoreForIntake: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/medications/route-guards", () => ({
|
||||
@@ -412,8 +413,8 @@ describe("POST /api/medications/[id]/intake — v1.8.2 reconcile (M2 inventory)"
|
||||
});
|
||||
|
||||
it("does NOT decrement inventory on a re-post of an already-taken slot (M2)", async () => {
|
||||
const { consumeOneDose } = await import(
|
||||
"@/lib/medications/inventory/service"
|
||||
const { consumeForIntake } = await import(
|
||||
"@/lib/medications/inventory/consumption"
|
||||
);
|
||||
// Resolver load → scheduled med.
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(
|
||||
@@ -455,15 +456,15 @@ describe("POST /api/medications/[id]/intake — v1.8.2 reconcile (M2 inventory)"
|
||||
expect(res.status).toBe(201);
|
||||
// Re-post of an already-taken slot → no pending→taken transition →
|
||||
// inventory must NOT be consumed.
|
||||
expect(consumeOneDose).not.toHaveBeenCalled();
|
||||
expect(consumeForIntake).not.toHaveBeenCalled();
|
||||
// The row was updated, not duplicated.
|
||||
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("DOES decrement inventory on a genuine pending→taken move (M2)", async () => {
|
||||
const { consumeOneDose } = await import(
|
||||
"@/lib/medications/inventory/service"
|
||||
const { consumeForIntake } = await import(
|
||||
"@/lib/medications/inventory/consumption"
|
||||
);
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(
|
||||
SCHEDULED_MED as never,
|
||||
@@ -501,7 +502,14 @@ describe("POST /api/medications/[id]/intake — v1.8.2 reconcile (M2 inventory)"
|
||||
ROUTE_PARAMS,
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(consumeOneDose).toHaveBeenCalledTimes(1);
|
||||
expect(consumeForIntake).toHaveBeenCalledTimes(1);
|
||||
expect(consumeForIntake).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
eventId: "row-pending",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("re-finds and updates the slot when the create races a P2002 (C1)", async () => {
|
||||
|
||||
@@ -53,6 +53,10 @@ vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/medications/inventory/consumption", () => ({
|
||||
restoreForIntake: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMedications: vi.fn(),
|
||||
}));
|
||||
@@ -241,3 +245,40 @@ describe("POST /api/medications/[id]/intake/bulk-delete — F-1 H-6 rate limit",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/medications/[id]/intake/bulk-delete — v1.16.10 inventory refund", () => {
|
||||
it("restores each stamped row before the tombstone sweep and ignores unstamped rows", async () => {
|
||||
const { restoreForIntake } = await import(
|
||||
"@/lib/medications/inventory/consumption"
|
||||
);
|
||||
const day1 = new Date(Date.UTC(2026, 4, 27, 8, 0, 0));
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
|
||||
{
|
||||
id: "evt-stamped",
|
||||
scheduledFor: day1,
|
||||
inventoryConsumption: [{ itemId: "item-1", units: 2 }],
|
||||
},
|
||||
{ id: "evt-unstamped", scheduledFor: day1, inventoryConsumption: null },
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValueOnce({
|
||||
count: 2,
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
postWithBody({ eventIds: ["evt-stamped", "evt-unstamped"] }),
|
||||
ROUTE_PARAMS,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(restoreForIntake).toHaveBeenCalledTimes(1);
|
||||
expect(restoreForIntake).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: "user-1", eventId: "evt-stamped" }),
|
||||
);
|
||||
// The refund runs BEFORE the soft-delete sweep.
|
||||
const restoreOrder = vi.mocked(restoreForIntake).mock
|
||||
.invocationCallOrder[0];
|
||||
const sweepOrder = vi.mocked(prisma.medicationIntakeEvent.updateMany)
|
||||
.mock.invocationCallOrder[0];
|
||||
expect(restoreOrder).toBeLessThan(sweepOrder);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { bulkDeleteIntakeEventsSchema } from "@/lib/validations/medication";
|
||||
import { restoreForIntake } from "@/lib/medications/inventory/consumption";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import {
|
||||
dayKeyForScheduledFor,
|
||||
@@ -106,7 +107,7 @@ export const POST = apiHandler(
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
},
|
||||
select: { id: true, scheduledFor: true },
|
||||
select: { id: true, scheduledFor: true, inventoryConsumption: true },
|
||||
});
|
||||
|
||||
if (targetRows.length === 0) {
|
||||
@@ -118,6 +119,19 @@ export const POST = apiHandler(
|
||||
dayKeysToRefresh.add(dayKeyForScheduledFor(row.scheduledFor, tz));
|
||||
}
|
||||
|
||||
// v1.16.10 — refund each stamped row's inventory consumption before
|
||||
// the tombstone sweep. Only stamped rows pay the round trip; the
|
||||
// hook is best-effort and never blocks the delete.
|
||||
for (const row of targetRows) {
|
||||
if (row.inventoryConsumption !== null) {
|
||||
await restoreForIntake({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
eventId: row.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// v1.15.18 LOW-6 — soft-delete to match the single-event DELETE handler
|
||||
// and the iOS tombstone contract: an intake is an immutable fact, so a
|
||||
// bulk "delete" sets `deletedAt` (+ bumps `syncVersion`) so the
|
||||
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
|
||||
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { consumeOneDose } from "@/lib/medications/inventory/service";
|
||||
import {
|
||||
consumeForIntake,
|
||||
restoreForIntake,
|
||||
} from "@/lib/medications/inventory/consumption";
|
||||
import { reconcileOneShotState } from "@/lib/medications/lifecycle";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
@@ -415,35 +418,27 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
}
|
||||
|
||||
// v1.4.25 W19b — pen-inventory dose decrement. Only fires for
|
||||
// non-skipped intakes; a skipped event is not a consumption event.
|
||||
// No-op when the medication has no tracked pens (most non-GLP-1
|
||||
// meds). Failures here must never block the intake write, so
|
||||
// errors are swallowed and logged — the intake is the source of
|
||||
// truth, the inventory is an opt-in companion.
|
||||
//
|
||||
// v1.8.2 M2 — gate on an ACTUAL pending→taken transition. An idempotent
|
||||
// re-post of an already-taken slot updates the row in place but must NOT
|
||||
// decrement again, else a repeated iOS sync drifts the GLP-1 pen count
|
||||
// down on every replay. `consumedTransition` is false when the slot was
|
||||
// already taken (or on a no-downgrade no-op).
|
||||
let inventoryOutcome: Awaited<ReturnType<typeof consumeOneDose>> = null;
|
||||
// v1.16.10 — inventory consumption / restore. A taken write consumes
|
||||
// `unitsPerDose` units (the stamp on the event row makes replays and
|
||||
// re-posts exactly-once; `consumedTransition` skips the read for the
|
||||
// no-op echo cases the slot upsert already classified). An explicit
|
||||
// skip can downgrade a previously-taken row (last-write-wins), so it
|
||||
// refunds whatever that row's stamp recorded. Both hooks are
|
||||
// best-effort and never block the intake write.
|
||||
if (!skipped && consumedTransition) {
|
||||
try {
|
||||
inventoryOutcome = await consumeOneDose({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
intakeAt: event.takenAt ?? event.scheduledFor,
|
||||
});
|
||||
} catch (err) {
|
||||
annotate({
|
||||
action: { name: "medication.inventory.consume_error" },
|
||||
meta: {
|
||||
medication_id: id,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
await consumeForIntake({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
eventId: event.id,
|
||||
intakeAt: event.takenAt ?? event.scheduledFor,
|
||||
});
|
||||
} else if (skipped) {
|
||||
await restoreForIntake({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
eventId: event.id,
|
||||
});
|
||||
}
|
||||
|
||||
await auditLog("medication.intake", {
|
||||
@@ -453,12 +448,6 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
medicationId: id,
|
||||
eventId: event.id,
|
||||
skipped,
|
||||
...(inventoryOutcome
|
||||
? {
|
||||
inventoryItemId: inventoryOutcome.itemId,
|
||||
inventoryChange: inventoryOutcome.change,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -471,12 +460,6 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
meta: {
|
||||
medication_id: id,
|
||||
skipped,
|
||||
...(inventoryOutcome
|
||||
? {
|
||||
inventory_item_id: inventoryOutcome.itemId,
|
||||
inventory_change: inventoryOutcome.change,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -137,8 +137,8 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — dosesRemaining stoc
|
||||
userId: "user-1",
|
||||
firstUseAt: null,
|
||||
state: "IN_USE",
|
||||
dosesTotal: 4,
|
||||
dosesRemaining: 3,
|
||||
unitsTotal: 4,
|
||||
unitsRemaining: 3,
|
||||
printedExpiry: null,
|
||||
notes: null,
|
||||
} as never);
|
||||
@@ -154,25 +154,25 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — dosesRemaining stoc
|
||||
const res = await PATCH(patchReq({ dosesRemaining: 1 }), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
const update = vi.mocked(prisma.medicationInventoryItem.update).mock
|
||||
.calls[0][0] as { data: { dosesRemaining: number } };
|
||||
expect(update.data.dosesRemaining).toBe(1);
|
||||
.calls[0][0] as unknown as { data: { unitsRemaining: number } };
|
||||
expect(update.data.unitsRemaining).toBe(1);
|
||||
});
|
||||
|
||||
it("derives USED_UP when corrected to zero", async () => {
|
||||
const res = await PATCH(patchReq({ dosesRemaining: 0 }), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
const update = vi.mocked(prisma.medicationInventoryItem.update).mock
|
||||
.calls[0][0] as { data: { dosesRemaining: number; state: string } };
|
||||
expect(update.data.dosesRemaining).toBe(0);
|
||||
.calls[0][0] as unknown as { data: { unitsRemaining: number; state: string } };
|
||||
expect(update.data.unitsRemaining).toBe(0);
|
||||
expect(update.data.state).toBe("USED_UP");
|
||||
});
|
||||
|
||||
it("clamps a raise above the item's capacity to dosesTotal", async () => {
|
||||
it("clamps a raise above the item's capacity to unitsTotal", async () => {
|
||||
const res = await PATCH(patchReq({ dosesRemaining: 99 }), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
const update = vi.mocked(prisma.medicationInventoryItem.update).mock
|
||||
.calls[0][0] as { data: { dosesRemaining: number } };
|
||||
expect(update.data.dosesRemaining).toBe(4);
|
||||
.calls[0][0] as unknown as { data: { unitsRemaining: number } };
|
||||
expect(update.data.unitsRemaining).toBe(4);
|
||||
});
|
||||
|
||||
it("rejects a negative correction with 422", async () => {
|
||||
|
||||
@@ -82,7 +82,7 @@ export const PATCH = apiHandler(
|
||||
// commutative — applying them in any order produces the same row.
|
||||
let nextFirstUseAt = existing.firstUseAt;
|
||||
let nextState = existing.state;
|
||||
let nextDosesRemaining = existing.dosesRemaining;
|
||||
let nextUnitsRemaining = existing.unitsRemaining;
|
||||
let nextPrintedExpiry = existing.printedExpiry;
|
||||
|
||||
if (markAsFirstUseAt) {
|
||||
@@ -98,14 +98,15 @@ export const PATCH = apiHandler(
|
||||
|
||||
if (dosesRemaining !== undefined) {
|
||||
// v1.16.1 — stock correction (Bestand tab adjust / withdraw).
|
||||
// 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).
|
||||
nextDosesRemaining = Math.min(dosesRemaining, existing.dosesTotal);
|
||||
// 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);
|
||||
}
|
||||
|
||||
if (markAsUsedUp === true) {
|
||||
nextDosesRemaining = 0;
|
||||
nextUnitsRemaining = 0;
|
||||
nextState = "USED_UP";
|
||||
}
|
||||
|
||||
@@ -124,8 +125,8 @@ export const PATCH = apiHandler(
|
||||
nextState = computeInventoryState(
|
||||
{
|
||||
state: nextState,
|
||||
dosesTotal: existing.dosesTotal,
|
||||
dosesRemaining: nextDosesRemaining,
|
||||
unitsTotal: existing.unitsTotal,
|
||||
unitsRemaining: nextUnitsRemaining,
|
||||
firstUseAt: nextFirstUseAt,
|
||||
printedExpiry: nextPrintedExpiry,
|
||||
},
|
||||
@@ -137,7 +138,7 @@ export const PATCH = apiHandler(
|
||||
data: {
|
||||
state: nextState,
|
||||
firstUseAt: nextFirstUseAt,
|
||||
dosesRemaining: nextDosesRemaining,
|
||||
unitsRemaining: nextUnitsRemaining,
|
||||
printedExpiry: nextPrintedExpiry,
|
||||
expiresAt: nextExpiresAt,
|
||||
notes: notes === undefined ? existing.notes : notes,
|
||||
@@ -185,7 +186,7 @@ export const DELETE = apiHandler(
|
||||
medicationId: id,
|
||||
itemId,
|
||||
finalState: existing.state,
|
||||
dosesRemaining: existing.dosesRemaining,
|
||||
unitsRemaining: existing.unitsRemaining,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -165,8 +165,9 @@ describe("POST /api/medications/[id]/inventory", () => {
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
state: "ACTIVE",
|
||||
dosesTotal: 4,
|
||||
dosesRemaining: 4,
|
||||
containerType: "OTHER",
|
||||
unitsTotal: 4,
|
||||
unitsRemaining: 4,
|
||||
firstUseAt: null,
|
||||
printedExpiry: new Date(printed),
|
||||
expiresAt: new Date(printed),
|
||||
@@ -198,8 +199,8 @@ describe("PATCH /api/medications/[id]/inventory/[itemId]", () => {
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
state: "ACTIVE" as const,
|
||||
dosesTotal: 4,
|
||||
dosesRemaining: 4,
|
||||
unitsTotal: 4,
|
||||
unitsRemaining: 4,
|
||||
firstUseAt: null,
|
||||
printedExpiry: null,
|
||||
purchasedAt: null,
|
||||
@@ -243,12 +244,12 @@ describe("PATCH /api/medications/[id]/inventory/[itemId]", () => {
|
||||
...existingActive,
|
||||
state: "IN_USE",
|
||||
firstUseAt: new Date("2026-05-01"),
|
||||
dosesRemaining: 2,
|
||||
unitsRemaining: 2,
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
|
||||
...existingActive,
|
||||
state: "USED_UP",
|
||||
dosesRemaining: 0,
|
||||
unitsRemaining: 0,
|
||||
} as never);
|
||||
|
||||
const res = await PATCH(
|
||||
@@ -264,7 +265,7 @@ describe("PATCH /api/medications/[id]/inventory/[itemId]", () => {
|
||||
.calls[0][0];
|
||||
expect(updateCall.data).toMatchObject({
|
||||
state: "USED_UP",
|
||||
dosesRemaining: 0,
|
||||
unitsRemaining: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -331,7 +332,7 @@ describe("DELETE /api/medications/[id]/inventory/[itemId]", () => {
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
state: "EXPIRED",
|
||||
dosesRemaining: 2,
|
||||
unitsRemaining: 2,
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationInventoryItem.delete).mockResolvedValue({
|
||||
id: "inv-1",
|
||||
|
||||
@@ -90,13 +90,17 @@ export const POST = apiHandler(
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { dosesTotal, printedExpiry, purchasedAt, notes } = parsed.data;
|
||||
const { dosesTotal, 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.
|
||||
data: buildCreateInventoryInput({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
dosesTotal,
|
||||
unitsTotal: dosesTotal,
|
||||
containerType: containerType ?? "OTHER",
|
||||
printedExpiry: printedExpiry ?? null,
|
||||
purchasedAt: purchasedAt ?? null,
|
||||
notes: notes ?? null,
|
||||
@@ -109,7 +113,8 @@ export const POST = apiHandler(
|
||||
details: {
|
||||
medicationId: id,
|
||||
itemId: created.id,
|
||||
dosesTotal,
|
||||
unitsTotal: dosesTotal,
|
||||
containerType: created.containerType,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -222,6 +222,7 @@ export const PUT = apiHandler(
|
||||
category,
|
||||
treatmentClass,
|
||||
dosesPerUnit,
|
||||
unitsPerDose,
|
||||
deliveryForm,
|
||||
trackInjectionSites,
|
||||
allowedInjectionSites,
|
||||
@@ -515,6 +516,9 @@ export const PUT = apiHandler(
|
||||
...(dose !== undefined && { dose }),
|
||||
...(treatmentClass !== undefined && { treatmentClass }),
|
||||
...(dosesPerUnit !== undefined && { dosesPerUnit }),
|
||||
// v1.16.10 — units consumed per dose. Field-by-field; the stamp on
|
||||
// already-taken intake events freezes their recorded consumption.
|
||||
...(unitsPerDose !== undefined && { unitsPerDose }),
|
||||
...(deliveryForm !== undefined && { deliveryForm }),
|
||||
// v1.8.5 — injection-site tracking opt-in + per-medication allowed
|
||||
// sites. Field-by-field; `false` / `[]` are valid explicit values
|
||||
|
||||
@@ -8,6 +8,8 @@ vi.mock("@/lib/db", () => ({
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
// v1.16.10 — the slot-move path re-inserts via the shared upsert.
|
||||
create: vi.fn(),
|
||||
// v1.4.39 W-SERVER-FIX — `scope=today` now backfills missing
|
||||
// rows for schedules whose window opens today (covers daily
|
||||
// meds with `daysOfWeek: null` whose reminder hasn't fired yet).
|
||||
@@ -18,6 +20,8 @@ vi.mock("@/lib/db", () => ({
|
||||
// v1.4.39 W-SERVER-FIX — today's projection reads active meds +
|
||||
// their schedules to know what to backfill.
|
||||
findMany: vi.fn(),
|
||||
// v1.15.18 — the band resolver loads the medication via findFirst.
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
medicationComplianceRollup: {
|
||||
findFirst: vi.fn(),
|
||||
@@ -44,6 +48,11 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/medications/inventory/consumption", () => ({
|
||||
consumeForIntake: vi.fn().mockResolvedValue(undefined),
|
||||
restoreForIntake: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
@@ -62,6 +71,10 @@ vi.mock("next/headers", () => ({
|
||||
import { GET, POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import {
|
||||
consumeForIntake,
|
||||
restoreForIntake,
|
||||
} from "@/lib/medications/inventory/consumption";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
|
||||
const SESSION_OK = {
|
||||
@@ -366,6 +379,147 @@ describe("POST /api/medications/intake", () => {
|
||||
where: { id: "e1" },
|
||||
data: { takenAt: null, skipped: true, syncVersion: { increment: 1 } },
|
||||
});
|
||||
// v1.16.10 — a row toggled out of taken refunds its consumption
|
||||
// stamp (no-op for a never-consumed row) and never consumes.
|
||||
expect(restoreForIntake).toHaveBeenCalledTimes(1);
|
||||
expect(restoreForIntake).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: "user-1", eventId: "e1" }),
|
||||
);
|
||||
expect(consumeForIntake).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── v1.16.10 — inventory consumption seams ──────────────────────────
|
||||
|
||||
it("taken (no slot move) consumes inventory exactly once on the toggled row", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const slot = new Date("2026-05-18T10:00:00.000Z");
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
|
||||
id: "e1",
|
||||
userId: "user-1",
|
||||
medicationId: "m1",
|
||||
scheduledFor: slot,
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
injectionSite: null,
|
||||
doseTaken: null,
|
||||
medication: {
|
||||
deliveryForm: "ORAL",
|
||||
trackInjectionSites: false,
|
||||
allowedInjectionSites: [],
|
||||
},
|
||||
} as never);
|
||||
// Band resolver: schedule-less medication → ad-hoc attribution on the
|
||||
// takenAt, which equals the existing anchor → no slot move.
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValue({
|
||||
id: "m1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
schedules: [],
|
||||
scheduleRevisions: [],
|
||||
} as never);
|
||||
vi.mocked(prisma.$transaction).mockImplementation(async (arg: unknown) =>
|
||||
Array.isArray(arg) ? Promise.all(arg) : (arg as (c: unknown) => unknown)(prisma),
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({
|
||||
id: "e1",
|
||||
takenAt: slot,
|
||||
skipped: false,
|
||||
scheduledFor: slot,
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await POST(
|
||||
req({
|
||||
intakeId: "e1",
|
||||
status: "taken",
|
||||
takenAt: slot.toISOString(),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(consumeForIntake).toHaveBeenCalledTimes(1);
|
||||
expect(consumeForIntake).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
medicationId: "m1",
|
||||
eventId: "e1",
|
||||
}),
|
||||
);
|
||||
expect(restoreForIntake).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("taken with a slot move restores the old row's stamp before tombstoning and consumes the converged row — net one", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const oldSlot = new Date("2026-05-18T10:00:00.000Z");
|
||||
const takenAt = new Date("2026-05-18T15:00:00.000Z");
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
|
||||
id: "e1",
|
||||
userId: "user-1",
|
||||
medicationId: "m1",
|
||||
scheduledFor: oldSlot,
|
||||
takenAt: oldSlot,
|
||||
skipped: false,
|
||||
injectionSite: null,
|
||||
doseTaken: null,
|
||||
medication: {
|
||||
deliveryForm: "ORAL",
|
||||
trackInjectionSites: false,
|
||||
allowedInjectionSites: [],
|
||||
},
|
||||
} as never);
|
||||
// Schedule-less → ad-hoc attribution on the new takenAt → slot moves.
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValue({
|
||||
id: "m1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
schedules: [],
|
||||
scheduleRevisions: [],
|
||||
} as never);
|
||||
// Tombstone write on e1, then the shared upsert: no row at the target
|
||||
// slot → create the converged row e2.
|
||||
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({
|
||||
id: "e1",
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({
|
||||
id: "e2",
|
||||
takenAt,
|
||||
skipped: false,
|
||||
idempotencyKey: null,
|
||||
scheduledFor: takenAt,
|
||||
source: "WEB",
|
||||
createdAt: takenAt,
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await POST(
|
||||
req({
|
||||
intakeId: "e1",
|
||||
status: "taken",
|
||||
takenAt: takenAt.toISOString(),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
// Net one: refund the source row, consume the converged row.
|
||||
expect(restoreForIntake).toHaveBeenCalledTimes(1);
|
||||
expect(restoreForIntake).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ eventId: "e1" }),
|
||||
);
|
||||
expect(consumeForIntake).toHaveBeenCalledTimes(1);
|
||||
expect(consumeForIntake).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ eventId: "e2" }),
|
||||
);
|
||||
// The refund runs BEFORE the tombstone write on the source row.
|
||||
const restoreOrder = vi.mocked(restoreForIntake).mock
|
||||
.invocationCallOrder[0];
|
||||
const tombstoneOrder = vi.mocked(prisma.medicationIntakeEvent.update)
|
||||
.mock.invocationCallOrder[0];
|
||||
expect(restoreOrder).toBeLessThan(tombstoneOrder);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -42,6 +42,10 @@ vi.mock("@/lib/idempotency", () => ({
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMedications: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/medications/inventory/consumption", () => ({
|
||||
consumeForIntake: vi.fn().mockResolvedValue(undefined),
|
||||
restoreForIntake: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
|
||||
recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined),
|
||||
dayKeyForScheduledFor: vi.fn().mockReturnValue("2026-01-01"),
|
||||
@@ -64,6 +68,10 @@ import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
consumeForIntake,
|
||||
restoreForIntake,
|
||||
} from "@/lib/medications/inventory/consumption";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -805,3 +813,180 @@ describe("POST /api/medications/intake/bulk — v1.15.20 band attribution", () =
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// v1.16.10 — inventory consumption seams
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /api/medications/intake/bulk — v1.16.10 inventory consumption", () => {
|
||||
const SCHEDULED_MED = {
|
||||
id: "med-1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
schedules: [
|
||||
{
|
||||
id: "s1",
|
||||
windowStart: "07:00",
|
||||
windowEnd: "07:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["07:00", "19:00"],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-06-15T18:00:00.000Z"));
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{ id: "med-1" },
|
||||
] as never);
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValue(
|
||||
SCHEDULED_MED as never,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("consumes exactly once per taken entry, on the landed row id", async () => {
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
|
||||
{
|
||||
id: "row-pending",
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
idempotencyKey: null,
|
||||
scheduledFor: new Date("2026-06-15T05:00:00Z"),
|
||||
source: "REMINDER",
|
||||
createdAt: new Date("2026-06-15T00:00:00Z"),
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
|
||||
id: "row-pending",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
medicationId: "med-1",
|
||||
scheduledFor: "2026-06-15T05:00:30.000Z",
|
||||
takenAt: "2026-06-15T05:02:00.000Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(consumeForIntake).toHaveBeenCalledTimes(1);
|
||||
expect(consumeForIntake).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
eventId: "row-pending",
|
||||
}),
|
||||
);
|
||||
expect(restoreForIntake).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("C2 — a pending echo onto an already-taken slot never reaches consume", async () => {
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
|
||||
{
|
||||
id: "row-taken",
|
||||
takenAt: new Date("2026-06-15T05:01:00Z"),
|
||||
skipped: false,
|
||||
idempotencyKey: null,
|
||||
scheduledFor: new Date("2026-06-15T05:00:00Z"),
|
||||
source: "WEB",
|
||||
createdAt: new Date("2026-06-15T05:01:00Z"),
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
medicationId: "med-1",
|
||||
scheduledFor: "2026-06-15T05:00:30.000Z",
|
||||
// no takenAt, skipped defaults false → pending echo
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(consumeForIntake).not.toHaveBeenCalled();
|
||||
expect(restoreForIntake).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("an explicit skip entry restores the slot row's stamp instead of consuming", async () => {
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
|
||||
{
|
||||
id: "row-taken",
|
||||
takenAt: new Date("2026-06-15T05:01:00Z"),
|
||||
skipped: false,
|
||||
idempotencyKey: null,
|
||||
scheduledFor: new Date("2026-06-15T05:00:00Z"),
|
||||
source: "WEB",
|
||||
createdAt: new Date("2026-06-15T05:01:00Z"),
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
|
||||
id: "row-taken",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
medicationId: "med-1",
|
||||
scheduledFor: "2026-06-15T05:00:30.000Z",
|
||||
skipped: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(consumeForIntake).not.toHaveBeenCalled();
|
||||
expect(restoreForIntake).toHaveBeenCalledTimes(1);
|
||||
expect(restoreForIntake).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: "user-1", eventId: "row-taken" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("an idempotency-key replay (duplicate) never consumes again", async () => {
|
||||
// An off-window take (00:20 local night, no scheduledFor) resolves
|
||||
// ad-hoc → the resolver-null branch runs its replay pre-check and
|
||||
// classifies the re-submission as a duplicate.
|
||||
vi.setSystemTime(new Date("2026-06-15T00:30:00.000Z"));
|
||||
vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValueOnce({
|
||||
id: "row-existing",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
medicationId: "med-1",
|
||||
takenAt: "2026-06-15T00:20:00.000Z",
|
||||
idempotencyKey: "ios-replay-1",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { duplicates: number; entries: Array<{ status: string }> };
|
||||
};
|
||||
expect(body.data.duplicates).toBe(1);
|
||||
expect(body.data.entries[0].status).toBe("duplicate");
|
||||
expect(consumeForIntake).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,10 @@ import {
|
||||
resolveSlotInstantForWrite,
|
||||
} from "@/lib/medications/scheduling/slot-upsert";
|
||||
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
|
||||
import {
|
||||
consumeForIntake,
|
||||
restoreForIntake,
|
||||
} from "@/lib/medications/inventory/consumption";
|
||||
import {
|
||||
boundedTakenAtSchema,
|
||||
injectionSiteEnum,
|
||||
@@ -407,7 +411,8 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
if (applied.noDowngradeNoOp) {
|
||||
// C2 — pending echo onto an already-actioned slot. Report it as a
|
||||
// duplicate so the iOS cursor advances WITHOUT downgrading the
|
||||
// recorded dose.
|
||||
// recorded dose. A pending echo is never a taken write, so it
|
||||
// must never reach the inventory consume below.
|
||||
duplicates += 1;
|
||||
results.push({ index: i, status: "duplicate", id: applied.row.id });
|
||||
} else if (applied.outcome === "updated") {
|
||||
@@ -417,6 +422,25 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
inserted += 1;
|
||||
results.push({ index: i, status: "inserted", id: applied.row.id });
|
||||
}
|
||||
// v1.16.10 — inventory: a taken write consumes (the stamp on the
|
||||
// row makes a replayed sync batch exactly-once), an explicit skip
|
||||
// refunds a previously-taken row's stamp. Pending echoes carry
|
||||
// neither flag and never touch stock.
|
||||
if (!applied.noDowngradeNoOp && isExplicitTaken) {
|
||||
await consumeForIntake({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
eventId: applied.row.id,
|
||||
intakeAt: entry.takenAt as Date,
|
||||
});
|
||||
} else if (!applied.noDowngradeNoOp && isExplicitSkip) {
|
||||
await restoreForIntake({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
eventId: applied.row.id,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Resolver-null: PRN / off-slot / future-slot-guarded. Before the
|
||||
// standalone insert, two guards keep the slot from forking into a
|
||||
@@ -485,6 +509,22 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
inserted += 1;
|
||||
results.push({ index: i, status: "inserted", id: applied.row.id });
|
||||
}
|
||||
// v1.16.10 — same inventory rule as the canonical-slot branch.
|
||||
if (!applied.noDowngradeNoOp && isExplicitTaken) {
|
||||
await consumeForIntake({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
eventId: applied.row.id,
|
||||
intakeAt: entry.takenAt as Date,
|
||||
});
|
||||
} else if (!applied.noDowngradeNoOp && isExplicitSkip) {
|
||||
await restoreForIntake({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
eventId: applied.row.id,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Genuinely standalone. Anchor the row on the intake instant —
|
||||
// the documented ad-hoc contract (`scheduledFor = takenAt`) — so
|
||||
@@ -515,6 +555,18 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
});
|
||||
inserted += 1;
|
||||
results.push({ index: i, status: "inserted", id: row.id });
|
||||
// v1.16.10 — a fresh standalone taken row consumes inventory
|
||||
// units (a fresh skip / pending row has nothing to consume or
|
||||
// refund).
|
||||
if (isExplicitTaken) {
|
||||
await consumeForIntake({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
eventId: row.id,
|
||||
intakeAt: entry.takenAt as Date,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const dayKey = dayKeyForScheduledFor(
|
||||
|
||||
@@ -42,6 +42,10 @@ import {
|
||||
resolveSlotForWriteByBand,
|
||||
} from "@/lib/medications/scheduling/slot-upsert";
|
||||
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
|
||||
import {
|
||||
consumeForIntake,
|
||||
restoreForIntake,
|
||||
} from "@/lib/medications/inventory/consumption";
|
||||
import {
|
||||
injectionSiteEnum,
|
||||
type InjectionSiteValue,
|
||||
@@ -540,11 +544,32 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
data: { snoozedUntil: null },
|
||||
}),
|
||||
]);
|
||||
// v1.16.10 — the toggle recorded a take; consume inventory units.
|
||||
// The stamp on the row makes a re-toggle of an already-taken slot
|
||||
// a no-op, so the count only moves on the genuine pending→taken
|
||||
// (or skipped→taken) transition.
|
||||
await consumeForIntake({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
medicationId: existing.medicationId,
|
||||
eventId: intakeId,
|
||||
intakeAt: resolvedTakenAt,
|
||||
});
|
||||
} else {
|
||||
// The take re-attributed to a different slot. Tombstone the source row
|
||||
// and route the dose through the shared canonical-slot upsert, which
|
||||
// converges onto any row already at the target slot rather than
|
||||
// bare-updating into an occupied slot (P2002-safe).
|
||||
//
|
||||
// v1.16.10 — refund the source row's consumption stamp BEFORE the
|
||||
// tombstone, then consume on the converged row below: a slot move
|
||||
// is a re-binding of one real dose, so the stock nets exactly one
|
||||
// consumption.
|
||||
await restoreForIntake({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
eventId: intakeId,
|
||||
});
|
||||
await prisma.medicationIntakeEvent.update({
|
||||
where: { id: intakeId },
|
||||
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
|
||||
@@ -574,6 +599,15 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
where: { id: existing.medicationId },
|
||||
data: { snoozedUntil: null },
|
||||
});
|
||||
// v1.16.10 — consume on the converged row (the old row's stamp
|
||||
// was refunded above, so the move nets one consumption).
|
||||
await consumeForIntake({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
medicationId: existing.medicationId,
|
||||
eventId: applied.row.id,
|
||||
intakeAt: resolvedTakenAt,
|
||||
});
|
||||
}
|
||||
} else if (status === "skipped") {
|
||||
updated = await prisma.medicationIntakeEvent.update({
|
||||
@@ -581,6 +615,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// v1.7.0 sync — bump the reconciliation counter on the skip toggle.
|
||||
data: { takenAt: null, skipped: true, syncVersion: { increment: 1 } },
|
||||
});
|
||||
// v1.16.10 — the row stopped being taken; refund whatever its
|
||||
// consumption stamp recorded (no-op for a never-consumed row).
|
||||
await restoreForIntake({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
eventId: intakeId,
|
||||
});
|
||||
} else {
|
||||
// snoozed: snoozedUntil lives on the Medication row.
|
||||
const until = snoozedUntil ?? new Date(Date.now() + 30 * 60_000); // default +30min
|
||||
|
||||
@@ -245,6 +245,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
category,
|
||||
treatmentClass,
|
||||
dosesPerUnit,
|
||||
unitsPerDose,
|
||||
deliveryForm,
|
||||
trackInjectionSites,
|
||||
allowedInjectionSites,
|
||||
@@ -312,6 +313,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// wire schema; Prisma fills the default GENERIC when omitted.
|
||||
...(treatmentClass !== undefined && { treatmentClass }),
|
||||
...(dosesPerUnit !== undefined && { dosesPerUnit }),
|
||||
// v1.16.10 — units consumed per dose; Prisma defaults to 1 when omitted.
|
||||
...(unitsPerDose !== undefined && { unitsPerDose }),
|
||||
// v1.6.0 — route of administration; Prisma defaults to ORAL when omitted.
|
||||
...(deliveryForm !== undefined && { deliveryForm }),
|
||||
// v1.8.5 — injection-site tracking opt-in + per-medication allowed
|
||||
|
||||
@@ -55,6 +55,11 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/medications/inventory/consumption", () => ({
|
||||
consumeForIntake: vi.fn().mockResolvedValue(undefined),
|
||||
restoreForIntake: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMedications: vi.fn(),
|
||||
}));
|
||||
@@ -257,6 +262,15 @@ describe("Telegram webhook — callback dispatch", () => {
|
||||
});
|
||||
// Intake write + snooze reset commit atomically.
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
// v1.16.10 — the confirmed take consumes inventory units (after the
|
||||
// intake transaction committed, on the created row).
|
||||
const { consumeForIntake } = await import(
|
||||
"@/lib/medications/inventory/consumption"
|
||||
);
|
||||
expect(consumeForIntake).toHaveBeenCalledTimes(1);
|
||||
expect(consumeForIntake).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: "user-1", medicationId: "med-1" }),
|
||||
);
|
||||
|
||||
expect(answerTelegramCallbackQuery).toHaveBeenCalledTimes(1);
|
||||
expect(deleteMessage).toHaveBeenCalledWith(
|
||||
@@ -329,6 +343,13 @@ describe("Telegram webhook — callback dispatch", () => {
|
||||
expect(snoozedUntil.getMinutes()).toBe(59);
|
||||
// Skip row + rest-of-day snooze commit atomically.
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
// v1.16.10 — an explicit skip refunds a previously-consumed stamp
|
||||
// (no-op for a never-consumed row) and never consumes.
|
||||
const { consumeForIntake, restoreForIntake } = await import(
|
||||
"@/lib/medications/inventory/consumption"
|
||||
);
|
||||
expect(consumeForIntake).not.toHaveBeenCalled();
|
||||
expect(restoreForIntake).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("'ack:<medId>' answers the callback without creating an intake event", async () => {
|
||||
|
||||
@@ -20,6 +20,10 @@ import {
|
||||
resolveSlotForWriteByBand,
|
||||
} from "@/lib/medications/scheduling/slot-upsert";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import {
|
||||
consumeForIntake,
|
||||
restoreForIntake,
|
||||
} from "@/lib/medications/inventory/consumption";
|
||||
import { localHmAsUtc } from "@/lib/tz/local-day";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
@@ -268,8 +272,9 @@ async function markMedicationTaken(
|
||||
// an aborted Postgres transaction, so the rare create race rolls the
|
||||
// whole write back instead of converging in place — Telegram
|
||||
// redelivers the non-200 update and the retry finds the raced row.
|
||||
scheduledFor = await prisma.$transaction(async (tx) => {
|
||||
const written = await prisma.$transaction(async (tx) => {
|
||||
let slotScheduledFor: Date;
|
||||
let eventId: string;
|
||||
if (canonicalSlot) {
|
||||
const applied = await applyCanonicalSlotWrite({
|
||||
client: tx,
|
||||
@@ -285,6 +290,7 @@ async function markMedicationTaken(
|
||||
attributionSource: "AUTO",
|
||||
});
|
||||
slotScheduledFor = applied.row.scheduledFor;
|
||||
eventId = applied.row.id;
|
||||
} else {
|
||||
// Ad-hoc / PRN — standalone row under the documented contract
|
||||
// (`scheduledFor = takenAt`).
|
||||
@@ -300,12 +306,25 @@ async function markMedicationTaken(
|
||||
},
|
||||
});
|
||||
slotScheduledFor = created.scheduledFor;
|
||||
eventId = created.id;
|
||||
}
|
||||
await tx.medication.update({
|
||||
where: { id: medication.id },
|
||||
data: { snoozedUntil: null },
|
||||
});
|
||||
return slotScheduledFor;
|
||||
return { slotScheduledFor, eventId };
|
||||
});
|
||||
scheduledFor = written.slotScheduledFor;
|
||||
// v1.16.10 — the button confirmed a take; consume inventory units.
|
||||
// Runs after the intake transaction committed (the dose record must
|
||||
// never hinge on the stock write); the stamp keeps a Telegram
|
||||
// redelivery exactly-once.
|
||||
await consumeForIntake({
|
||||
client: prisma,
|
||||
userId,
|
||||
medicationId: medication.id,
|
||||
eventId: written.eventId,
|
||||
intakeAt: takenAt,
|
||||
});
|
||||
// The confirmation must reflect on the next read (today feed, card
|
||||
// pill, compliance) rather than wait out the cache TTL.
|
||||
@@ -494,8 +513,9 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
// re-reminded for a recorded skip or silenced without one. Same
|
||||
// P2002 trade-off as the take path — the rare create race rolls
|
||||
// back and Telegram's redelivery converges on the raced row.
|
||||
skippedScheduledFor = await prisma.$transaction(async (tx) => {
|
||||
const writtenSkip = await prisma.$transaction(async (tx) => {
|
||||
let slotScheduledFor: Date;
|
||||
let eventId: string;
|
||||
if (canonicalSlot) {
|
||||
const applied = await applyCanonicalSlotWrite({
|
||||
client: tx,
|
||||
@@ -510,6 +530,7 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
createSource: "REMINDER",
|
||||
});
|
||||
slotScheduledFor = applied.row.scheduledFor;
|
||||
eventId = applied.row.id;
|
||||
} else {
|
||||
const created = await tx.medicationIntakeEvent.create({
|
||||
data: {
|
||||
@@ -523,12 +544,22 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
},
|
||||
});
|
||||
slotScheduledFor = created.scheduledFor;
|
||||
eventId = created.id;
|
||||
}
|
||||
await tx.medication.update({
|
||||
where: { id: medication.id },
|
||||
data: { snoozedUntil: endOfDay },
|
||||
});
|
||||
return slotScheduledFor;
|
||||
return { slotScheduledFor, eventId };
|
||||
});
|
||||
skippedScheduledFor = writtenSkip.slotScheduledFor;
|
||||
// v1.16.10 — an explicit skip can downgrade a previously-taken
|
||||
// slot row (last-write-wins); refund whatever its consumption
|
||||
// stamp recorded. No-op for a never-consumed row.
|
||||
await restoreForIntake({
|
||||
client: prisma,
|
||||
userId: user.id,
|
||||
eventId: writtenSkip.eventId,
|
||||
});
|
||||
invalidateUserMedications(user.id, { evict: true });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user