fix(medications): stock claims are atomic and only a dose becoming taken consumes

Two hardening passes on the consumption stamp:

- consumeForIntake claims the stamp with a single conditional UPDATE
  (NULL-stamp predicate + row lock) instead of read-then-write, so a
  double tap racing a sync replay decrements exactly once; the loser
  sees count 0. restoreForIntake clears the stamp the same way, so
  concurrent restores cannot refund twice.
- Every seam consumes only on the pending-to-taken transition. A sync
  replay that re-posts an already-taken history row, or an edit that
  corrects the timestamp of an old dose, never touches today's stock.
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 18:48:30 +02:00
1 parent e21dfd040e
commit 7eeab2ae98
9 files changed
+345 -90

No files matched your search

+18 -9
View File
@@ -275,6 +275,10 @@ async function markMedicationTaken(
const written = await prisma.$transaction(async (tx) => {
let slotScheduledFor: Date;
let eventId: string;
// v1.16.10 — only a genuine pending→taken transition may consume
// inventory units; a redelivered button press converging onto an
// already-taken slot row (including a pre-stamp row) must not.
let consumedTransition = true;
if (canonicalSlot) {
const applied = await applyCanonicalSlotWrite({
client: tx,
@@ -291,6 +295,7 @@ async function markMedicationTaken(
});
slotScheduledFor = applied.row.scheduledFor;
eventId = applied.row.id;
consumedTransition = applied.consumedTransition;
} else {
// Ad-hoc / PRN — standalone row under the documented contract
// (`scheduledFor = takenAt`).
@@ -312,20 +317,24 @@ async function markMedicationTaken(
where: { id: medication.id },
data: { snoozedUntil: null },
});
return { slotScheduledFor, eventId };
return { slotScheduledFor, eventId, consumedTransition };
});
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,
});
// redelivery exactly-once and the transition gate keeps a re-press
// on an already-taken slot from decrementing through a pre-stamp
// row.
if (written.consumedTransition) {
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.
invalidateUserMedications(userId, { evict: true });