mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +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
@@ -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