From 4ca3de5ff39830d795a11c9103ee10809aed159b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 10 Jun 2026 16:54:15 +0200 Subject: [PATCH] fix(medications): make the nightly dedup and the pin path respect each other MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deliberately detached ad-hoc intake could be folded back into its slot cluster by the nightly dedup, and a pinned row could lose its slot to a sibling on the sync-version tiebreak — pins now outrank every other row and detached rows never join a cluster, mirrored into the operator repair script. Pinning onto an already-served slot no longer overwrites the existing record silently; the API refuses with a clear error while an identical re-post stays idempotent. The idempotency cache also refuses to store invite-token-shaped responses. --- scripts/repair-intake-anomalies.ts | 19 +- .../intake/[eventId]/__tests__/route.test.ts | 42 +++++ .../[id]/intake/[eventId]/route.ts | 25 +++ src/app/api/medications/[id]/intake/route.ts | 23 +++ .../intake/bulk/__tests__/route.test.ts | 69 ++++++-- src/app/api/medications/intake/bulk/route.ts | 18 ++ src/lib/__tests__/idempotency.test.ts | 4 + src/lib/idempotency.ts | 19 +- .../intake-slot-dedup-isolation.test.ts | 13 +- .../__tests__/intake-slot-dedup-pin.test.ts | 162 ++++++++++++++++++ src/lib/medications/intake-slot-dedup.ts | 37 +++- .../scheduling/__tests__/pin-conflict.test.ts | 124 ++++++++++++++ src/lib/medications/scheduling/slot-upsert.ts | 47 +++++ 13 files changed, 575 insertions(+), 27 deletions(-) create mode 100644 src/lib/medications/__tests__/intake-slot-dedup-pin.test.ts create mode 100644 src/lib/medications/scheduling/__tests__/pin-conflict.test.ts diff --git a/scripts/repair-intake-anomalies.ts b/scripts/repair-intake-anomalies.ts index b84f89852..2e564b00d 100644 --- a/scripts/repair-intake-anomalies.ts +++ b/scripts/repair-intake-anomalies.ts @@ -113,15 +113,21 @@ interface IntakeRow { skipped: boolean; source: string; createdAt: Date; + /** v1.16.0 — slot-binding provenance; USER_PIN = deliberate user pin. */ + attributionSource: string; } /** - * Rank a row for the slot-winner pick: taken (2) > skipped (1) > - * pending (0). `takenAt` set and `skipped` together still counts as - * taken — the recorded dose dominates. + * Rank a row for the slot-winner pick: pinned take (3) > taken (2) > + * skipped (1) > pending (0). `takenAt` set and `skipped` together still + * counts as taken — the recorded dose dominates. The USER_PIN rung + * (v1.16.0) mirrors the dedup worker: a deliberate "diesem Slot + * zuordnen" decision is the dose of record for its slot. */ function rowRank(row: IntakeRow): number { - if (row.takenAt !== null) return 2; + if (row.takenAt !== null) { + return row.attributionSource === "USER_PIN" ? 3 : 2; + } if (row.skipped) return 1; return 0; } @@ -431,9 +437,11 @@ async function main(): Promise { skipped: boolean; source: string; created_at: Date; + attribution_source: string; }>( ` - SELECT "id", "taken_at", "skipped", "source"::text AS source, "created_at" + SELECT "id", "taken_at", "skipped", "source"::text AS source, "created_at", + "attribution_source"::text AS attribution_source FROM "medication_intake_events" WHERE "user_id" = $1 AND "medication_id" = $2 @@ -449,6 +457,7 @@ async function main(): Promise { skipped: r.skipped, source: r.source, createdAt: r.created_at, + attributionSource: r.attribution_source, })); if (rows.length < 2) continue; // collapsed since the scan — nothing to do. diff --git a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts index cde47a0cd..56381227c 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts @@ -458,6 +458,48 @@ describe("PUT — v1.15.18 band re-attribution", () => { } }); + it("422s a pin onto a slot already served by another recorded action (v1.16.0)", async () => { + // The edited event is an ad-hoc take; the pin targets the real 07:00 + // slot — but that slot already carries a DIFFERENT recorded take. + // Converging would overwrite it (last-write-wins), so the route + // refuses with `medications.intake.force_slot.occupied` before any + // write happens. + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({ + id: "e1", + userId: "user-1", + medicationId: "m1", + scheduledFor: at(12, 0), + takenAt: at(12, 0), + skipped: false, + } as never); + // findPinConflict's slot-row read: the 07:00 anchor is served. + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + { + id: "other-take", + takenAt: at(7, 5), + skipped: false, + idempotencyKey: null, + scheduledFor: at(7, 0), + source: "WEB", + createdAt: at(0, 1), + }, + ] as never); + + const res = await PUT( + putReq({ forceSlotInstant: at(7, 0).toISOString() }), + ROUTE_CTX, + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + meta?: { errorCode?: string }; + }; + expect(body.meta?.errorCode).toBe( + "medications.intake.force_slot.occupied", + ); + expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + }); + it("422s a forceSlotInstant that is not a real slot", async () => { vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({ id: "e1", diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index ee537ebbb..6c94b66b4 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -17,6 +17,7 @@ import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication- import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz"; import { applyCanonicalSlotWrite, + findPinConflict, resolveForcedSlotForWrite, resolveSlotForWriteByBand, } from "@/lib/medications/scheduling/slot-upsert"; @@ -158,6 +159,30 @@ export const PUT = apiHandler( { errorCode: "medications.intake.force_slot.invalid" }, ); } + // v1.16.0 — refuse to pin onto a slot another recorded action + // already serves (excluding the row being edited): the explicit- + // write last-write-wins rule would silently overwrite that dose + // record. The ledger UI only offers the pin for unserved slots, + // so this only fires for stale clients / raw API calls. + if ( + await findPinConflict({ + userId: user.id, + medicationId: id, + canonicalSlot: forced, + incomingTakenAt: nextSkipped ? null : nextTakenAt, + excludeEventId: eventId, + }) + ) { + annotate({ + action: { name: "medication.intake.force_slot.occupied" }, + meta: { medication_id: id, event_id: eventId }, + }); + return apiError( + "forceSlotInstant already carries a recorded dose action", + 422, + { errorCode: "medications.intake.force_slot.occupied" }, + ); + } resolvedScheduledFor = forced; attributionSource = "USER_PIN"; } else if ( diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index a522b20bd..eaa82370f 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -24,6 +24,7 @@ import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; import { applyCanonicalSlotWrite, + findPinConflict, resolveForcedSlotForWrite, resolveSlotForWriteByBand, resolveSlotInstantForWrite, @@ -198,6 +199,28 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { { errorCode: "medications.intake.force_slot.invalid" }, ); } + // v1.16.0 — refuse to pin onto a slot another recorded action already + // serves: the explicit-write last-write-wins rule would silently + // overwrite that dose record. The ledger UI only offers the pin for + // unserved slots, so this only fires for stale clients / raw API calls. + if ( + await findPinConflict({ + userId: user.id, + medicationId: id, + canonicalSlot, + incomingTakenAt: resolvedTakenAt ?? null, + }) + ) { + annotate({ + action: { name: "medication.intake.force_slot.occupied" }, + meta: { medication_id: id }, + }); + return apiError( + "forceSlotInstant already carries a recorded dose action", + 422, + { errorCode: "medications.intake.force_slot.occupied" }, + ); + } attributionSource = "USER_PIN"; } else { // A non-skip write on this route always carries a `takenAt` (defaulted to diff --git a/src/app/api/medications/intake/bulk/__tests__/route.test.ts b/src/app/api/medications/intake/bulk/__tests__/route.test.ts index fb399277e..d33694a57 100644 --- a/src/app/api/medications/intake/bulk/__tests__/route.test.ts +++ b/src/app/api/medications/intake/bulk/__tests__/route.test.ts @@ -653,18 +653,22 @@ describe("POST /api/medications/intake/bulk — v1.15.20 band attribution", () = it("forceSlotInstant pins an off-window take onto the named real slot", async () => { // 14:00 Berlin falls in no band; the client pins the take onto the - // morning slot anchor (05:00Z = 07:00 Berlin). - vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([ - { - id: "row-morning", - 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); + // morning slot anchor (05:00Z = 07:00 Berlin). The slot rows are read + // twice on the pin path: once by the v1.16.0 occupied-slot guard + // (`findPinConflict` — a pending row is not a conflict), once by the + // canonical upsert itself. + const morningPendingRow = { + id: "row-morning", + takenAt: null, + skipped: false, + idempotencyKey: null, + scheduledFor: new Date("2026-06-15T05:00:00Z"), + source: "REMINDER", + createdAt: new Date("2026-06-15T00:00:00Z"), + }; + vi.mocked(prisma.medicationIntakeEvent.findMany) + .mockResolvedValueOnce([morningPendingRow] as never) + .mockResolvedValueOnce([morningPendingRow] as never); vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({ id: "row-morning", } as never); @@ -689,6 +693,47 @@ describe("POST /api/medications/intake/bulk — v1.15.20 band attribution", () = expect(body.data.entries[0].id).toBe("row-morning"); }); + it("marks an entry skipped when the pinned slot already carries a recorded action", async () => { + // v1.16.0 — the morning slot is already served by a different take; + // pinning another take onto it would overwrite that dose record via + // last-write-wins, so the entry is refused per-entry + // (`force_slot_occupied`) instead of converging. + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([ + { + id: "row-morning", + takenAt: new Date("2026-06-15T05:10:00Z"), + skipped: false, + idempotencyKey: null, + scheduledFor: new Date("2026-06-15T05:00:00Z"), + source: "WEB", + createdAt: new Date("2026-06-15T00:00:00Z"), + }, + ] as never); + + const res = await POST( + postReq({ + entries: [ + { + medicationId: "med-1", + takenAt: "2026-06-15T12:00:00.000Z", + forceSlotInstant: "2026-06-15T05:00:00.000Z", + }, + ], + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + skipped: Array<{ index: number; reason: string }>; + entries: Array<{ status: string; reason?: string }>; + }; + }; + expect(body.data.entries[0].status).toBe("skipped"); + expect(body.data.entries[0].reason).toBe("force_slot_occupied"); + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled(); + }); + it("marks an entry skipped when forceSlotInstant names no real slot", async () => { const res = await POST( postReq({ diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index e95a7a504..13c5eb342 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -52,6 +52,7 @@ import { } from "@/lib/rollups/medication-compliance-rollups"; import { applyCanonicalSlotWrite, + findPinConflict, resolveForcedSlotForWrite, resolveSlotForWriteByBand, resolveSlotInstantForWrite, @@ -280,6 +281,23 @@ async function postBulk(request: NextRequest): Promise { results.push({ index: i, status: "skipped", reason }); continue; } + // v1.16.0 — refuse to pin onto a slot another recorded action + // already serves: the explicit-write last-write-wins rule would + // silently overwrite that dose record. Per-entry skip, mirroring + // the single route's 422 `medications.intake.force_slot.occupied`. + if ( + await findPinConflict({ + userId: user.id, + medicationId: entry.medicationId, + canonicalSlot, + incomingTakenAt: entry.takenAt ?? null, + }) + ) { + const reason = "force_slot_occupied"; + skipped.push({ index: i, reason }); + results.push({ index: i, status: "skipped", reason }); + continue; + } attributionSource = "USER_PIN"; } else if (isExplicitTaken) { const attribution = await resolveSlotForWriteByBand({ diff --git a/src/lib/__tests__/idempotency.test.ts b/src/lib/__tests__/idempotency.test.ts index 6883dc2c8..a34526749 100644 --- a/src/lib/__tests__/idempotency.test.ts +++ b/src/lib/__tests__/idempotency.test.ts @@ -397,6 +397,10 @@ describe("withIdempotency body-content exclusion (P12)", () => { ["hlk_ access token", '{"data":{"token":"hlk_abc123"},"error":null}'], ["hlr_ refresh token", '{"data":{"refresh":"hlr_xyz789"},"error":null}'], ["hls_ share-link token", '{"data":{"link":"hls_def456"},"error":null}'], + [ + "hlv_ registration invite token", + '{"data":{"token":"hlv_0a1b2c3d"},"error":null}', + ], ["sk- OpenAI key", '{"data":{"echoed":"sk-1234567890"},"error":null}'], [ "sk-ant- Anthropic key", diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts index 27af41c1e..bc6da8b94 100644 --- a/src/lib/idempotency.ts +++ b/src/lib/idempotency.ts @@ -59,7 +59,10 @@ function getIdempotencyKey(request: Request | NextRequest): string | null { * handler again. * - `null` — no live row; the caller may claim + run. */ -type CacheLookup = { kind: "replay"; response: NextResponse } | { kind: "pending" } | null; +type CacheLookup = + | { kind: "replay"; response: NextResponse } + | { kind: "pending" } + | null; /** * Look up the state of a (userId, key, method, path) tuple. Distinguishes @@ -315,7 +318,10 @@ export function withIdempotency< return NextResponse.json( { data: null, - error: { message: "A request with this Idempotency-Key is already in progress" }, + error: { + message: + "A request with this Idempotency-Key is already in progress", + }, }, { status: 409, headers: { "X-Idempotent-Replay": "false" } }, ); @@ -333,7 +339,10 @@ export function withIdempotency< return NextResponse.json( { data: null, - error: { message: "A request with this Idempotency-Key is already in progress" }, + error: { + message: + "A request with this Idempotency-Key is already in progress", + }, }, { status: 409, headers: { "X-Idempotent-Replay": "false" } }, ); @@ -357,12 +366,14 @@ export function withIdempotency< // `hlk_` = our access tokens // `hlr_` = our refresh tokens // `hls_` = clinician share-link tokens (v1.11) + // `hlv_` = registration invite tokens (v1.16 — the admin mint + // response is the one place the raw token appears) // `sk-…` = OpenAI / Anthropic keys (full token form, not the // raw substring — a 422 body explaining "task-id…" // or any other word containing `sk-` would otherwise // silently break idempotency for benign retries). const SECRET_PATTERN = - /(?:\b(?:hlk_|hlr_|hls_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/; + /(?:\b(?:hlk_|hlr_|hls_|hlv_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/; const cloned = response.clone(); const text = await cloned.text(); if (!SECRET_PATTERN.test(text)) { diff --git a/src/lib/medications/__tests__/intake-slot-dedup-isolation.test.ts b/src/lib/medications/__tests__/intake-slot-dedup-isolation.test.ts index 5904049ef..6d784a435 100644 --- a/src/lib/medications/__tests__/intake-slot-dedup-isolation.test.ts +++ b/src/lib/medications/__tests__/intake-slot-dedup-isolation.test.ts @@ -64,7 +64,10 @@ const MED = { // Two duplicate rows per slot (07:00 and 19:00) on 2026-06-15 CEST. // 07:00 CEST = 05:00Z, 19:00 CEST = 17:00Z. Each slot has a pending // REMINDER row + a taken WEB row a minute apart → both collapse, both -// need a winner normalise. +// need a winner normalise. The taken rows carry the legacy-drift shape +// (`scheduledFor` ≠ `takenAt`): an exactly-equal pair is the band-era +// deliberate-ad-hoc signature, which the dedup deliberately never +// collapses (v1.16.0 pin-awareness — see the dedicated pin test file). function seedRows() { return [ { @@ -75,15 +78,17 @@ function seedRows() { skipped: false, syncVersion: 0, createdAt: new Date("2026-06-15T00:00:00Z"), + attributionSource: "AUTO", }, { id: "r-07-web", medicationId: "med-1", scheduledFor: new Date("2026-06-15T05:01:00.000Z"), - takenAt: new Date("2026-06-15T05:01:00.000Z"), + takenAt: new Date("2026-06-15T05:03:00.000Z"), skipped: false, syncVersion: 1, createdAt: new Date("2026-06-15T05:01:00Z"), + attributionSource: "AUTO", }, { id: "r-19-rem", @@ -93,15 +98,17 @@ function seedRows() { skipped: false, syncVersion: 0, createdAt: new Date("2026-06-15T00:00:00Z"), + attributionSource: "AUTO", }, { id: "r-19-web", medicationId: "med-1", scheduledFor: new Date("2026-06-15T17:01:00.000Z"), - takenAt: new Date("2026-06-15T17:01:00.000Z"), + takenAt: new Date("2026-06-15T17:03:00.000Z"), skipped: false, syncVersion: 1, createdAt: new Date("2026-06-15T17:01:00Z"), + attributionSource: "AUTO", }, ]; } diff --git a/src/lib/medications/__tests__/intake-slot-dedup-pin.test.ts b/src/lib/medications/__tests__/intake-slot-dedup-pin.test.ts new file mode 100644 index 000000000..24f438013 --- /dev/null +++ b/src/lib/medications/__tests__/intake-slot-dedup-pin.test.ts @@ -0,0 +1,162 @@ +/** + * v1.16.0 — pin-awareness of the intake-slot dedup pass. + * + * Two regressions the v1.15.20 pin/unpin flow could otherwise suffer from + * the nightly dedup cron: + * + * 1. A deliberately UNPINNED ("Zuordnung lösen") or outside-every-band + * take is stored as a standalone ad-hoc row with + * `scheduledFor === takenAt`. The dedup's canonical-instant resolver + * still uses the legacy ± half-window snap, which is WIDER than the + * band model — without the guard the cron would re-merge the row into + * the nearest slot's cluster, silently reverting the user's binding + * decision (and, when the slot row is also a take, soft-deleting one + * of two real dose records). + * + * 2. A USER_PIN row shares its slot anchor with a sibling row (cross- + * source race). The winner pick must prefer the pinned take — the pin + * IS the dose of record for its slot — instead of letting the + * `syncVersion` tie-break soft-delete the user's deliberate decision. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + medication: { findMany: vi.fn() }, + medicationIntakeEvent: { + findFirst: vi.fn(), + findMany: vi.fn(), + updateMany: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() })); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ + recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +})); + +import { dedupeUserIntakeSlots } from "../intake-slot-dedup"; +import { prisma } from "@/lib/db"; + +const TZ = "Europe/Berlin"; + +// A daily 07:00 / 19:00 SCHEDULED med — same shape as the isolation test. +const SCHEDULE = { + 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, +}; + +const MED = { + id: "med-1", + userId: "u1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00Z"), + schedules: [SCHEDULE], +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + timezone: TZ, + } as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([MED] as never); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue( + null as never, + ); + vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({ + count: 1, + } as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue( + {} as never, + ); +}); + +describe("dedupeUserIntakeSlots — pin-awareness (v1.16.0)", () => { + it("never re-merges a deliberate ad-hoc take (scheduledFor === takenAt) into a slot cluster", async () => { + // 07:00 CEST = 05:00Z. The pending REMINDER row sits on the anchor; + // the unpinned take sits 40 min later — inside the legacy snap + // tolerance, outside the user's deliberate ad-hoc decision. + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + { + id: "r-07-rem", + medicationId: "med-1", + scheduledFor: new Date("2026-06-15T05:00:00.000Z"), + takenAt: null, + skipped: false, + syncVersion: 0, + createdAt: new Date("2026-06-15T00:00:00Z"), + attributionSource: "AUTO", + }, + { + id: "r-07-adhoc", + medicationId: "med-1", + scheduledFor: new Date("2026-06-15T05:40:00.000Z"), + takenAt: new Date("2026-06-15T05:40:00.000Z"), + skipped: false, + syncVersion: 3, + createdAt: new Date("2026-06-15T05:40:00Z"), + attributionSource: "AUTO", + }, + ] as never); + + const summary = await dedupeUserIntakeSlots("u1"); + + // The ad-hoc row never joins the 05:00 cluster → the slot holds one + // row → nothing collapses, nothing is soft-deleted or normalised. + expect(summary.slotsCollapsed).toBe(0); + expect(summary.rowsSoftDeleted).toBe(0); + expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled(); + expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled(); + }); + + it("keeps the USER_PIN row as the slot winner over a higher-syncVersion sibling take", async () => { + // Both rows sit on the 05:00Z anchor (cross-source race shape). The + // sibling AUTO take has the higher syncVersion — without the pin rung + // it would win the tie-break and the user's pin would be tombstoned. + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + { + id: "r-07-pin", + medicationId: "med-1", + scheduledFor: new Date("2026-06-15T05:00:00.000Z"), + takenAt: new Date("2026-06-15T09:30:00.000Z"), + skipped: false, + syncVersion: 1, + createdAt: new Date("2026-06-15T05:00:00Z"), + attributionSource: "USER_PIN", + }, + { + id: "r-07-auto", + medicationId: "med-1", + scheduledFor: new Date("2026-06-15T05:00:00.000Z"), + takenAt: new Date("2026-06-15T05:05:00.000Z"), + skipped: false, + syncVersion: 7, + createdAt: new Date("2026-06-15T05:01:00Z"), + attributionSource: "AUTO", + }, + ] as never); + + const summary = await dedupeUserIntakeSlots("u1"); + + expect(summary.slotsCollapsed).toBe(1); + // The AUTO sibling is the loser; the pinned row survives. + const updateManyArg = vi.mocked(prisma.medicationIntakeEvent.updateMany) + .mock.calls[0][0] as { where: { id: { in: string[] } } }; + expect(updateManyArg.where.id.in).toEqual(["r-07-auto"]); + }); +}); diff --git a/src/lib/medications/intake-slot-dedup.ts b/src/lib/medications/intake-slot-dedup.ts index b8a01f6d0..fe4afe0c4 100644 --- a/src/lib/medications/intake-slot-dedup.ts +++ b/src/lib/medications/intake-slot-dedup.ts @@ -98,14 +98,39 @@ interface DedupIntakeRow { skipped: boolean; syncVersion: number; createdAt: Date; + /** v1.16.0 — slot-binding provenance; USER_PIN = deliberate user pin. */ + attributionSource: string; } /** - * Winner priority of a row inside a slot: taken (2) > skipped (1) > - * pending (0). A higher number wins. + * v1.16.0 — true for a row the band-era write path deliberately recorded + * as a standalone ad-hoc take (`scheduledFor === takenAt`, the unpin / + * outside-every-band signature). The dedup snap must never second-guess + * that decision: the canonical-instant resolver still uses the legacy + * ± half-window tolerance, which is WIDER than the band model, so an + * unpinned ("Zuordnung gelöst") or early off-band take within snap reach + * of a slot would otherwise be re-merged into that slot's cluster — + * silently reverting the user's binding decision (and, when the slot row + * is also a take, soft-deleting one of two real dose records). + */ +function isDeliberateAdHocRow(row: DedupIntakeRow): boolean { + return ( + row.takenAt !== null && + row.scheduledFor.getTime() === row.takenAt.getTime() + ); +} + +/** + * Winner priority of a row inside a slot: pinned take (3) > taken (2) > + * skipped (1) > pending (0). A higher number wins. The USER_PIN rung + * (v1.16.0) keeps a deliberate "diesem Slot zuordnen" decision from + * losing the tie-break against a same-slot sibling row — the pin is the + * dose of record for its slot. */ function rowPriority(row: DedupIntakeRow): number { - if (row.takenAt !== null) return 2; + if (row.takenAt !== null) { + return row.attributionSource === "USER_PIN" ? 3 : 2; + } if (row.skipped) return 1; return 0; } @@ -219,6 +244,8 @@ export async function dedupeUserIntakeSlots( skipped: true, syncVersion: true, createdAt: true, + // v1.16.0 — pin-awareness: a USER_PIN row must win its slot. + attributionSource: true, }, orderBy: { scheduledFor: "asc" }, })) as DedupIntakeRow[]; @@ -230,6 +257,10 @@ export async function dedupeUserIntakeSlots( // never collapse together. const slots = new Map(); for (const row of rows) { + // v1.16.0 — a deliberate band-era ad-hoc take never joins a slot + // cluster (see `isDeliberateAdHocRow`). Declining to collapse is + // always safe: the row simply keeps its own anchor. + if (isDeliberateAdHocRow(row)) continue; const canonical = resolveCanonicalSlotInstant({ medication: { id: med.id, diff --git a/src/lib/medications/scheduling/__tests__/pin-conflict.test.ts b/src/lib/medications/scheduling/__tests__/pin-conflict.test.ts new file mode 100644 index 000000000..2166d0b2c --- /dev/null +++ b/src/lib/medications/scheduling/__tests__/pin-conflict.test.ts @@ -0,0 +1,124 @@ +/** + * v1.16.0 — `findPinConflict`, the guard behind the 422 + * `medications.intake.force_slot.occupied` refusal on the USER_PIN write + * paths. A pin moves a DIFFERENT take onto a named slot; converging it + * onto a slot whose live row is already actioned would overwrite that + * recorded action through the explicit-write last-write-wins rule — a + * silent loss of a dose record. The ledger UI only offers the pin for + * unserved slots, so the guard is the API-level backstop. + */ +import { describe, expect, it, vi } from "vitest"; + +import { findPinConflict } from "../slot-upsert"; + +const SLOT = new Date("2026-06-15T05:00:00.000Z"); + +interface StubRow { + id: string; + takenAt: Date | null; + skipped: boolean; + idempotencyKey: string | null; + scheduledFor: Date; + source: string; + createdAt: Date; +} + +function clientWithRows(rows: StubRow[]) { + return { + medication: {} as never, + medicationIntakeEvent: { + findMany: vi.fn().mockResolvedValue(rows), + } as never, + }; +} + +function row(overrides: Partial): StubRow { + return { + id: "r-1", + takenAt: null, + skipped: false, + idempotencyKey: null, + scheduledFor: SLOT, + source: "WEB", + createdAt: new Date("2026-06-15T00:00:00Z"), + ...overrides, + }; +} + +describe("findPinConflict", () => { + it("does not conflict with an empty slot or a pending projection row", async () => { + expect( + await findPinConflict({ + userId: "u1", + medicationId: "m1", + canonicalSlot: SLOT, + incomingTakenAt: new Date("2026-06-15T09:30:00Z"), + client: clientWithRows([]), + }), + ).toBe(false); + + expect( + await findPinConflict({ + userId: "u1", + medicationId: "m1", + canonicalSlot: SLOT, + incomingTakenAt: new Date("2026-06-15T09:30:00Z"), + client: clientWithRows([row({ takenAt: null, skipped: false })]), + }), + ).toBe(false); + }); + + it("conflicts with a live taken row carrying a different takenAt", async () => { + expect( + await findPinConflict({ + userId: "u1", + medicationId: "m1", + canonicalSlot: SLOT, + incomingTakenAt: new Date("2026-06-15T09:30:00Z"), + client: clientWithRows([ + row({ takenAt: new Date("2026-06-15T05:05:00Z") }), + ]), + }), + ).toBe(true); + }); + + it("conflicts with an explicitly skipped slot row", async () => { + expect( + await findPinConflict({ + userId: "u1", + medicationId: "m1", + canonicalSlot: SLOT, + incomingTakenAt: new Date("2026-06-15T09:30:00Z"), + client: clientWithRows([row({ skipped: true })]), + }), + ).toBe(true); + }); + + it("treats an identical takenAt as an idempotent re-post, not a conflict", async () => { + const takenAt = new Date("2026-06-15T09:30:00Z"); + expect( + await findPinConflict({ + userId: "u1", + medicationId: "m1", + canonicalSlot: SLOT, + incomingTakenAt: takenAt, + client: clientWithRows([row({ takenAt: new Date(takenAt) })]), + }), + ).toBe(false); + }); + + it("never conflicts with the row being edited itself (excludeEventId)", async () => { + expect( + await findPinConflict({ + userId: "u1", + medicationId: "m1", + canonicalSlot: SLOT, + incomingTakenAt: new Date("2026-06-15T09:30:00Z"), + excludeEventId: "r-1", + client: clientWithRows([ + row({ id: "r-1", takenAt: new Date("2026-06-15T05:05:00Z") }), + ]), + }), + ).toBe(false); + }); +}); diff --git a/src/lib/medications/scheduling/slot-upsert.ts b/src/lib/medications/scheduling/slot-upsert.ts index fa31ea53b..c90f6f90e 100644 --- a/src/lib/medications/scheduling/slot-upsert.ts +++ b/src/lib/medications/scheduling/slot-upsert.ts @@ -526,6 +526,53 @@ export async function resolveSlotForWriteByBand( }); } +/** + * v1.16.0 — pin-conflict probe for the USER_PIN write paths. A pin moves a + * DIFFERENT take onto a named slot; converging it onto a slot whose live + * row is already actioned (taken or explicitly skipped) would overwrite + * that recorded action through the explicit-write last-write-wins rule — + * a silent loss of a dose record. The read ledger only offers the pin for + * UNSERVED slots (`nearestSlot.filled` gates the kebab action), so a + * conflict here means a stale client or a hand-rolled API call: the route + * refuses it with 422 `medications.intake.force_slot.occupied`. + * + * Not a conflict: + * - a pending projection row at the slot (that is the normal target the + * pin converges onto); + * - the row being edited itself (`excludeEventId` on the PUT path); + * - an actioned row whose `takenAt` equals the incoming instant — that + * is an idempotent re-post of the same pinned dose. + */ +export async function findPinConflict(input: { + userId: string; + medicationId: string; + /** The validated canonical slot anchor the pin targets. */ + canonicalSlot: Date; + /** The incoming row's takenAt (null for a skip-shaped edit). */ + incomingTakenAt: Date | null; + /** The event being edited (PUT path) — never conflicts with itself. */ + excludeEventId?: string; + client?: PrismaLike; +}): Promise { + const client = input.client ?? defaultPrisma; + const rows = await findSlotRows( + client, + input.userId, + input.medicationId, + input.canonicalSlot, + ); + return rows.some((row) => { + if (input.excludeEventId && row.id === input.excludeEventId) return false; + const actioned = row.takenAt !== null || row.skipped; + if (!actioned) return false; + const sameTake = + input.incomingTakenAt !== null && + row.takenAt !== null && + row.takenAt.getTime() === input.incomingTakenAt.getTime(); + return !sameTake; + }); +} + export interface ResolveForcedSlotInput { userId: string; medicationId: string;