mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
fix(medications): converge intake writes onto one row per scheduled slot
A twice-daily medication ended up with two rows for one dose slot: the projector / reminder worker mints a pending row (source REMINDER) at the slot's `localHmAsUtc` instant, while a "taken" write from the per-med or bulk intake route inserted a fresh row (source WEB / API). Because `source` is part of the `(userId, medicationId, scheduledFor, source)` unique key — and the written instant could drift by a minute from the projected one — the two rows coexisted: one taken, one still pending and overdue. The card then read the slot as taken, counted 100%, and suppressed the "due now" prompt for a dose the user had not taken. Resolve every scheduled intake write to the slot's canonical instant before writing and upsert in place, source-agnostic: - `resolve-slot-instant.ts` snaps an incoming instant to the nearest scheduled slot for that day (enumerated through the canonical recurrence engine, so PRN and cyclic off-weeks resolve to no slot) and anchors it on the same `localHmAsUtc` the projector and worker use. Off-slot and as-needed writes resolve to null and keep insert semantics. - The per-med and bulk routes look up the existing row at the canonical `(userId, medicationId, scheduledFor)` ignoring source and update it in place — mirroring the status-toggle route — instead of inserting a sibling. The bulk per-entry response gains an additive `updated` status. - The reminder worker's missed-dose mint now skips a slot that already carries an actioned row (taken or skipped) from any source, not only an existing pending REMINDER row. PRN / as-needed writes are unaffected. Regression tests pin the collapse (exact and one-minute drift), the PRN exemption, and the worker guard.
This commit is contained in:
1 parent
4578078cc1
commit
0d7d08f616
11 files changed
+1033
-52
No files matched your search
@@ -26,6 +26,10 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: {
|
||||
findUnique: vi.fn(),
|
||||
// v1.8.2 — the slot-snap resolver loads the medication via
|
||||
// findFirst; default to a schedule-less med so the resolver returns
|
||||
// null (unscheduled path) and the original insert behaviour holds.
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
@@ -307,6 +311,16 @@ describe("POST /api/medications/[id]/intake — one-shot lifecycle", () => {
|
||||
skipped: false,
|
||||
};
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([createdEvent] as never);
|
||||
// v1.8.2 — slot-snap resolver loads the med; no schedules → null slot
|
||||
// → original insert + dedup path preserved.
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce({
|
||||
id: "med-1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: true,
|
||||
createdAt: new Date(),
|
||||
schedules: [],
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce(
|
||||
null as never, // dedup probe
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ import { reconcileOneShotState } from "@/lib/medications/lifecycle";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { resolveSlotInstantForWrite } from "@/lib/medications/scheduling/slot-upsert";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
@@ -74,6 +75,29 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
const { scheduledFor, takenAt, skipped, idempotencyKey } = parsed.data;
|
||||
|
||||
const resolvedTakenAt = skipped ? null : (takenAt ?? new Date());
|
||||
const incomingScheduledFor = scheduledFor ?? takenAt ?? new Date();
|
||||
|
||||
// v1.8.2 — source-agnostic slot snap. A twice-daily med carries a
|
||||
// pending REMINDER row the projector/worker minted at the canonical
|
||||
// `localHmAsUtc` slot instant. Without this, a manual "Genommen" write
|
||||
// (source WEB) inserted a SECOND row for the slot because the unique
|
||||
// key includes `source` and the iOS-vs-server `scheduledFor` can drift
|
||||
// by a minute — inflating compliance to 100% and suppressing the
|
||||
// "take now" prompt for a dose the user hadn't taken. Snap the write
|
||||
// to the canonical slot and update the existing row in place.
|
||||
//
|
||||
// Resolved BEFORE the idempotency/dedup window so a scheduled dose
|
||||
// routes through the slot upsert (which is itself the dedup): the
|
||||
// legacy 60-second window would otherwise short-circuit by returning
|
||||
// the slot's pending REMINDER row WITHOUT applying the user's takenAt.
|
||||
const canonicalSlot = await resolveSlotInstantForWrite({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
userTz: user.timezone,
|
||||
incoming: incomingScheduledFor,
|
||||
});
|
||||
|
||||
// Idempotency check (explicit key or server-side dedup window)
|
||||
if (idempotencyKey) {
|
||||
const existing = await prisma.medicationIntakeEvent.findFirst({
|
||||
@@ -86,8 +110,10 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
if (existing) {
|
||||
return apiSuccess(existing);
|
||||
}
|
||||
} else {
|
||||
// Server-side dedup: prevent double-logging within 60 seconds
|
||||
} else if (!canonicalSlot) {
|
||||
// Unscheduled / PRN only — the slot upsert handles dedup for
|
||||
// scheduled doses by collapsing onto the canonical slot row.
|
||||
// Server-side dedup: prevent double-logging within 60 seconds.
|
||||
const recentDuplicate = await prisma.medicationIntakeEvent.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
@@ -102,28 +128,89 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
}
|
||||
|
||||
const [event] = await prisma.$transaction([
|
||||
prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
let event;
|
||||
if (canonicalSlot) {
|
||||
// Scheduled dose — find the slot row regardless of `source` (the
|
||||
// pending REMINDER row, or any prior row for this slot) and update
|
||||
// it in place; mirrors the status-toggle route. Falls through to a
|
||||
// create when the slot has no row yet.
|
||||
const existingSlotRow = await prisma.medicationIntakeEvent.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
scheduledFor: scheduledFor ?? takenAt ?? new Date(),
|
||||
takenAt: skipped ? null : (takenAt ?? new Date()),
|
||||
skipped,
|
||||
source: "WEB",
|
||||
idempotencyKey: idempotencyKey ?? null,
|
||||
scheduledFor: canonicalSlot,
|
||||
deletedAt: null,
|
||||
},
|
||||
}),
|
||||
// Reset snooze when medication is taken
|
||||
...(!skipped
|
||||
? [
|
||||
prisma.medication.update({
|
||||
where: { id },
|
||||
data: { snoozedUntil: null },
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
});
|
||||
if (existingSlotRow) {
|
||||
[event] = await prisma.$transaction([
|
||||
prisma.medicationIntakeEvent.update({
|
||||
where: { id: existingSlotRow.id },
|
||||
data: {
|
||||
takenAt: resolvedTakenAt,
|
||||
skipped,
|
||||
syncVersion: { increment: 1 },
|
||||
idempotencyKey:
|
||||
idempotencyKey ?? existingSlotRow.idempotencyKey ?? null,
|
||||
},
|
||||
}),
|
||||
...(!skipped
|
||||
? [
|
||||
prisma.medication.update({
|
||||
where: { id },
|
||||
data: { snoozedUntil: null },
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
} else {
|
||||
[event] = await prisma.$transaction([
|
||||
prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
scheduledFor: canonicalSlot,
|
||||
takenAt: resolvedTakenAt,
|
||||
skipped,
|
||||
source: "WEB",
|
||||
idempotencyKey: idempotencyKey ?? null,
|
||||
},
|
||||
}),
|
||||
...(!skipped
|
||||
? [
|
||||
prisma.medication.update({
|
||||
where: { id },
|
||||
data: { snoozedUntil: null },
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
// Unscheduled / PRN / off-slot — keep the original insert behaviour.
|
||||
[event] = await prisma.$transaction([
|
||||
prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
scheduledFor: incomingScheduledFor,
|
||||
takenAt: resolvedTakenAt,
|
||||
skipped,
|
||||
source: "WEB",
|
||||
idempotencyKey: idempotencyKey ?? null,
|
||||
},
|
||||
}),
|
||||
// Reset snooze when medication is taken
|
||||
...(!skipped
|
||||
? [
|
||||
prisma.medication.update({
|
||||
where: { id },
|
||||
data: { snoozedUntil: null },
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
}
|
||||
|
||||
// v1.4.25 W19b — pen-inventory dose decrement. Only fires for
|
||||
// non-skipped intakes; a skipped event is not a consumption event.
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
recomputeMedicationComplianceForDay,
|
||||
dayKeyForScheduledFor,
|
||||
} from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { resolveSlotInstantForWrite } from "@/lib/medications/scheduling/slot-upsert";
|
||||
|
||||
const MAX_ENTRIES_PER_BATCH = 500;
|
||||
const BATCH_RATE_LIMIT_MAX = 60;
|
||||
@@ -70,7 +71,12 @@ const bulkPayloadSchema = z.object({
|
||||
entries: z.array(bulkEntrySchema).min(1).max(MAX_ENTRIES_PER_BATCH),
|
||||
});
|
||||
|
||||
type EntryStatus = "inserted" | "duplicate" | "skipped";
|
||||
// v1.8.2 — `updated` joins the per-entry status vocabulary: a write that
|
||||
// snaps onto an existing scheduled-slot row (e.g. the pending REMINDER
|
||||
// row) updates it in place rather than inserting. The iOS sync engine
|
||||
// treats `updated` the same as `inserted` for cursor advancement — both
|
||||
// mean "the server accepted this entry and produced a row id".
|
||||
type EntryStatus = "inserted" | "updated" | "duplicate" | "skipped";
|
||||
interface EntryResult {
|
||||
index: number;
|
||||
status: EntryStatus;
|
||||
@@ -154,6 +160,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
|
||||
const results: EntryResult[] = [];
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
let duplicates = 0;
|
||||
const skipped: Array<{ index: number; reason: string }> = [];
|
||||
// v1.4.39 W-MED — collect distinct `(medicationId, dayKey)` pairs
|
||||
@@ -173,23 +180,70 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
|
||||
try {
|
||||
const scheduledFor = entry.scheduledFor ?? new Date();
|
||||
// The idempotencyKey, when supplied, has a UNIQUE index. A
|
||||
// re-submission of the same key returns the existing row via
|
||||
// P2002 → status: "duplicate".
|
||||
const row = await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
scheduledFor,
|
||||
takenAt: entry.takenAt ?? null,
|
||||
skipped: entry.skipped,
|
||||
source: "API",
|
||||
idempotencyKey: entry.idempotencyKey ?? null,
|
||||
},
|
||||
const incomingScheduledFor =
|
||||
entry.scheduledFor ?? entry.takenAt ?? new Date();
|
||||
|
||||
// v1.8.2 — source-agnostic slot snap. iOS posts the "Genommen"
|
||||
// reminder action here (source API); without this it inserted a
|
||||
// SECOND row for a slot already carrying the projector/worker's
|
||||
// pending REMINDER row (the unique key includes `source`, and the
|
||||
// iOS-vs-server `scheduledFor` drifts by a minute). Snap onto the
|
||||
// canonical slot instant and update the existing row in place.
|
||||
const canonicalSlot = await resolveSlotInstantForWrite({
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
userTz: user.timezone,
|
||||
incoming: incomingScheduledFor,
|
||||
});
|
||||
inserted += 1;
|
||||
results.push({ index: i, status: "inserted", id: row.id });
|
||||
|
||||
const scheduledFor = canonicalSlot ?? incomingScheduledFor;
|
||||
|
||||
const existingSlotRow = canonicalSlot
|
||||
? await prisma.medicationIntakeEvent.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
scheduledFor: canonicalSlot,
|
||||
deletedAt: null,
|
||||
},
|
||||
select: { id: true, idempotencyKey: true },
|
||||
})
|
||||
: null;
|
||||
|
||||
if (existingSlotRow) {
|
||||
// Scheduled dose with an existing slot row — update in place
|
||||
// (keep the row's original `source`), report "updated".
|
||||
const row = await prisma.medicationIntakeEvent.update({
|
||||
where: { id: existingSlotRow.id },
|
||||
data: {
|
||||
takenAt: entry.takenAt ?? null,
|
||||
skipped: entry.skipped,
|
||||
syncVersion: { increment: 1 },
|
||||
idempotencyKey:
|
||||
entry.idempotencyKey ?? existingSlotRow.idempotencyKey ?? null,
|
||||
},
|
||||
});
|
||||
updated += 1;
|
||||
results.push({ index: i, status: "updated", id: row.id });
|
||||
} else {
|
||||
// No existing slot row (fresh scheduled dose, or unscheduled /
|
||||
// PRN) — insert. The idempotencyKey, when supplied, has a UNIQUE
|
||||
// index; a re-submission returns the existing row via P2002 →
|
||||
// status: "duplicate".
|
||||
const row = await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
scheduledFor,
|
||||
takenAt: entry.takenAt ?? null,
|
||||
skipped: entry.skipped,
|
||||
source: "API",
|
||||
idempotencyKey: entry.idempotencyKey ?? null,
|
||||
},
|
||||
});
|
||||
inserted += 1;
|
||||
results.push({ index: i, status: "inserted", id: row.id });
|
||||
}
|
||||
const dayKey = dayKeyForScheduledFor(scheduledFor, user.timezone);
|
||||
touchedDays.set(`${entry.medicationId}|${dayKey}`, {
|
||||
medicationId: entry.medicationId,
|
||||
@@ -231,6 +285,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
details: {
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
updated,
|
||||
duplicates,
|
||||
skipped: skipped.length,
|
||||
},
|
||||
@@ -241,6 +296,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
meta: {
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
updated,
|
||||
duplicates,
|
||||
skipped: skipped.length,
|
||||
},
|
||||
@@ -249,7 +305,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
|
||||
// caches when at least one row landed so the next read reflects the
|
||||
// ingested batch.
|
||||
if (inserted > 0) {
|
||||
if (inserted > 0 || updated > 0) {
|
||||
invalidateUserMedications(user.id);
|
||||
}
|
||||
|
||||
@@ -282,6 +338,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
return apiSuccess({
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
updated,
|
||||
duplicates,
|
||||
skipped,
|
||||
entries: results,
|
||||
|
||||
Reference in new issue
Block a user