mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +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,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
buildCanonicalSchedule,
|
||||
buildRecurrenceContext,
|
||||
scheduleEmitsInWindow,
|
||||
shouldMintMissedDoseRow,
|
||||
} from "@/lib/medications/scheduling/worker-helpers";
|
||||
import { syncUserMeasurements } from "@/lib/withings/sync";
|
||||
import { syncUserActivity } from "@/lib/withings/sync-activity";
|
||||
@@ -736,21 +737,23 @@ async function handleReminderCheck(jobs: Job<ReminderCheckPayload>[]) {
|
||||
|
||||
// RED phase: create missed intake event for this slot.
|
||||
if (currentPhase === "RED") {
|
||||
// v1.7.0 sync — intentionally NO `deletedAt: null` filter:
|
||||
// a tombstoned row still occupies the `(userId, medicationId,
|
||||
// scheduledFor, source)` unique slot, so the missed-dose
|
||||
// create must treat it as present to avoid a P2002 collision.
|
||||
const existingMissed = await prisma.medicationIntakeEvent.count({
|
||||
where: {
|
||||
medicationId: med.id,
|
||||
userId: med.user.id,
|
||||
scheduledFor: slotScheduledFor,
|
||||
takenAt: null,
|
||||
source: "REMINDER",
|
||||
},
|
||||
// v1.8.2 — gate the missed-dose mint through the shared
|
||||
// guard. It refuses to mint when the slot already carries an
|
||||
// existing pending REMINDER row (P2002-collision avoidance,
|
||||
// tombstones included) OR an ACTIONED row (taken / skipped)
|
||||
// from ANY source. The intake write paths snap a "Genommen" /
|
||||
// "Übersprungen" write onto the canonical slot instant
|
||||
// (source-agnostic update), so a user who acted before the
|
||||
// RED phase opens has a live taken/skipped row at this exact
|
||||
// slot — minting here would re-create the duplicate the
|
||||
// write paths just collapsed.
|
||||
const shouldMint = await shouldMintMissedDoseRow(prisma, {
|
||||
userId: med.user.id,
|
||||
medicationId: med.id,
|
||||
scheduledFor: slotScheduledFor,
|
||||
});
|
||||
|
||||
if (existingMissed === 0) {
|
||||
if (shouldMint) {
|
||||
await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: med.user.id,
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* v1.8.2 — canonical slot-snap helper tests.
|
||||
*
|
||||
* Proves the snap contract the intake write paths depend on:
|
||||
* - multi-time (07:00 / 19:00): an incoming instant near a slot snaps
|
||||
* to that slot's canonical `localHmAsUtc` instant; a ±1-minute drift
|
||||
* still snaps to the same instant (the duplicate-row root cause).
|
||||
* - single-time: snaps to the one slot.
|
||||
* - PRN schedule → null (keep insert behaviour, never collapse).
|
||||
* - off-slot beyond tolerance → null.
|
||||
* - DST spring-forward day still snaps to the canonical instant.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveCanonicalSlotInstant } from "../resolve-slot-instant";
|
||||
import type { SlotResolverMedication } from "../resolve-slot-instant";
|
||||
import type { WorkerScheduleRow } from "../worker-helpers";
|
||||
import { localHmAsUtc } from "@/lib/timezone";
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
|
||||
function makeSchedule(
|
||||
overrides: Partial<WorkerScheduleRow> = {},
|
||||
): WorkerScheduleRow {
|
||||
return {
|
||||
id: "sched-1",
|
||||
windowStart: "07:00",
|
||||
windowEnd: "08:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: [],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeMedication(
|
||||
schedules: WorkerScheduleRow[],
|
||||
overrides: Partial<SlotResolverMedication> = {},
|
||||
): SlotResolverMedication {
|
||||
return {
|
||||
id: "med-1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
schedules,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveCanonicalSlotInstant — multi-time", () => {
|
||||
// A daily 07:00 / 19:00 med. 2026-06-15 is CEST (UTC+2), so 07:00 local
|
||||
// = 05:00Z and 19:00 local = 17:00Z.
|
||||
const med = makeMedication([
|
||||
makeSchedule({ timesOfDay: ["07:00", "19:00"], windowEnd: "07:00" }),
|
||||
]);
|
||||
|
||||
it("snaps an exact-07:00 write to the canonical 07:00 slot instant", () => {
|
||||
const incoming = new Date("2026-06-15T05:00:00.000Z"); // 07:00 CEST
|
||||
const result = resolveCanonicalSlotInstant({ medication: med, userTz: TZ, incoming });
|
||||
const canonical = localHmAsUtc(incoming, TZ, 7, 0);
|
||||
expect(result?.toISOString()).toBe(canonical.toISOString());
|
||||
});
|
||||
|
||||
it("snaps a +1-minute-drifted 07:00 write to the SAME canonical instant", () => {
|
||||
const incoming = new Date("2026-06-15T05:01:00.000Z"); // 07:01 CEST
|
||||
const result = resolveCanonicalSlotInstant({ medication: med, userTz: TZ, incoming });
|
||||
const canonical = localHmAsUtc(
|
||||
new Date("2026-06-15T05:00:00.000Z"),
|
||||
TZ,
|
||||
7,
|
||||
0,
|
||||
);
|
||||
expect(result?.toISOString()).toBe(canonical.toISOString());
|
||||
});
|
||||
|
||||
it("snaps a 19:00 write to the canonical 19:00 slot, not the 07:00 slot", () => {
|
||||
const incoming = new Date("2026-06-15T17:02:00.000Z"); // 19:02 CEST
|
||||
const result = resolveCanonicalSlotInstant({ medication: med, userTz: TZ, incoming });
|
||||
const canonical = localHmAsUtc(incoming, TZ, 19, 0);
|
||||
expect(result?.toISOString()).toBe(canonical.toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCanonicalSlotInstant — single-time", () => {
|
||||
it("snaps to the one slot", () => {
|
||||
const med = makeMedication([
|
||||
makeSchedule({ timesOfDay: ["08:00"], windowStart: "08:00", windowEnd: "09:00" }),
|
||||
]);
|
||||
const incoming = new Date("2026-06-15T06:10:00.000Z"); // 08:10 CEST
|
||||
const result = resolveCanonicalSlotInstant({ medication: med, userTz: TZ, incoming });
|
||||
const canonical = localHmAsUtc(incoming, TZ, 8, 0);
|
||||
expect(result?.toISOString()).toBe(canonical.toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCanonicalSlotInstant — exemptions", () => {
|
||||
it("returns null for a PRN schedule (as-needed never snaps)", () => {
|
||||
const med = makeMedication([
|
||||
makeSchedule({ scheduleType: "PRN", timesOfDay: [] }),
|
||||
]);
|
||||
const incoming = new Date("2026-06-15T05:00:00.000Z");
|
||||
expect(
|
||||
resolveCanonicalSlotInstant({ medication: med, userTz: TZ, incoming }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a medication with no schedules", () => {
|
||||
const med = makeMedication([]);
|
||||
const incoming = new Date("2026-06-15T05:00:00.000Z");
|
||||
expect(
|
||||
resolveCanonicalSlotInstant({ medication: med, userTz: TZ, incoming }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an off-slot write beyond tolerance", () => {
|
||||
// 07:00 / 19:00 schedule; a 13:00-local write is 4–6h from either
|
||||
// slot — well outside the ±2h default tolerance → unscheduled.
|
||||
const med = makeMedication([
|
||||
makeSchedule({ timesOfDay: ["07:00", "19:00"], windowEnd: "07:00" }),
|
||||
]);
|
||||
const incoming = new Date("2026-06-15T11:00:00.000Z"); // 13:00 CEST
|
||||
expect(
|
||||
resolveCanonicalSlotInstant({ medication: med, userTz: TZ, incoming }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCanonicalSlotInstant — DST spring-forward", () => {
|
||||
it("snaps a write on the 2026-03-29 spring-forward day to the canonical instant", () => {
|
||||
// 2026-03-29 is the CET→CEST transition. 08:00 local exists (the
|
||||
// gap is 02:00→03:00). The canonical instant must match localHmAsUtc.
|
||||
const med = makeMedication([
|
||||
makeSchedule({ timesOfDay: ["08:00"], windowStart: "08:00", windowEnd: "09:00" }),
|
||||
]);
|
||||
const incoming = new Date("2026-03-29T06:05:00.000Z"); // 08:05 CEST
|
||||
const result = resolveCanonicalSlotInstant({ medication: med, userTz: TZ, incoming });
|
||||
const canonical = localHmAsUtc(incoming, TZ, 8, 0);
|
||||
expect(result?.toISOString()).toBe(canonical.toISOString());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* v1.8.2 — reminder-worker RED-phase mint guard.
|
||||
*
|
||||
* The worker must NOT mint a pending REMINDER row for a slot that the
|
||||
* user has already actioned (taken / skipped) from ANY source, nor for
|
||||
* a slot that already carries a pending REMINDER row. Pins the
|
||||
* duplicate-intake fix on the worker side.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { shouldMintMissedDoseRow } from "../worker-helpers";
|
||||
|
||||
const SLOT = {
|
||||
userId: "u1",
|
||||
medicationId: "m1",
|
||||
scheduledFor: new Date("2026-06-15T05:00:00.000Z"),
|
||||
};
|
||||
|
||||
/**
|
||||
* Fake `count` that resolves the two probes in call order: the first
|
||||
* call is the pending-REMINDER probe, the second is the actioned probe.
|
||||
*/
|
||||
function fakeClient(pendingReminder: number, actioned: number) {
|
||||
const count = vi
|
||||
.fn<(args: { where: Record<string, unknown> }) => Promise<number>>()
|
||||
.mockResolvedValueOnce(pendingReminder)
|
||||
.mockResolvedValueOnce(actioned);
|
||||
return { medicationIntakeEvent: { count } };
|
||||
}
|
||||
|
||||
describe("shouldMintMissedDoseRow", () => {
|
||||
it("mints when the slot is empty (no pending, no actioned row)", async () => {
|
||||
const client = fakeClient(0, 0);
|
||||
expect(await shouldMintMissedDoseRow(client, SLOT)).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT mint when a pending REMINDER row already exists", async () => {
|
||||
const client = fakeClient(1, 0);
|
||||
expect(await shouldMintMissedDoseRow(client, SLOT)).toBe(false);
|
||||
// short-circuits before the actioned probe
|
||||
expect(client.medicationIntakeEvent.count).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does NOT mint when an actioned (taken) row exists from any source", async () => {
|
||||
const client = fakeClient(0, 1);
|
||||
expect(await shouldMintMissedDoseRow(client, SLOT)).toBe(false);
|
||||
// the actioned probe filters takenAt-set OR skipped, live rows only
|
||||
const actionedWhere = client.medicationIntakeEvent.count.mock.calls[1]?.[0]
|
||||
.where as Record<string, unknown>;
|
||||
expect(actionedWhere.deletedAt).toBeNull();
|
||||
expect(actionedWhere.OR).toEqual([
|
||||
{ takenAt: { not: null } },
|
||||
{ skipped: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does NOT mint when a skipped row exists at the slot", async () => {
|
||||
const client = fakeClient(0, 1);
|
||||
expect(await shouldMintMissedDoseRow(client, SLOT)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* v1.8.2 — canonical slot-snap for intake writes.
|
||||
*
|
||||
* Closes the duplicate-intake-row bug: a twice-daily med ends up with a
|
||||
* pending REMINDER row AND a separate taken WEB/API row for the same
|
||||
* dose slot because the unique key carries `source` and the two
|
||||
* `scheduledFor` instants can drift by a minute between iOS and the
|
||||
* server's `localHmAsUtc`. The intake write paths use this helper to
|
||||
* snap an incoming write to the SAME canonical slot instant the
|
||||
* projector + reminder worker mint with, so the write resolves to one
|
||||
* row per slot regardless of `source` or sub-minute drift.
|
||||
*
|
||||
* Given a medication's schedules, the user's IANA timezone, and an
|
||||
* incoming instant (the write's `scheduledFor`, or `takenAt` as a
|
||||
* fallback), `resolveCanonicalSlotInstant` returns the canonical slot
|
||||
* instant for that dose — byte-identical to the projector's
|
||||
* `localHmAsUtc(day, userTz, h, m)` row — or `null` when the dose does
|
||||
* not map to a scheduled slot (PRN / as-needed / off-slot beyond
|
||||
* tolerance / cyclic off-week). A `null` result is the caller's signal
|
||||
* to keep the unmodified insert behaviour.
|
||||
*
|
||||
* Pure / synchronous: no DB access. The caller `select`s the schedule
|
||||
* columns the canonical engine consumes and threads `lastIntakeAt` in.
|
||||
*/
|
||||
import {
|
||||
buildCanonicalSchedule,
|
||||
buildRecurrenceContext,
|
||||
type WorkerMedicationRow,
|
||||
type WorkerScheduleRow,
|
||||
} from "@/lib/medications/scheduling/worker-helpers";
|
||||
import {
|
||||
occurrencesBetween,
|
||||
type CanonicalSchedule,
|
||||
type RecurrenceContext,
|
||||
} from "@/lib/medications/scheduling/recurrence";
|
||||
import { getLocalDateParts, localHmAsUtc } from "@/lib/timezone";
|
||||
|
||||
const ONE_MINUTE_MS = 60_000;
|
||||
const ONE_HOUR_MS = 60 * ONE_MINUTE_MS;
|
||||
const ONE_DAY_MS = 24 * ONE_HOUR_MS;
|
||||
|
||||
/**
|
||||
* Default snap tolerance when a schedule has no usable window span. ±2h
|
||||
* is wide enough to absorb the iOS-vs-server `localHmAsUtc` minute drift
|
||||
* plus a reasonable "I took it a bit late" margin, while staying narrow
|
||||
* enough that a clearly off-slot manual log (e.g. a 13:00 entry against a
|
||||
* 07:00 / 19:00 schedule) is treated as unscheduled and keeps its own
|
||||
* row rather than colliding into the nearest slot.
|
||||
*/
|
||||
const DEFAULT_TOLERANCE_MS = 2 * ONE_HOUR_MS;
|
||||
|
||||
/**
|
||||
* The minimal medication projection the slot resolver needs. Matches the
|
||||
* worker/projector `select` shape — `oneShot`, the schedule rows, plus
|
||||
* the recurrence-context anchors.
|
||||
*/
|
||||
export interface SlotResolverMedication extends WorkerMedicationRow {
|
||||
schedules: WorkerScheduleRow[];
|
||||
}
|
||||
|
||||
export interface ResolveSlotInput {
|
||||
medication: SlotResolverMedication;
|
||||
userTz: string;
|
||||
/**
|
||||
* The write's incoming `scheduledFor`, or `takenAt` as a fallback when
|
||||
* the client omitted `scheduledFor`. The day-in-tz of this instant
|
||||
* determines which calendar day's slots are enumerated.
|
||||
*/
|
||||
incoming: Date;
|
||||
/**
|
||||
* Latest non-tombstoned `takenAt` for the medication. Only consulted by
|
||||
* rolling schedules; pass `null` when unknown / not loaded.
|
||||
*/
|
||||
lastIntakeAt?: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap `incoming` to the nearest canonical scheduled-slot instant for the
|
||||
* dose's calendar day (in `userTz`), or return `null` when no slot lands
|
||||
* within tolerance (treat as unscheduled / PRN — keep insert behaviour).
|
||||
*
|
||||
* The returned instant is produced by `localHmAsUtc(day, userTz, h, m)` —
|
||||
* the exact function the projector (`project-today-intakes.ts`) and the
|
||||
* reminder worker mint pending rows with — so an existing pending row for
|
||||
* the slot dedupes on the canonical instant.
|
||||
*/
|
||||
export function resolveCanonicalSlotInstant(
|
||||
input: ResolveSlotInput,
|
||||
): Date | null {
|
||||
const { medication, userTz, incoming } = input;
|
||||
const schedules = medication.schedules ?? [];
|
||||
if (schedules.length === 0) return null;
|
||||
|
||||
const ctx: RecurrenceContext = buildRecurrenceContext({
|
||||
medication,
|
||||
userTz,
|
||||
lastIntakeAt: input.lastIntakeAt ?? null,
|
||||
});
|
||||
|
||||
// The day in userTz implied by the incoming instant. Enumerate that
|
||||
// day's slots over a window padded by a day on each side so a
|
||||
// time-of-day near the local-midnight boundary (and any DST shift) is
|
||||
// still captured — the projector + worker apply the time-of-day to the
|
||||
// local day, which can land just outside a naive same-UTC-day window.
|
||||
const parts = getLocalDateParts(incoming, userTz);
|
||||
const localDayMidnightUtc = localHmAsUtc(incoming, userTz, 0, 0);
|
||||
const windowStart = new Date(localDayMidnightUtc.getTime() - ONE_DAY_MS);
|
||||
const windowEnd = new Date(localDayMidnightUtc.getTime() + 2 * ONE_DAY_MS);
|
||||
|
||||
let best: { at: Date; toleranceMs: number; deltaMs: number } | null = null;
|
||||
|
||||
for (const scheduleRow of schedules) {
|
||||
const canonical: CanonicalSchedule = buildCanonicalSchedule(scheduleRow);
|
||||
const occurrences = occurrencesBetween(
|
||||
canonical,
|
||||
windowStart,
|
||||
windowEnd,
|
||||
ctx,
|
||||
);
|
||||
const tolerance = snapToleranceMs(canonical);
|
||||
|
||||
for (const occ of occurrences) {
|
||||
// Only consider slots that land on the SAME local calendar day as
|
||||
// the incoming instant — a slot on the padded neighbouring day must
|
||||
// not capture a write meant for a different day.
|
||||
const occParts = getLocalDateParts(occ.at, userTz);
|
||||
if (
|
||||
occParts.year !== parts.year ||
|
||||
occParts.month !== parts.month ||
|
||||
occParts.day !== parts.day
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const deltaMs = Math.abs(occ.at.getTime() - incoming.getTime());
|
||||
if (deltaMs > tolerance) continue;
|
||||
if (best === null || deltaMs < best.deltaMs) {
|
||||
best = { at: occ.at, toleranceMs: tolerance, deltaMs };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best ? best.at : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tolerance = ± half the schedule's window span (so two adjacent slots
|
||||
* never overlap their capture zones), falling back to ±2h when the
|
||||
* window is degenerate (`windowStart === windowEnd`) or unparseable.
|
||||
*/
|
||||
function snapToleranceMs(schedule: CanonicalSchedule): number {
|
||||
const startMin = hhmmToMinutes(schedule.windowStart);
|
||||
const endMin = hhmmToMinutes(schedule.windowEnd);
|
||||
if (startMin === null || endMin === null) return DEFAULT_TOLERANCE_MS;
|
||||
let span = endMin - startMin;
|
||||
if (span < 0) span += 24 * 60; // overnight window
|
||||
if (span === 0) return DEFAULT_TOLERANCE_MS;
|
||||
const halfSpanMs = (span / 2) * ONE_MINUTE_MS;
|
||||
// Never go below the minute-drift floor: even a tight 30-minute window
|
||||
// must absorb the iOS-vs-server sub-minute `scheduledFor` drift.
|
||||
return Math.max(halfSpanMs, ONE_MINUTE_MS);
|
||||
}
|
||||
|
||||
function hhmmToMinutes(hhmm: string): number | null {
|
||||
const [hStr, mStr] = hhmm.split(":");
|
||||
const h = Number(hStr);
|
||||
const m = Number(mStr);
|
||||
if (!Number.isFinite(h) || !Number.isFinite(m)) return null;
|
||||
return h * 60 + m;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* v1.8.2 — shared slot resolution for the intake write paths.
|
||||
*
|
||||
* Both `POST /api/medications/[id]/intake` (source WEB) and
|
||||
* `POST /api/medications/intake/bulk` (source API) must resolve an
|
||||
* incoming dose write to the canonical scheduled-slot instant so the
|
||||
* write updates the pending REMINDER row the projector/worker minted
|
||||
* rather than inserting a second row that differs only by `source` (and
|
||||
* by a sub-minute `scheduledFor` drift). This module loads the
|
||||
* medication's schedules + the rolling-anchor `lastIntakeAt` and runs the
|
||||
* pure `resolveCanonicalSlotInstant` snap.
|
||||
*
|
||||
* Returns `null` when the dose does not map to a scheduled slot
|
||||
* (PRN / off-slot beyond tolerance / cyclic off-week / no schedules) —
|
||||
* the caller's signal to keep the unmodified insert behaviour.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
import { prisma as defaultPrisma } from "@/lib/db";
|
||||
import { resolveCanonicalSlotInstant } from "@/lib/medications/scheduling/resolve-slot-instant";
|
||||
import type { WorkerScheduleRow } from "@/lib/medications/scheduling/worker-helpers";
|
||||
|
||||
type PrismaLike = Pick<PrismaClient, "medication" | "medicationIntakeEvent">;
|
||||
|
||||
const SCHEDULE_SELECT = {
|
||||
id: true,
|
||||
windowStart: true,
|
||||
windowEnd: true,
|
||||
daysOfWeek: true,
|
||||
timesOfDay: true,
|
||||
reminderGraceMinutes: true,
|
||||
rrule: true,
|
||||
rollingIntervalDays: true,
|
||||
scheduleType: true,
|
||||
cyclicOnWeeks: true,
|
||||
cyclicOffWeeks: true,
|
||||
} as const;
|
||||
|
||||
const MEDICATION_SELECT = {
|
||||
id: true,
|
||||
startsOn: true,
|
||||
endsOn: true,
|
||||
oneShot: true,
|
||||
createdAt: true,
|
||||
schedules: { select: SCHEDULE_SELECT },
|
||||
} as const;
|
||||
|
||||
export interface ResolveSlotForWriteInput {
|
||||
userId: string;
|
||||
medicationId: string;
|
||||
userTz: string;
|
||||
/**
|
||||
* The write's `scheduledFor`, or `takenAt` as the fallback when the
|
||||
* client omitted `scheduledFor`.
|
||||
*/
|
||||
incoming: Date;
|
||||
/** Inject a Prisma client/tx in tests; defaults to the app client. */
|
||||
client?: PrismaLike;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the canonical scheduled-slot instant for an intake write, or
|
||||
* `null` when the dose is unscheduled (PRN / off-slot / no schedules).
|
||||
*
|
||||
* Loads the medication's schedule rows (and, when any schedule is
|
||||
* rolling, the latest non-tombstoned `takenAt` to anchor the next-due
|
||||
* computation byte-identically to the projector + worker).
|
||||
*/
|
||||
export async function resolveSlotInstantForWrite(
|
||||
input: ResolveSlotForWriteInput,
|
||||
): Promise<Date | null> {
|
||||
const client = input.client ?? defaultPrisma;
|
||||
|
||||
const medication = await client.medication.findFirst({
|
||||
where: { id: input.medicationId, userId: input.userId },
|
||||
select: MEDICATION_SELECT,
|
||||
});
|
||||
if (!medication || medication.schedules.length === 0) return null;
|
||||
|
||||
// Rolling cadence anchors off the last logged intake — fetch it only
|
||||
// when a schedule actually needs it (mirrors the projector's gate).
|
||||
let lastIntakeAt: Date | null = null;
|
||||
if (medication.schedules.some((s) => s.rollingIntervalDays !== null)) {
|
||||
const lastIntake = await client.medicationIntakeEvent.findFirst({
|
||||
where: {
|
||||
userId: input.userId,
|
||||
medicationId: input.medicationId,
|
||||
deletedAt: null,
|
||||
takenAt: { not: null },
|
||||
},
|
||||
orderBy: { takenAt: "desc" },
|
||||
select: { takenAt: true },
|
||||
});
|
||||
lastIntakeAt = lastIntake?.takenAt ?? null;
|
||||
}
|
||||
|
||||
return resolveCanonicalSlotInstant({
|
||||
medication: {
|
||||
id: medication.id,
|
||||
startsOn: medication.startsOn,
|
||||
endsOn: medication.endsOn,
|
||||
oneShot: medication.oneShot,
|
||||
createdAt: medication.createdAt,
|
||||
schedules: medication.schedules as WorkerScheduleRow[],
|
||||
},
|
||||
userTz: input.userTz,
|
||||
incoming: input.incoming,
|
||||
lastIntakeAt,
|
||||
});
|
||||
}
|
||||
@@ -127,3 +127,63 @@ export function scheduleEmitsInWindow(
|
||||
): boolean {
|
||||
return occurrencesBetween(schedule, windowStart, windowEnd, ctx).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal Prisma surface the missed-dose guard needs — just a `count`
|
||||
* over `MedicationIntakeEvent`. Keeps the helper unit-testable with a
|
||||
* tiny fake and the worker passing its real client.
|
||||
*/
|
||||
interface IntakeCountClient {
|
||||
medicationIntakeEvent: {
|
||||
count: (args: { where: Record<string, unknown> }) => Promise<number>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.8.2 — decide whether the reminder worker should mint a RED-phase
|
||||
* pending `REMINDER` row for the given slot.
|
||||
*
|
||||
* Returns `false` (skip the mint) when the slot already carries either:
|
||||
* - an existing pending `REMINDER` row (P2002-collision avoidance; the
|
||||
* `deletedAt: null` filter is intentionally omitted because a
|
||||
* tombstoned REMINDER row still occupies the `(userId, medicationId,
|
||||
* scheduledFor, source)` unique slot), OR
|
||||
* - an ACTIONED row — `takenAt` set OR `skipped` — from ANY source,
|
||||
* restricted to live rows (`deletedAt: null`). The intake write
|
||||
* paths snap a "Genommen" / "Übersprungen" write onto this exact
|
||||
* canonical slot instant via a source-agnostic update, so a dose the
|
||||
* user acted on before the RED phase opens already has a live
|
||||
* taken/skipped row here. Without this arm the worker would mint a
|
||||
* pending REMINDER row alongside the user's WEB/API taken row — the
|
||||
* duplicate-intake bug — because the two differ by `source` and the
|
||||
* unique index would not collide.
|
||||
*
|
||||
* `scheduledFor` must be the canonical `localHmAsUtc` slot instant the
|
||||
* projector + write paths use, so the existence probes match byte-for-byte.
|
||||
*/
|
||||
export async function shouldMintMissedDoseRow(
|
||||
client: IntakeCountClient,
|
||||
slot: { userId: string; medicationId: string; scheduledFor: Date },
|
||||
): Promise<boolean> {
|
||||
const existingPendingReminder = await client.medicationIntakeEvent.count({
|
||||
where: {
|
||||
medicationId: slot.medicationId,
|
||||
userId: slot.userId,
|
||||
scheduledFor: slot.scheduledFor,
|
||||
takenAt: null,
|
||||
source: "REMINDER",
|
||||
},
|
||||
});
|
||||
if (existingPendingReminder > 0) return false;
|
||||
|
||||
const existingActioned = await client.medicationIntakeEvent.count({
|
||||
where: {
|
||||
medicationId: slot.medicationId,
|
||||
userId: slot.userId,
|
||||
scheduledFor: slot.scheduledFor,
|
||||
deletedAt: null,
|
||||
OR: [{ takenAt: { not: null } }, { skipped: true }],
|
||||
},
|
||||
});
|
||||
return existingActioned === 0;
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
buildRecurrenceContext,
|
||||
scheduleEmitsInWindow,
|
||||
} from "@/lib/medications/scheduling/worker-helpers";
|
||||
import { localHmAsUtc } from "@/lib/timezone";
|
||||
|
||||
const TEST_USER_ID = "user-medication-cadence-worker";
|
||||
|
||||
@@ -712,3 +713,77 @@ describe("legacy daysOfWeek fallback", () => {
|
||||
expect(scheduleEmitsInWindow(schedule, ctx, on.start, on.end)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// 8. v1.8.2 — per-med intake POST collapses onto the canonical slot
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("v1.8.2 per-med intake POST — source-agnostic slot collapse", () => {
|
||||
it("updates a pre-existing pending REMINDER slot row instead of inserting a second WEB row", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const med = await prisma.medication.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
name: "Bisoprolol",
|
||||
dose: "2.5mg",
|
||||
active: true,
|
||||
schedules: {
|
||||
create: {
|
||||
windowStart: "07:00",
|
||||
windowEnd: "07:00",
|
||||
timesOfDay: ["07:00"],
|
||||
daysOfWeek: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const slot = localHmAsUtc(new Date(), "Europe/Berlin", 7, 0);
|
||||
const pending = await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
medicationId: med.id,
|
||||
scheduledFor: slot,
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
source: "REMINDER",
|
||||
},
|
||||
});
|
||||
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
|
||||
const { POST } = await import("@/app/api/medications/[id]/intake/route");
|
||||
// +1 minute drift between the iOS write and the server's localHmAsUtc.
|
||||
const drifted = new Date(slot.getTime() + 60_000);
|
||||
const res = await POST(
|
||||
new NextRequest(`http://localhost/api/medications/${med.id}/intake`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
scheduledFor: drifted.toISOString(),
|
||||
takenAt: drifted.toISOString(),
|
||||
skipped: false,
|
||||
}),
|
||||
}),
|
||||
{ params: Promise.resolve({ id: med.id }) },
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
const json = (await res.json()) as { data: { id: string } };
|
||||
expect(json.data.id).toBe(pending.id); // updated the SAME row
|
||||
|
||||
const rows = await prisma.medicationIntakeEvent.findMany({
|
||||
where: { userId: TEST_USER_ID, medicationId: med.id },
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.id).toBe(pending.id);
|
||||
expect(rows[0]?.takenAt).not.toBeNull();
|
||||
expect(rows[0]?.scheduledFor.toISOString()).toBe(slot.toISOString());
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { localHmAsUtc } from "@/lib/timezone";
|
||||
|
||||
const TEST_USER_ID = "user-medications-intake-bulk";
|
||||
const OTHER_USER_ID = "user-medications-intake-other";
|
||||
@@ -160,6 +161,203 @@ describe("POST /api/medications/intake/bulk (real Postgres)", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
// v1.8.2 — duplicate-intake slot collapse. A scheduled med carries a
|
||||
// pending REMINDER row at the canonical `localHmAsUtc` slot instant;
|
||||
// an iOS "Genommen" write (source API) must UPDATE that row, not insert
|
||||
// a second source-API row that differs only by source + sub-minute
|
||||
// drift.
|
||||
describe("v1.8.2 — source-agnostic slot collapse", () => {
|
||||
const TZ = "Europe/Berlin";
|
||||
|
||||
async function makeScheduledMed(timesOfDay: string[]): Promise<string> {
|
||||
const prisma = getPrismaClient();
|
||||
const med = await prisma.medication.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
name: "Ramipril",
|
||||
dose: "5mg",
|
||||
active: true,
|
||||
schedules: {
|
||||
create: {
|
||||
windowStart: timesOfDay[0],
|
||||
windowEnd: timesOfDay[0],
|
||||
timesOfDay,
|
||||
daysOfWeek: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return med.id;
|
||||
}
|
||||
|
||||
it("collapses an API taken-write onto a pre-existing pending REMINDER slot row (exact instant)", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const medId = await makeScheduledMed(["07:00"]);
|
||||
// The projector/worker minted this pending REMINDER row.
|
||||
const slot = localHmAsUtc(new Date(), TZ, 7, 0);
|
||||
const pending = await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
medicationId: medId,
|
||||
scheduledFor: slot,
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
source: "REMINDER",
|
||||
},
|
||||
});
|
||||
|
||||
const { POST } = await import("@/app/api/medications/intake/bulk/route");
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
{
|
||||
medicationId: medId,
|
||||
scheduledFor: slot.toISOString(),
|
||||
takenAt: slot.toISOString(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: { inserted: number; updated: number; entries: Array<{ status: string }> };
|
||||
};
|
||||
expect(json.data.inserted).toBe(0);
|
||||
expect(json.data.updated).toBe(1);
|
||||
expect(json.data.entries[0]?.status).toBe("updated");
|
||||
|
||||
const rows = await prisma.medicationIntakeEvent.findMany({
|
||||
where: { userId: TEST_USER_ID, medicationId: medId },
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.id).toBe(pending.id);
|
||||
expect(rows[0]?.takenAt).not.toBeNull();
|
||||
expect(rows[0]?.source).toBe("REMINDER"); // original source preserved
|
||||
});
|
||||
|
||||
it("collapses even when the write's scheduledFor drifts by 1 minute", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const medId = await makeScheduledMed(["07:00"]);
|
||||
const slot = localHmAsUtc(new Date(), TZ, 7, 0);
|
||||
await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
medicationId: medId,
|
||||
scheduledFor: slot,
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
source: "REMINDER",
|
||||
},
|
||||
});
|
||||
|
||||
const drifted = new Date(slot.getTime() + 60_000); // +1 min
|
||||
const { POST } = await import("@/app/api/medications/intake/bulk/route");
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
{
|
||||
medicationId: medId,
|
||||
scheduledFor: drifted.toISOString(),
|
||||
takenAt: drifted.toISOString(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const json = (await res.json()) as {
|
||||
data: { inserted: number; updated: number };
|
||||
};
|
||||
expect(json.data.updated).toBe(1);
|
||||
expect(json.data.inserted).toBe(0);
|
||||
|
||||
const rows = await prisma.medicationIntakeEvent.findMany({
|
||||
where: { userId: TEST_USER_ID, medicationId: medId },
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.scheduledFor.toISOString()).toBe(slot.toISOString());
|
||||
});
|
||||
|
||||
it("does NOT collapse PRN doses — two as-needed logs keep two rows", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const med = await prisma.medication.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
name: "Ibuprofen",
|
||||
dose: "400mg",
|
||||
active: true,
|
||||
schedules: {
|
||||
create: {
|
||||
windowStart: "00:00",
|
||||
windowEnd: "00:00",
|
||||
timesOfDay: [],
|
||||
daysOfWeek: null,
|
||||
scheduleType: "PRN",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { POST } = await import("@/app/api/medications/intake/bulk/route");
|
||||
const t1 = new Date("2026-06-15T09:00:00.000Z");
|
||||
const t2 = new Date("2026-06-15T15:00:00.000Z");
|
||||
await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
{ medicationId: med.id, scheduledFor: t1.toISOString(), takenAt: t1.toISOString() },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
{ medicationId: med.id, scheduledFor: t2.toISOString(), takenAt: t2.toISOString() },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const json = (await res.json()) as {
|
||||
data: { inserted: number; updated: number };
|
||||
};
|
||||
expect(json.data.inserted).toBe(1);
|
||||
expect(json.data.updated).toBe(0);
|
||||
|
||||
const rows = await prisma.medicationIntakeEvent.findMany({
|
||||
where: { userId: TEST_USER_ID, medicationId: med.id },
|
||||
});
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("creates exactly one row for a fresh scheduled dose with no pre-existing slot row", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const medId = await makeScheduledMed(["19:00"]);
|
||||
const slot = localHmAsUtc(new Date(), TZ, 19, 0);
|
||||
|
||||
const { POST } = await import("@/app/api/medications/intake/bulk/route");
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
{
|
||||
medicationId: medId,
|
||||
scheduledFor: slot.toISOString(),
|
||||
takenAt: slot.toISOString(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const json = (await res.json()) as {
|
||||
data: { inserted: number; updated: number };
|
||||
};
|
||||
expect(json.data.inserted).toBe(1);
|
||||
expect(json.data.updated).toBe(0);
|
||||
|
||||
const rows = await prisma.medicationIntakeEvent.findMany({
|
||||
where: { userId: TEST_USER_ID, medicationId: medId },
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
// snapped to the canonical slot instant
|
||||
expect(rows[0]?.scheduledFor.toISOString()).toBe(slot.toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
it("returns `duplicate` when an idempotencyKey is re-used", async () => {
|
||||
const { POST } = await import("@/app/api/medications/intake/bulk/route");
|
||||
const body = {
|
||||
|
||||
Reference in new issue
Block a user