mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
24928dbb38
commit
4ca3de5ff3
13 files changed
+575
-27
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user