fix(medications): make the nightly dedup and the pin path respect each other

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 16:54:15 +02:00
1 parent 24928dbb38
commit 4ca3de5ff3
13 files changed
+575 -27

No files matched your search

+14 -5
View File
@@ -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<void> {
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<void> {
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.
@@ -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",
@@ -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 (
@@ -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
@@ -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({
@@ -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<Response> {
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({
+4
View File
@@ -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",
+15 -4
View File
@@ -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)) {
@@ -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",
},
];
}
@@ -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"]);
});
});
+34 -3
View File
@@ -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<string, { instant: Date; rows: DedupIntakeRow[] }>();
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,
@@ -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>): 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);
});
});
@@ -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<boolean> {
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;