mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
fix(medications): align the slot lifecycle with the band model end to end
The auto-miss cron stamped every pending row missed a flat day after its slot even though weekly schedules stay takeable for days, touched tombstones because it never filtered deleted rows, and a verdict stuck even after the dose was taken. The cron now derives its cutoff from the schedule's window reach, skips deleted rows and bumps the sync counter, and a take clears the auto-miss flag. Editing a schedule tombstones the open pending rows of the old anchors and reconciles a stale window onto the new times. The bulk write path attributes takes through the same band engine as the single-intake routes and accepts forceSlotInstant per entry. Configured dose windows now reach every compliance call site through one shared schedule select, and the card window pill takes a timezone, treats a degenerate window as a point and counts dose times instead of schedule rows.
This commit is contained in:
1 parent
fb6e494f64
commit
0a4bf34a2b
16 files changed
+1380
-214
No files matched your search
@@ -18,6 +18,7 @@ import {
|
||||
buildComplianceMedicationContext,
|
||||
calculateCompliance,
|
||||
lastNonSkippedTakenAt,
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import { getMedicationCategories } from "@/lib/medication-category";
|
||||
import { apiHandler, requireAuth, type AuthContext } from "@/lib/api-handler";
|
||||
@@ -258,7 +259,9 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
// Bounded reads — left untouched per the directive.
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
include: { schedules: true },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT } },
|
||||
});
|
||||
const categoryMap = await getMedicationCategories(
|
||||
medications.map((m) => m.id),
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
buildComplianceMedicationContext,
|
||||
calculateCompliance,
|
||||
lastNonSkippedTakenAt,
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import { pairByTimestamp } from "@/lib/analytics/correlations";
|
||||
import {
|
||||
@@ -1004,7 +1005,9 @@ async function buildTargetsResponse(user: AuthedUser) {
|
||||
// 8. Medication Compliance (average across active medications)
|
||||
const activeMedications = await prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
include: { schedules: true },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT } },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ vi.mock("@/lib/db", () => ({
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
medicationIntakeEvent: {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
apiToken: {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
@@ -176,6 +179,153 @@ describe("PUT /api/medications/[id] — v1.5 scheduling primitives", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/medications/[id] — schedule replace migrates open slots", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "m1",
|
||||
userId: "user-1",
|
||||
active: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.update).mockResolvedValue({
|
||||
id: "m1",
|
||||
userId: "user-1",
|
||||
schedules: [],
|
||||
} as never);
|
||||
vi.mocked(getMedicationCategories).mockResolvedValue({});
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
});
|
||||
|
||||
it("tombstones open pending rows (today + future) on a schedule replace", async () => {
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
schedules: [{ windowStart: "20:00", windowEnd: "20:30" }],
|
||||
}),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medicationIntakeEvent.updateMany).toHaveBeenCalledTimes(1);
|
||||
const arg = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock
|
||||
.calls[0][0] as {
|
||||
where: Record<string, unknown>;
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
// Only live, never-acted rows from today's local day-start forward —
|
||||
// taken / skipped / auto-missed history must survive a schedule edit.
|
||||
expect(arg.where).toMatchObject({
|
||||
userId: "user-1",
|
||||
medicationId: "m1",
|
||||
deletedAt: null,
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
});
|
||||
expect(
|
||||
(arg.where.scheduledFor as { gte: Date }).gte,
|
||||
).toBeInstanceOf(Date);
|
||||
expect(arg.data.deletedAt).toBeInstanceOf(Date);
|
||||
expect(arg.data.syncVersion).toEqual({ increment: 1 });
|
||||
});
|
||||
|
||||
it("does NOT touch intake rows when the body carries no schedules array", async () => {
|
||||
const res = await PUT(putReq({ name: "Renamed" }), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/medications/[id] — window / timesOfDay consistency", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "m1",
|
||||
userId: "user-1",
|
||||
active: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.update).mockResolvedValue({
|
||||
id: "m1",
|
||||
userId: "user-1",
|
||||
schedules: [],
|
||||
} as never);
|
||||
vi.mocked(getMedicationCategories).mockResolvedValue({});
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
});
|
||||
|
||||
function createdSchedule(): Record<string, unknown> {
|
||||
const calls = vi.mocked(prisma.medication.update).mock.calls;
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
const arg = calls[calls.length - 1][0] as unknown as {
|
||||
data: { schedules: { create: Array<Record<string, unknown>> } };
|
||||
};
|
||||
return arg.data.schedules.create[0];
|
||||
}
|
||||
|
||||
it("pulls a stale window to the min/max of new timesOfDay", async () => {
|
||||
// The client changed only the dose times and echoed the old window
|
||||
// back; 21:00 falls outside [08:00, 09:00].
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
timesOfDay: ["07:00", "21:00"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const created = createdSchedule();
|
||||
expect(created.windowStart).toBe("07:00");
|
||||
expect(created.windowEnd).toBe("21:00");
|
||||
});
|
||||
|
||||
it("keeps a window that already covers every time byte-identical", async () => {
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "06:00",
|
||||
windowEnd: "22:00",
|
||||
timesOfDay: ["08:00", "20:00"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const created = createdSchedule();
|
||||
expect(created.windowStart).toBe("06:00");
|
||||
expect(created.windowEnd).toBe("22:00");
|
||||
});
|
||||
|
||||
it("keeps an overnight window whose times sit inside the wrap", async () => {
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "22:00",
|
||||
windowEnd: "02:00",
|
||||
timesOfDay: ["23:00"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const created = createdSchedule();
|
||||
expect(created.windowStart).toBe("22:00");
|
||||
expect(created.windowEnd).toBe("02:00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/medications/[id] — primary-schedule grace bridge", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
@@ -31,7 +31,10 @@ import {
|
||||
type CadenceEngineContext,
|
||||
} from "@/lib/medications/scheduling/cadence";
|
||||
import { complianceChips } from "@/lib/medications/scheduling/compliance";
|
||||
import { lastNonSkippedTakenAt } from "@/lib/analytics/compliance";
|
||||
import {
|
||||
lastNonSkippedTakenAt,
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
@@ -52,7 +55,9 @@ export const GET = apiHandler(
|
||||
|
||||
const med = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
include: { schedules: true },
|
||||
// v1.15.20 — schedules through the shared compliance select so the
|
||||
// configured per-dose windows reach this surface like every other.
|
||||
include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT } },
|
||||
});
|
||||
if (!med) {
|
||||
return apiError("Medication not found", 404);
|
||||
|
||||
@@ -19,10 +19,49 @@ import {
|
||||
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { computeNextDueAt } from "@/lib/medications/scheduling/next-due";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { getUserTodayBounds } from "@/lib/timezone";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
/** Parse "HH:mm" into minutes-of-day; NaN-safe (malformed → 0). */
|
||||
function hhmmToMinutes(value: string): number {
|
||||
const [h, m] = value.split(":").map(Number);
|
||||
if (!Number.isFinite(h) || !Number.isFinite(m)) return 0;
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile a schedule's `windowStart` / `windowEnd` with its effective
|
||||
* `timesOfDay`. The schedule editor can change only the dose times while the
|
||||
* client echoes the previous window back; a time outside the window then
|
||||
* breaks every consumer that assumes `timesOfDay ⊆ [windowStart, windowEnd]`
|
||||
* (the window-status pill, the reminder phases, the legacy compliance
|
||||
* walker). When ANY time falls outside the window — including the overnight
|
||||
* `windowEnd < windowStart` shape, checked with wrap-around membership —
|
||||
* the window is pulled to the min/max of the times so the persisted row is
|
||||
* self-consistent. A window that already covers every time is left
|
||||
* byte-identical (explicitly wider windows are a feature).
|
||||
*/
|
||||
function reconcileScheduleWindow(
|
||||
windowStart: string,
|
||||
windowEnd: string,
|
||||
timesOfDay: string[],
|
||||
): { windowStart: string; windowEnd: string } {
|
||||
if (timesOfDay.length === 0) return { windowStart, windowEnd };
|
||||
const startMins = hhmmToMinutes(windowStart);
|
||||
const endMins = hhmmToMinutes(windowEnd);
|
||||
const overnight = endMins < startMins;
|
||||
const inWindow = (t: number): boolean =>
|
||||
overnight ? t >= startMins || t <= endMins : t >= startMins && t <= endMins;
|
||||
const sorted = [...timesOfDay].sort(
|
||||
(a, b) => hhmmToMinutes(a) - hhmmToMinutes(b),
|
||||
);
|
||||
const allInside = sorted.every((t) => inWindow(hhmmToMinutes(t)));
|
||||
if (allInside) return { windowStart, windowEnd };
|
||||
return { windowStart: sorted[0], windowEnd: sorted[sorted.length - 1] };
|
||||
}
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -239,6 +278,31 @@ export const PUT = apiHandler(
|
||||
|
||||
// If schedules provided, replace all
|
||||
if (schedules) {
|
||||
// A schedule replace invalidates the open slot anchors the projector /
|
||||
// reminder worker minted for the OLD times: a pending 08:00 row for a
|
||||
// medication that now doses at 20:00 would linger as a phantom slot,
|
||||
// auto-miss later, and depress compliance. Tombstone today's and
|
||||
// future open pending rows (never an actioned row — `takenAt`,
|
||||
// `skipped`, and `autoMissed` are user-visible history) and bump
|
||||
// `syncVersion` so delta-sync clients drop them. The projector and
|
||||
// the reminder worker re-mint the anchors for the new times on their
|
||||
// next pass — the route does not pre-create them.
|
||||
const { start: todayStart } = getUserTodayBounds(
|
||||
new Date(),
|
||||
user.timezone || "Europe/Berlin",
|
||||
);
|
||||
await prisma.medicationIntakeEvent.updateMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
deletedAt: null,
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
scheduledFor: { gte: todayStart },
|
||||
},
|
||||
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
|
||||
});
|
||||
await prisma.medicationSchedule.deleteMany({
|
||||
where: { medicationId: id },
|
||||
});
|
||||
@@ -292,9 +356,19 @@ export const PUT = apiHandler(
|
||||
? s.timesOfDay
|
||||
: [s.windowStart];
|
||||
|
||||
// Invariant 4 — window / times consistency. See
|
||||
// `reconcileScheduleWindow`: a times-only edit that echoes the
|
||||
// stale window back gets the window pulled to the min/max of
|
||||
// the new times.
|
||||
const window = reconcileScheduleWindow(
|
||||
s.windowStart,
|
||||
s.windowEnd,
|
||||
effectiveTimesOfDay,
|
||||
);
|
||||
|
||||
return {
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
windowStart: window.windowStart,
|
||||
windowEnd: window.windowEnd,
|
||||
label: s.label ?? null,
|
||||
dose: s.dose ?? null,
|
||||
daysOfWeek: serializeScheduleRecurrence({
|
||||
|
||||
@@ -66,7 +66,15 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
// Explicit timezone keeps the band/slot fixtures below host-TZ-stable:
|
||||
// the engine expands occurrences in the user's zone, so an unset value
|
||||
// would silently track the machine's clock.
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "tester",
|
||||
role: "USER" as const,
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
};
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
@@ -480,6 +488,45 @@ describe("POST /api/medications/intake/bulk — v1.15.19 resolver-null convergen
|
||||
);
|
||||
});
|
||||
|
||||
it("anchors a pending echo through the canonical snap (band engine is taken-only)", async () => {
|
||||
// A pending echo (no takenAt) has nothing to attribute by — it must
|
||||
// keep the anchor snap that binds it to the projector-minted slot row.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
|
||||
{
|
||||
id: "row-pending",
|
||||
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);
|
||||
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
|
||||
id: "row-pending",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
medicationId: "med-1",
|
||||
scheduledFor: "2026-06-15T05:00:30.000Z",
|
||||
// no takenAt → pending echo onto the (still pending) slot row
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { updated: number; entries: Array<{ status: string }> };
|
||||
};
|
||||
// The echo converges onto the canonical slot row (here a no-op update
|
||||
// path through the shared upsert), never a standalone insert.
|
||||
expect(body.data.entries[0].status).toBe("updated");
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the idempotencyKey replay contract: a re-submission reports duplicate, not updated", async () => {
|
||||
// The replay pre-check fires BEFORE the convergence probe so a re-sent
|
||||
// entry keeps its historical `duplicate` status.
|
||||
@@ -513,3 +560,193 @@ describe("POST /api/medications/intake/bulk — v1.15.19 resolver-null convergen
|
||||
expect(prisma.medicationIntakeEvent.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// v1.15.20 — taken writes attribute through the window-band engine
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /api/medications/intake/bulk — v1.15.20 band attribution", () => {
|
||||
const SCHEDULED_MED = {
|
||||
id: "med-1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
schedules: [
|
||||
{
|
||||
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,
|
||||
doseWindows: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// 20:30 Berlin on 2026-06-15 — past both fixture slots for the day.
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-06-15T18:30:00.000Z"));
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
{ id: "med-1" },
|
||||
] as never);
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValue(
|
||||
SCHEDULED_MED as never,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("attributes a takenAt-only entry to its slot by band membership", async () => {
|
||||
// 19:30 Berlin sits inside the 19:00 slot's ±60 min on-time band — the
|
||||
// take must converge onto the slot's pending REMINDER row even though
|
||||
// the client named no scheduledFor (the legacy snap refused to bind a
|
||||
// defaulted anchor to a slot; the band engine binds by the take itself).
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
|
||||
{
|
||||
id: "row-evening",
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
idempotencyKey: null,
|
||||
scheduledFor: new Date("2026-06-15T17:00:00Z"),
|
||||
source: "REMINDER",
|
||||
createdAt: new Date("2026-06-15T00:00:00Z"),
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
|
||||
id: "row-evening",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
medicationId: "med-1",
|
||||
takenAt: "2026-06-15T17:30:00.000Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
updated: number;
|
||||
inserted: number;
|
||||
entries: Array<{ status: string; id?: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data.updated).toBe(1);
|
||||
expect(body.data.inserted).toBe(0);
|
||||
expect(body.data.entries[0].status).toBe("updated");
|
||||
expect(body.data.entries[0].id).toBe("row-evening");
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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);
|
||||
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
|
||||
id: "row-morning",
|
||||
} 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: { updated: number; entries: Array<{ status: string; id?: string }> };
|
||||
};
|
||||
expect(body.data.updated).toBe(1);
|
||||
expect(body.data.entries[0].status).toBe("updated");
|
||||
expect(body.data.entries[0].id).toBe("row-morning");
|
||||
});
|
||||
|
||||
it("marks an entry skipped when forceSlotInstant names no real slot", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
medicationId: "med-1",
|
||||
takenAt: "2026-06-15T12:00:00.000Z",
|
||||
forceSlotInstant: "2026-06-15T12:34: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_invalid");
|
||||
expect(body.data.skipped[0]).toEqual({
|
||||
index: 0,
|
||||
reason: "force_slot_invalid",
|
||||
});
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("files an out-of-band take as ad-hoc (scheduledFor = takenAt)", async () => {
|
||||
// 14:00 Berlin, no band, no pin → standalone row anchored on the take.
|
||||
vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValueOnce({
|
||||
id: "row-adhoc",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
medicationId: "med-1",
|
||||
takenAt: "2026-06-15T12:00:00.000Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { inserted: number; entries: Array<{ status: string }> };
|
||||
};
|
||||
expect(body.data.inserted).toBe(1);
|
||||
expect(body.data.entries[0].status).toBe("inserted");
|
||||
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
scheduledFor: new Date("2026-06-15T12:00:00.000Z"),
|
||||
takenAt: new Date("2026-06-15T12:00:00.000Z"),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,9 @@
|
||||
* idempotencyKey?, — Medication.intake_events.idempotency_key
|
||||
* (existing UNIQUE column; serves as the
|
||||
* per-entry dedup hint)
|
||||
* forceSlotInstant?, — ISO timestamp; pins a taken entry onto a
|
||||
* named real scheduled slot (server-validated
|
||||
* against the band anchors)
|
||||
* }
|
||||
*
|
||||
* Response (always 200): processed / inserted / duplicates / skipped /
|
||||
@@ -49,6 +52,8 @@ import {
|
||||
} from "@/lib/rollups/medication-compliance-rollups";
|
||||
import {
|
||||
applyCanonicalSlotWrite,
|
||||
resolveForcedSlotForWrite,
|
||||
resolveSlotForWriteByBand,
|
||||
resolveSlotInstantForWrite,
|
||||
} from "@/lib/medications/scheduling/slot-upsert";
|
||||
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
|
||||
@@ -79,6 +84,17 @@ const bulkEntrySchema = z.object({
|
||||
// entry skipped (`injection_site_not_allowed`) without failing the
|
||||
// whole batch, matching the bulk endpoint's per-entry contract.
|
||||
injectionSite: injectionSiteEnum.optional(),
|
||||
// v1.15.20 — late-take "attribute anyway" pin, mirroring the single
|
||||
// intake route: pin a taken entry onto a named real scheduled slot
|
||||
// instead of the default window-band attribution. Validated server-side
|
||||
// against the band anchors; an instant that is not a slot marks the
|
||||
// entry skipped (`force_slot_invalid`) per the bulk per-entry contract.
|
||||
// Ignored on non-taken entries (a pending echo / skip carries no take
|
||||
// to attribute).
|
||||
forceSlotInstant: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const bulkPayloadSchema = z.object({
|
||||
@@ -228,32 +244,6 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
const incomingScheduledFor =
|
||||
entry.scheduledFor ?? entry.takenAt ?? new Date();
|
||||
|
||||
// v1.8.2 — source-agnostic slot snap. iOS posts the "Genommen"
|
||||
// reminder action here (source API); without this it inserted a
|
||||
// SECOND row for a slot already carrying the projector/worker's
|
||||
// pending REMINDER row (the unique key includes `source`, and the
|
||||
// iOS-vs-server `scheduledFor` drifts by a minute). Snap onto the
|
||||
// canonical slot instant and update the existing row in place.
|
||||
const canonicalSlot = await resolveSlotInstantForWrite({
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
userTz: user.timezone,
|
||||
incoming: incomingScheduledFor,
|
||||
// Only a client-supplied `scheduledFor` names a real slot. A
|
||||
// `takenAt`-only / defaulted-now write must not snap across the
|
||||
// wide ±halfGap window onto a far slot (phantom morning dose).
|
||||
instantIsExplicit: entry.scheduledFor !== undefined,
|
||||
// Dose-safety: a taken write (has `takenAt`, not skipped) must never
|
||||
// snap forward onto a future slot. A pending sync echo (no `takenAt`)
|
||||
// legitimately maps to a future slot, so the guard stays off for it.
|
||||
isTakenWrite: !entry.skipped && entry.takenAt !== undefined,
|
||||
});
|
||||
|
||||
// The `scheduledFor` instant the row actually lands on — set by the
|
||||
// branch that performs the write so the rollup recompute below keys
|
||||
// the same day the stored row anchors to.
|
||||
let effectiveScheduledFor = canonicalSlot ?? incomingScheduledFor;
|
||||
|
||||
// C2 — classify the incoming write. An offline-sync replay echoes a
|
||||
// PENDING projection (no `takenAt`, `skipped:false`) for a slot the
|
||||
// user may already have actioned; that echo must NEVER clear a
|
||||
@@ -262,6 +252,67 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
const isExplicitTaken = !entry.skipped && entry.takenAt !== undefined;
|
||||
const isExplicitSkip = entry.skipped === true;
|
||||
|
||||
// v1.15.20 — taken writes attribute through the window-band engine
|
||||
// (the SAME minter the read ledger + the compliance % consume), so a
|
||||
// bulk-synced take binds to a slot identically to the single-route
|
||||
// write. The optional `forceSlotInstant` pins an off-window take onto
|
||||
// a named real slot. Pending echoes and skips carry no take to
|
||||
// attribute by, so they keep the canonical anchor snap that binds
|
||||
// them to the projector/worker-minted slot row.
|
||||
let canonicalSlot: Date | null;
|
||||
if (isExplicitTaken && entry.forceSlotInstant !== undefined) {
|
||||
canonicalSlot = await resolveForcedSlotForWrite({
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
userTz: user.timezone,
|
||||
slotInstant: entry.forceSlotInstant,
|
||||
});
|
||||
if (canonicalSlot === null) {
|
||||
// Mirrors the single route's 422 (`medications.intake.force_slot
|
||||
// .invalid`) as a per-entry skip so one bad pin never fails the
|
||||
// batch.
|
||||
const reason = "force_slot_invalid";
|
||||
skipped.push({ index: i, reason });
|
||||
results.push({ index: i, status: "skipped", reason });
|
||||
continue;
|
||||
}
|
||||
} else if (isExplicitTaken) {
|
||||
const attribution = await resolveSlotForWriteByBand({
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
userTz: user.timezone,
|
||||
takenAt: entry.takenAt as Date,
|
||||
});
|
||||
canonicalSlot = attribution.slotInstant;
|
||||
} else {
|
||||
// v1.8.2 — source-agnostic anchor snap for pending echoes + skips.
|
||||
// iOS posts the reminder actions here (source API); without this
|
||||
// they inserted a SECOND row for a slot already carrying the
|
||||
// projector/worker's pending REMINDER row (the unique key includes
|
||||
// `source`, and the iOS-vs-server `scheduledFor` drifts by a
|
||||
// minute). Snap onto the canonical slot instant and update the
|
||||
// existing row in place.
|
||||
canonicalSlot = await resolveSlotInstantForWrite({
|
||||
userId: user.id,
|
||||
medicationId: entry.medicationId,
|
||||
userTz: user.timezone,
|
||||
incoming: incomingScheduledFor,
|
||||
// Only a client-supplied `scheduledFor` names a real slot. A
|
||||
// defaulted-now write must not snap across the wide ±halfGap
|
||||
// window onto a far slot (phantom morning dose).
|
||||
instantIsExplicit: entry.scheduledFor !== undefined,
|
||||
// Pending echoes / skips never record a take; a pending sync
|
||||
// echo legitimately maps to a future slot, so the taken
|
||||
// future-slot guard stays off here.
|
||||
isTakenWrite: false,
|
||||
});
|
||||
}
|
||||
|
||||
// The `scheduledFor` instant the row actually lands on — set by the
|
||||
// branch that performs the write so the rollup recompute below keys
|
||||
// the same day the stored row anchors to.
|
||||
let effectiveScheduledFor = canonicalSlot ?? incomingScheduledFor;
|
||||
|
||||
// v1.8.5 — resolve + validate the optional per-entry injection
|
||||
// site. A disallowed site marks the entry skipped without failing
|
||||
// the batch (per-entry contract); a non-injection / tracking-off /
|
||||
|
||||
Reference in new issue
Block a user