fix(medications): key the dedup pin guard on persisted attribution, not a timestamp shape

Every standalone intake anchors its slot on the take time since
v1.15.19, so guarding the nightly dedup on that shape excluded ordinary
drift duplicates from collapsing — the original point of the sweep.
Releasing a pinned dose now persists the user's choice on the row, the
dedup and the repair script key their exclusion on that flag, the read
model reports a released row as deliberately off-schedule instead of
re-snapping it within the anchor epsilon, and the attributed badge only
shows on rows actually bound to a slot. A few fixture names and stray
comments also drop personal references.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 17:33:56 +02:00
1 parent 04765ea8ac
commit a5650d0617
11 files changed
+267 -93

No files matched your search

+1 -1
View File
@@ -430,7 +430,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// v1.4.36 W3 T1 — `extractFeatures` enforces a 5 MB ceiling on the
// serialised payload. If the raw (bucketed) shape ever blows past
// it (regression watch — the v1.4.35 rawMeasurements shape hit
// 25.9 MB on Marc's account), the helper throws
// 25.9 MB on the largest tenant), the helper throws
// `FeaturesPayloadTooLargeError`. We downgrade to the aggregated
// shape rather than 500-ing so the user still gets an insight; the
// annotate event surfaces the regression to ops via the Logflare
@@ -358,6 +358,50 @@ describe("PUT — v1.15.18 band re-attribution", () => {
expect((created?.scheduledFor as Date).getTime()).toBe(at(19, 0).getTime());
});
it("unpin (forceSlotInstant: null) keeps USER_PIN provenance on the released ad-hoc row (v1.16.0)", async () => {
// The row is pinned onto the 07:00 anchor; its real takenAt (12:00) is
// outside every band. Releasing the binding re-attributes by band →
// miss → the row re-anchors on its own takenAt — and the release is
// itself a user-fixed decision, so the row must keep USER_PIN (the
// nightly dedup keys its standalone guarantee on that marker).
vi.mocked(prisma.medicationIntakeEvent.findFirst)
// PUT lookup of the edited event
.mockResolvedValueOnce({
id: "e1",
userId: "user-1",
medicationId: "m1",
scheduledFor: at(7, 0),
takenAt: at(12, 0),
skipped: false,
} as never)
// lifecycle liveIntake probe
.mockResolvedValue(null as never);
// applyCanonicalSlotWrite: no existing row at the 12:00 instant → create.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue(
{} as never,
);
vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({
id: "e2",
userId: "user-1",
medicationId: "m1",
scheduledFor: at(12, 0),
takenAt: at(12, 0),
skipped: false,
} as never);
const res = await PUT(putReq({ forceSlotInstant: null }), ROUTE_CTX);
expect(res.status).toBe(200);
const created = vi.mocked(prisma.medicationIntakeEvent.create).mock
.calls[0]?.[0]?.data;
// Released row: anchored on its own takenAt, provenance stays USER_PIN.
expect((created?.scheduledFor as Date).getTime()).toBe(at(12, 0).getTime());
expect(created?.attributionSource).toBe("USER_PIN");
});
it("422s an edited takenAt before the medication's start date (P0-4)", async () => {
// Use fixed wall-clock so the schema's "not in the future / within 5
// years" bounds hold regardless of when the suite runs.
@@ -131,7 +131,12 @@ export const PUT = apiHandler(
// anchor instead of degrading the off-window take back to ad-hoc;
// 3. else an explicit `forceSlotInstant: null` UNPINS (v1.15.20,
// "Zuordnung lösen"): re-attribute the take by band on its takenAt
// (ad-hoc when no band matches) and reset the provenance to AUTO;
// (ad-hoc when no band matches). v1.16.0 — the release is itself a
// deliberate binding decision, so the row KEEPS `USER_PIN`
// provenance ("attribution user-fixed": onto a slot OR deliberately
// ad-hoc). The persisted marker is what keeps the nightly slot
// dedup from snapping the released row back into the cluster the
// user just detached it from;
// 4. else a taken edit attributes by band (slot anchor on match, the
// take's own time on a miss → ad-hoc), provenance AUTO;
// 5. a skip / pending edit keeps the existing `scheduledFor` anchor.
@@ -198,10 +203,15 @@ export const PUT = apiHandler(
});
// band.at on match; the take's own time on a miss (ad-hoc).
resolvedScheduledFor = attribution.slotInstant ?? nextTakenAt;
attributionSource = "AUTO";
// v1.16.0 — an explicit unpin stays user-fixed (USER_PIN) even on
// the released row; a plain taken/skipped edit re-attributes AUTO.
attributionSource =
data.forceSlotInstant === null ? "USER_PIN" : "AUTO";
} else if (data.forceSlotInstant === null) {
// Unpin on a row that is not a confirmed take (skip / pending):
// nothing to re-attribute, but the provenance still resets.
// nothing to re-attribute, but the provenance still resets — a
// skip / pending row anchors on its slot by construction, so there
// is no user-fixed ad-hoc decision to persist.
attributionSource = "AUTO";
}
// skip / pending edits leave `scheduledFor` on the existing anchor.
@@ -57,7 +57,7 @@ describe("GET /api/user/profile", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
username: "marc",
displayName: "Marc B.",
displayName: "Alex T.",
email: "marc@example.com",
dateOfBirth: new Date("1985-03-12T00:00:00.000Z"),
gender: "MALE",
@@ -77,7 +77,7 @@ describe("GET /api/user/profile", () => {
};
};
expect(body.data.username).toBe("marc");
expect(body.data.displayName).toBe("Marc B.");
expect(body.data.displayName).toBe("Alex T.");
expect(body.data.heightCm).toBe(180);
expect(body.data.locale).toBe("de");
});
@@ -137,7 +137,7 @@ describe("PATCH /api/user/profile", () => {
vi.mocked(prisma.user.update).mockResolvedValue({
id: "user-1",
username: "marc",
displayName: "Marc B.",
displayName: "Alex T.",
email: null,
role: "USER",
heightCm: null,
@@ -147,13 +147,13 @@ describe("PATCH /api/user/profile", () => {
locale: "de",
} as never);
const res = await PATCH(req({ displayName: "Marc B.", locale: "de" }));
const res = await PATCH(req({ displayName: "Alex T.", locale: "de" }));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "user-1" },
data: expect.objectContaining({
displayName: "Marc B.",
displayName: "Alex T.",
locale: "de",
}),
}),