fix(medications): every intake path converges on the slot, and caches cannot resurrect a pre-write body

The external ingest endpoint was the one intake writer that never
attributed to the minted band: it stored a bare row beside the pending
slot, so an early take through the API showed as the last intake while
the slot kept demanding a dose, the ledger recorded an extra plus a
miss, and the day tally counted both. It now resolves the band and
converges onto the pending row like every other path, the Telegram
confirm and skip buttons do the same preferring the reminder's own
slot, and the nightly convergence pass heals the pairs this has
already produced — one band match, one live anchor row, both days'
rollups recomputed. The cache layer fences in-flight builders on
invalidation so a read can neither join a pre-write build nor have a
background refresh re-cache a pre-write body as fresh. Ad-hoc rows no
longer resolve a different slot through the six-hour radius, a bounded
early grace credits a take up to an hour before the window without
crossing the previous band, day-scale cadences taken early downgrade
the slot-day prompt instead of inviting a double dose, the overdue
pill ignores the projector's pending mints, created intakes carry the
same plausibility bounds as edits, slot moves keep the recorded
injection site and dose, both affected days recompute on a moved
toggle, deliberate slot-less skips converge instead of orphaning, the
coach reads adherence from the ledger, the cards and quick-add run in
the user's timezone with the served next-due as the gate, and
schedule-revision, wipe and restore writes invalidate the caches they
feed.
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 10:43:32 +02:00
1 parent ea29e9cf1b
commit aaecd141ee
53 files changed
+2550 -202

No files matched your search

@@ -50,6 +50,7 @@ import {
} from "@/lib/rollups/medication-compliance-rollups";
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
import { restoreCycleData } from "@/lib/cycle/backup";
import { invalidateUserData } from "@/lib/cache/invalidate";
export const dynamic = "force-dynamic";
@@ -536,6 +537,10 @@ const handler = apiHandler(
},
});
// v1.16.9 — the restore replaced every row the owner's cached
// payloads were built on; hard-evict all of that user's buckets.
invalidateUserData(ownerId);
const response: RestoreResponse = {
restored: true,
summary,
+5
View File
@@ -5,6 +5,7 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { NextRequest, NextResponse } from "next/server";
import { invalidateAllCaches } from "@/lib/cache/invalidate";
export const dynamic = "force-dynamic";
@@ -105,5 +106,9 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
details: result,
});
// v1.16.9 — the wipe touched every user's rows; clear every cache
// bucket so no per-user payload survives the reset.
invalidateAllCaches();
return apiSuccess({ cleared: true, ...result });
});
@@ -12,7 +12,9 @@ vi.mock("@/lib/db", () => ({
},
medicationIntakeEvent: {
findFirst: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
medication: {
findFirst: vi.fn(),
@@ -38,6 +40,9 @@ vi.mock("@/lib/app-settings", () => ({
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: vi.fn(),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
@@ -55,6 +60,7 @@ import { POST } from "../route";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/ingest/medication", {
@@ -145,3 +151,156 @@ describe("POST /api/ingest/medication — 422 multi-issue (v1.4.43 W6)", () => {
expect(res.status).toBe(422);
});
});
describe("POST /api/ingest/medication — slot attribution + convergence (v1.16.9)", () => {
// The maintainer's live bug: a daily 09:00 med, the worker pre-minted the
// pending REMINDER row at 09:00 Berlin, the user ingested the take at
// 08:42 Berlin via the external API. The bare create anchored a SECOND
// row at 08:42 and left the 09:00 pending row open — reminder kept
// firing, ledger showed ad-hoc + later missed, today feed said "due".
const SLOT_0900_BERLIN = new Date("2026-06-10T07:00:00.000Z"); // 09:00 CEST
const TAKEN_0842_BERLIN = new Date("2026-06-10T06:42:00.000Z"); // 08:42 CEST
const scheduleRow = {
id: "sched-1",
windowStart: "09:00",
windowEnd: "09:00",
daysOfWeek: null,
timesOfDay: ["09:00"],
reminderGraceMinutes: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
};
const pendingReminderRow = {
id: "evt-pending-0900",
takenAt: null,
skipped: false,
idempotencyKey: null,
scheduledFor: SLOT_0900_BERLIN,
source: "REMINDER",
createdAt: new Date("2026-06-10T05:00:00.000Z"),
};
function wireHappyPath() {
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "tok-1",
userId: "user-1",
permissions: ["*"],
revoked: false,
expiresAt: null,
lastUsedAt: null,
} as never);
vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Europe/Berlin",
} as never);
// First findFirst (by name) returns the bare medication; the slot
// resolver's findFirst (selects schedules) returns the projection.
vi.mocked(prisma.medication.findFirst).mockImplementation(((args: {
where: Record<string, unknown>;
}) => {
if (args.where && "name" in args.where) {
return Promise.resolve({ id: "med-1", name: "Metformin" });
}
return Promise.resolve({
id: "med-1",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
schedules: [scheduleRow],
scheduleRevisions: [],
});
}) as never);
// Idempotency probe → no replay.
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(null);
// The slot's live rows: the worker-minted pending REMINDER row.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
pendingReminderRow,
] as never);
vi.mocked(prisma.medicationIntakeEvent.update).mockImplementation(((args: {
where: { id: string };
data: Record<string, unknown>;
}) =>
Promise.resolve({
...pendingReminderRow,
...args.data,
syncVersion: 2,
})) as never);
}
it("converges an 08:42 take onto the pending 09:00 REMINDER row (no second row)", async () => {
wireHappyPath();
const res = await POST(
postReq({
medicationName: "Metformin",
takenAt: TAKEN_0842_BERLIN.toISOString(),
idempotencyKey: "ha-ingest-1",
}),
);
expect(res.status).toBe(201);
const body = (await res.json()) as { data: { id: string } };
// The pending REMINDER row was updated in place — never a bare create.
expect(body.data.id).toBe("evt-pending-0900");
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1);
const update = vi.mocked(prisma.medicationIntakeEvent.update).mock
.calls[0][0] as {
where: { id: string };
data: { takenAt: Date; skipped: boolean; autoMissed?: boolean };
};
expect(update.where.id).toBe("evt-pending-0900");
expect(update.data.takenAt?.getTime()).toBe(TAKEN_0842_BERLIN.getTime());
expect(update.data.skipped).toBe(false);
// A recorded take clears a prior cron auto-miss.
expect(update.data.autoMissed).toBe(false);
});
it("hard-evicts the user's medication caches after the write", async () => {
wireHappyPath();
await POST(
postReq({
medicationName: "Metformin",
takenAt: TAKEN_0842_BERLIN.toISOString(),
idempotencyKey: "ha-ingest-2",
}),
);
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
evict: true,
});
});
it("keeps the standalone ad-hoc contract for an off-window take", async () => {
wireHappyPath();
// 14:30 Berlin — outside the 09:00 band and its late tail.
const offWindow = new Date("2026-06-10T12:30:00.000Z");
vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({
id: "evt-adhoc",
scheduledFor: offWindow,
takenAt: offWindow,
skipped: false,
source: "API",
} as never);
const res = await POST(
postReq({
medicationName: "Metformin",
takenAt: offWindow.toISOString(),
idempotencyKey: "ha-ingest-3",
}),
);
expect(res.status).toBe(201);
expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled();
const create = vi.mocked(prisma.medicationIntakeEvent.create).mock
.calls[0][0] as { data: { scheduledFor: Date; takenAt: Date } };
// Ad-hoc rows anchor on the intake instant (`scheduledFor = takenAt`).
expect(create.data.scheduledFor.getTime()).toBe(offWindow.getTime());
expect(create.data.takenAt.getTime()).toBe(offWindow.getTime());
});
});
+73 -14
View File
@@ -15,6 +15,12 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { externalIntakeSchema } from "@/lib/validations/medication";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
import {
applyCanonicalSlotWrite,
resolveSlotForWriteByBand,
} from "@/lib/medications/scheduling/slot-upsert";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { NextRequest, NextResponse } from "next/server";
/**
@@ -159,17 +165,64 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("API endpoint for this medication is disabled", 403);
}
const event = await prisma.medicationIntakeEvent.create({
data: {
// The external ingest path runs without a User record in scope;
// resolve the timezone once — band attribution and the compliance
// recompute below both anchor on the user's local day.
const ingestUser = await prisma.user.findUnique({
where: { id: apiToken.userId },
select: { timezone: true },
});
const userTz = ingestUser?.timezone || DEFAULT_TIMEZONE;
const effectiveTakenAt = takenAt ?? new Date();
// v1.16.9 — attribute the take to its scheduled slot by window-band
// membership, the SAME engine the per-medication intake route and the
// read ledger consume. The bare create this replaces anchored every
// ingest at `scheduledFor = takenAt`, leaving the slot's pending
// REMINDER row open: the reminder kept firing, the ledger showed an
// ad-hoc take PLUS a missed slot, and the today feed still said "due".
const attribution = await resolveSlotForWriteByBand({
userId: apiToken.userId,
medicationId: medication.id,
userTz,
takenAt: effectiveTakenAt,
});
let event;
if (attribution.slotInstant) {
// Scheduled dose — converge onto the one canonical slot row (the
// pending REMINDER row when one exists) through the shared upsert:
// H1 deterministic selection, C2 no-downgrade, P2002-safe create.
const applied = await applyCanonicalSlotWrite({
client: prisma,
userId: apiToken.userId,
medicationId: medication.id,
scheduledFor: takenAt ?? new Date(),
takenAt: takenAt ?? new Date(),
canonicalSlot: attribution.slotInstant,
takenAt: effectiveTakenAt,
skipped: false,
source: "API",
isExplicitTaken: true,
isExplicitSkip: false,
idempotencyKey,
},
});
createSource: "API",
attributionSource: "AUTO",
});
event = applied.row;
} else {
// Ad-hoc / PRN / off-window — standalone row under the documented
// contract (`scheduledFor = takenAt`).
event = await prisma.medicationIntakeEvent.create({
data: {
userId: apiToken.userId,
medicationId: medication.id,
scheduledFor: effectiveTakenAt,
takenAt: effectiveTakenAt,
skipped: false,
source: "API",
idempotencyKey,
},
});
}
await auditLog("medication.ingest.external", {
userId: apiToken.userId,
@@ -181,15 +234,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
annotate({ meta: { medication_id: medication.id, event_id: event.id } });
annotate({
meta: {
medication_id: medication.id,
event_id: event.id,
slot_resolved: attribution.slotInstant !== null,
},
});
// v1.16.9 — the ingest is an interactive dose record; the next read
// (today feed, card pill, compliance heatmap) must reflect it rather
// than wait out the TTL.
invalidateUserMedications(apiToken.userId, { evict: true });
// v1.4.39 W-MED — refresh the compliance rollup for the ingested
// event's user-day. The external ingest path runs without a User
// record in scope; resolve the tz once for the recompute.
const ingestUser = await prisma.user.findUnique({
where: { id: apiToken.userId },
select: { timezone: true },
});
// event's user-day.
await recomputeMedicationComplianceForEvent({
userId: apiToken.userId,
medicationId: medication.id,
@@ -264,6 +264,11 @@ export const PUT = apiHandler(
idempotencyKey: null,
createSource: "WEB",
attributionSource,
// v1.16.9 — a slot move is a re-binding, not a new dose: the
// original row's recorded injection site and dose override ride
// along onto the converged row instead of being tombstoned away.
injectionSite: event.injectionSite,
doseTaken: event.doseTaken,
});
updated = applied.row;
}
+23 -8
View File
@@ -179,14 +179,29 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
// Skips carry no binding decision (undefined → column untouched/default).
let attributionSource: "AUTO" | "USER_PIN" | undefined;
if (skipped) {
canonicalSlot = await resolveSlotInstantForWrite({
userId: user.id,
medicationId: id,
userTz: user.timezone,
incoming: incomingScheduledFor,
instantIsExplicit: scheduledFor !== undefined,
isTakenWrite: false,
});
if (scheduledFor === undefined) {
// v1.16.9 — a slot-less deliberate skip resolves by band membership
// on the skip moment, like a take does. The defaulted-now snap only
// reached the tight reminder-grace window, so a skip tapped past it
// landed as an orphan ad-hoc row while the slot's pending REMINDER
// row stayed open to auto-miss.
const attribution = await resolveSlotForWriteByBand({
userId: user.id,
medicationId: id,
userTz: user.timezone,
takenAt: incomingScheduledFor,
});
canonicalSlot = attribution.slotInstant;
} else {
canonicalSlot = await resolveSlotInstantForWrite({
userId: user.id,
medicationId: id,
userTz: user.timezone,
incoming: incomingScheduledFor,
instantIsExplicit: true,
isTakenWrite: false,
});
}
} else if (forceSlotInstant !== undefined) {
canonicalSlot = await resolveForcedSlotForWrite({
userId: user.id,
+5 -2
View File
@@ -25,6 +25,7 @@ import type { Prisma } from "@/generated/prisma/client";
import {
computeDisplayDue,
OVERDUE_LOOKBACK_MS,
toResolvedSlotMark,
} from "@/lib/medications/scheduling/next-due";
import {
dayKeyForScheduledFor,
@@ -137,7 +138,9 @@ export const GET = apiHandler(
{ autoMissed: true },
],
},
select: { scheduledFor: true },
// v1.16.9 — `takenAt` rides along so an ad-hoc row (`scheduledFor
// === takenAt`) cannot ±6h-resolve a different slot.
select: { scheduledFor: true, takenAt: true },
});
// v1.16.4 — current-era floor: the open-overdue search mints from
// the LIVE schedule rows, so it must not reach past the newest
@@ -161,7 +164,7 @@ export const GET = apiHandler(
now,
userTz: user.timezone || "Europe/Berlin",
lastIntakeAt: lastIntake?.takenAt ?? null,
resolvedSlots: resolvedRows.map((r) => r.scheduledFor),
resolvedSlots: resolvedRows.map(toResolvedSlotMark),
eraStart: latestRevision?.validUntil ?? null,
});
@@ -39,6 +39,7 @@ import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { scheduleRevisionUpdateSchema } from "@/lib/validations/schedule-revision";
import { toRevisionPayloadEntry } from "@/lib/medications/scheduling/schedule-eras";
import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import type { Prisma } from "@/generated/prisma/client";
type RouteParams = { params: Promise<{ id: string; revisionId: string }> };
@@ -249,6 +250,10 @@ export const PATCH = apiHandler(
// The corrected era re-segments history; refresh the pre-aggregated
// compliance rollups asynchronously (best-effort).
// v1.16.9 — an era write re-segments the bands every cached payload
// (list next-due, compliance cells, dashboard tally) was built on;
// hard-evict so the next read reflects the new history immediately.
invalidateUserMedications(user.id, { evict: true });
await enqueueUserMedicationComplianceBackfill(user.id);
return apiSuccess({
@@ -321,6 +326,10 @@ export const DELETE = apiHandler(
// History re-segments without the era; refresh the pre-aggregated
// compliance rollups asynchronously (best-effort).
// v1.16.9 — an era write re-segments the bands every cached payload
// (list next-due, compliance cells, dashboard tally) was built on;
// hard-evict so the next read reflects the new history immediately.
invalidateUserMedications(user.id, { evict: true });
await enqueueUserMedicationComplianceBackfill(user.id);
return apiSuccess({ deleted: true });
@@ -40,6 +40,7 @@ import {
type ScheduleRevisionEntry,
} from "@/lib/medications/scheduling/schedule-eras";
import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import type { Prisma } from "@/generated/prisma/client";
type RouteParams = { params: Promise<{ id: string }> };
@@ -280,6 +281,10 @@ export const POST = apiHandler(
// The new era re-segments history the compliance rollups already
// pre-aggregated; refresh them asynchronously. Best-effort like
// every other rollup write-hook.
// v1.16.9 — an era write re-segments the bands every cached payload
// (list next-due, compliance cells, dashboard tally) was built on;
// hard-evict so the next read reflects the new history immediately.
invalidateUserMedications(user.id, { evict: true });
await enqueueUserMedicationComplianceBackfill(user.id);
return apiSuccess(
@@ -10,6 +10,13 @@ vi.mock("@/lib/db", () => ({
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn(),
},
medicationIntakeEvent: {
groupBy: vi.fn().mockResolvedValue([]),
findMany: vi.fn().mockResolvedValue([]),
},
medicationScheduleRevision: {
groupBy: vi.fn().mockResolvedValue([]),
},
medicationCategoryAssignment: {
findMany: vi.fn().mockResolvedValue([]),
},
@@ -59,9 +66,11 @@ vi.mock("next/headers", () => ({
})),
}));
import { POST } from "../route";
import { GET, POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { cachedSwr } from "@/lib/cache/server-cache";
import { getUserTodayBounds } from "@/lib/tz/local-day";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -353,3 +362,52 @@ describe("POST /api/medications — v1.5.4 clinical category extension", () => {
expect(res.status).toBe(422);
});
});
describe("GET /api/medications — todayEventCount counts only actioned rows (v1.16.9)", () => {
it("filters the today-count groupBy to taken-or-skipped rows", async () => {
// The dashboard projector mints a pending row per slot of the day, so
// an unfiltered count covered every passed dose after any dashboard
// visit and the cards' overdue pill went dark. The count read must
// exclude pending rows.
// (`vi.resetAllMocks()` in beforeEach clears the factory defaults, so
// re-wire the pass-through + empty result sets here.)
vi.mocked(cachedSwr).mockImplementation((async (
_c: unknown,
_k: string,
f: () => Promise<unknown>,
) => f()) as never);
vi.mocked(getUserTodayBounds).mockReturnValue({
start: new Date("2026-06-10T00:00:00Z"),
end: new Date("2026-06-10T23:59:59Z"),
} as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.groupBy).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.medicationScheduleRevision.groupBy).mockResolvedValue(
[] as never,
);
// The handler body takes no parameters; `apiHandler` still receives
// the request through the wrapper's rest args.
const res = await (
GET as unknown as (req: NextRequest) => Promise<Response>
)(new NextRequest("http://localhost/api/medications"));
expect(res.status).toBe(200);
const groupByCalls = vi.mocked(prisma.medicationIntakeEvent.groupBy).mock
.calls;
const countCall = groupByCalls.find(
(c) => (c[0] as { _count?: unknown })._count !== undefined,
);
expect(countCall).toBeDefined();
const where = (
countCall![0] as {
where: { OR?: unknown };
}
).where;
expect(where.OR).toEqual([{ takenAt: { not: null } }, { skipped: true }]);
});
});
+19 -4
View File
@@ -59,6 +59,7 @@ import {
} from "@/lib/medications/scheduling/slot-upsert";
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
import {
boundedTakenAtSchema,
injectionSiteEnum,
type InjectionSiteValue,
} from "@/lib/validations/medication";
@@ -74,10 +75,9 @@ const bulkEntrySchema = z.object({
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
takenAt: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
// v1.16.9 — shared plausibility bounds (no future beyond clock skew,
// 5-year floor), matching the single-intake create + edit paths.
takenAt: boundedTakenAtSchema.optional(),
skipped: z.boolean().optional().default(false),
idempotencyKey: z.string().min(1).max(128).optional(),
// v1.8.5 — optional per-entry injection site. Validated against the
@@ -313,6 +313,21 @@ async function postBulk(request: NextRequest): Promise<Response> {
});
canonicalSlot = attribution.slotInstant;
attributionSource = "AUTO";
} else if (isExplicitSkip && entry.scheduledFor === undefined) {
// v1.16.9 — a slot-less deliberate skip resolves by band
// membership on the skip moment, exactly like a take does. The
// defaulted-now snap below only reaches the tight reminder-grace
// window, so a skip tapped 20 minutes past the dose time inserted
// an orphan ad-hoc skip row while the slot's pending REMINDER row
// stayed open and later auto-missed — the user's deliberate skip
// recorded as a miss.
const attribution = await resolveSlotForWriteByBand({
userId: user.id,
medicationId: entry.medicationId,
userTz: user.timezone,
takenAt: incomingScheduledFor,
});
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
+20 -1
View File
@@ -560,7 +560,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
isExplicitSkip: false,
idempotencyKey: null,
createSource: "WEB",
injectionSite: resolvedInjectionSite,
// v1.16.9 — carry the recorded site / dose override across the
// tombstone + re-insert: a slot move is a re-binding, not a new
// dose, so the original row's documentation must survive when the
// toggle write itself carries none.
injectionSite:
resolvedInjectionSite ??
(existing.injectionSite as InjectionSiteValue | null),
doseTaken: existing.doseTaken,
});
updated = applied.row;
await prisma.medication.update({
@@ -611,6 +618,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
scheduledFor: existing.scheduledFor,
tz: userTzForHook,
});
// v1.16.9 — a slot move re-binds the dose onto a DIFFERENT instant,
// possibly a different local day. Recompute the target day too, or the
// source day self-corrects while the destination keeps its stale tuple.
const movedTo = updated?.scheduledFor;
if (movedTo && movedTo.getTime() !== existing.scheduledFor.getTime()) {
await recomputeMedicationComplianceForEvent({
userId: user.id,
medicationId: existing.medicationId,
scheduledFor: movedTo,
tz: userTzForHook,
});
}
return apiSuccess(updated);
});
+18 -4
View File
@@ -18,6 +18,8 @@ import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
import {
computeDisplayDue,
OVERDUE_LOOKBACK_MS,
toResolvedSlotMark,
type ResolvedSlotMark,
} from "@/lib/medications/scheduling/next-due";
import { getUserTodayBounds } from "@/lib/tz/local-day";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
@@ -73,10 +75,17 @@ async function buildMedicationsList(
prisma.medicationIntakeEvent.groupBy({
by: ["medicationId"],
// v1.7.0 sync — exclude tombstoned rows from the today-count map.
// v1.16.9 — count only ACTIONED rows (taken or skipped). The
// dashboard projector mints pending rows for every slot of the
// day, so counting all rows made `todayEventCount` cover every
// passed dose after any dashboard visit — and the cards'
// overdue-pill suppression (`todayEventCount < passedDoseCount`)
// went dark nondeterministically for genuinely overdue doses.
where: {
userId,
deletedAt: null,
scheduledFor: { gte: todayStartUtc, lte: todayEndUtc },
OR: [{ takenAt: { not: null } }, { skipped: true }],
},
_count: { id: true },
}),
@@ -91,7 +100,11 @@ async function buildMedicationsList(
{ autoMissed: true },
],
},
select: { medicationId: true, scheduledFor: true },
// v1.16.9 — `takenAt` rides along so the ad-hoc shape
// (`scheduledFor === takenAt`) is detectable: such a row must not
// ±6h-resolve a DIFFERENT slot (a 14:30 ad-hoc take hid tonight's
// genuinely-due 20:00 dose).
select: { medicationId: true, scheduledFor: true, takenAt: true },
}),
// v1.16.4 — current-era floor per medication: the newest revision's
// `validUntil` is where the LIVE schedule rows became valid. The
@@ -106,11 +119,12 @@ async function buildMedicationsList(
}),
]);
const resolvedSlotsByMedId = new Map<string, Date[]>();
const resolvedSlotsByMedId = new Map<string, ResolvedSlotMark[]>();
for (const e of resolvedEvents) {
const mark = toResolvedSlotMark(e);
const list = resolvedSlotsByMedId.get(e.medicationId);
if (list) list.push(e.scheduledFor);
else resolvedSlotsByMedId.set(e.medicationId, [e.scheduledFor]);
if (list) list.push(mark);
else resolvedSlotsByMedId.set(e.medicationId, [mark]);
}
const eraStartByMedId = new Map<string, Date>();
+5
View File
@@ -4,6 +4,7 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { invalidateUserData } from "@/lib/cache/invalidate";
export const dynamic = "force-dynamic";
@@ -119,5 +120,9 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "settings.data.clear" }, meta: result });
// v1.16.9 — every cached payload was built on the rows just deleted;
// hard-evict the user's buckets so no surface serves pre-wipe data.
invalidateUserData(userId);
return apiSuccess({ cleared: true, ...result });
});
@@ -18,9 +18,12 @@ vi.mock("@/lib/db", () => ({
medicationIntakeEvent: {
create: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
},
telegramReminderMessage: {
deleteMany: vi.fn(),
findFirst: vi.fn(),
},
telegramScheduledDeletion: {
createMany: vi.fn(),
@@ -52,8 +55,17 @@ vi.mock("@/lib/logging/context", () => ({
})),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: vi.fn(),
}));
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
}));
import { POST, GET } from "../route";
import { prisma } from "@/lib/db";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import {
answerTelegramCallbackQuery,
deleteMessage,
@@ -67,10 +79,40 @@ const TG_USER = {
id: "user-1",
telegramBotToken: "ENC(token-blob)",
locale: "en",
timezone: "Europe/Berlin",
};
const MEDICATION = { id: "med-1", name: "Ramipril" };
/**
* The slot-attribution load (`loadAttributeMedication`) selects the full
* schedule projection (distinguishable by `select.schedules`); every other
* `medication.findFirst` in the webhook wants the bare id/name shape. The
* default projection carries no schedules, so the generic dispatch tests
* exercise the ad-hoc (standalone create) path unchanged.
*/
function wireMedicationFindFirst(
schedules: unknown[] = [],
scheduleRevisions: unknown[] = [],
) {
vi.mocked(prisma.medication.findFirst).mockImplementation(((args: {
select?: Record<string, unknown>;
}) => {
if (args?.select && "schedules" in args.select) {
return Promise.resolve({
id: "med-1",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
schedules,
scheduleRevisions,
});
}
return Promise.resolve(MEDICATION);
}) as never);
}
beforeEach(() => {
vi.resetAllMocks();
process.env.TELEGRAM_WEBHOOK_SECRET = "sekret";
@@ -80,14 +122,18 @@ beforeEach(() => {
resetAt: Date.now() + 60_000,
} as never);
vi.mocked(prisma.user.findFirst).mockResolvedValue(TG_USER as never);
vi.mocked(prisma.medication.findFirst).mockResolvedValue(MEDICATION as never);
wireMedicationFindFirst();
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medication.update).mockResolvedValue({} as never);
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(null);
vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.telegramReminderMessage.deleteMany).mockResolvedValue({
count: 0,
} as never);
vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue(null);
vi.mocked(prisma.telegramScheduledDeletion.createMany).mockResolvedValue({
count: 0,
} as never);
@@ -185,15 +231,7 @@ describe("Telegram webhook — callback dispatch", () => {
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
expect(res.status).toBe(200);
// $transaction is invoked with [create-intake, update-medication]. Inspect
// its argument list for both writes.
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
const txOps = vi.mocked(prisma.$transaction).mock.calls[0][0] as unknown;
expect(Array.isArray(txOps)).toBe(true);
// The route calls prisma.medicationIntakeEvent.create + medication.update
// synchronously while building the transaction array, so these mocks
// record the arguments the way it built them.
// No schedules wired → the take is ad-hoc and inserts standalone.
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
const intakeArgs = vi.mocked(prisma.medicationIntakeEvent.create).mock
.calls[0][0];
@@ -226,7 +264,7 @@ describe("Telegram webhook — callback dispatch", () => {
null,
);
await POST(tgRequest(callbackUpdate("taken:med-1")));
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
// Second call: existing-row lookup hits → no second write.
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce({
@@ -234,7 +272,7 @@ describe("Telegram webhook — callback dispatch", () => {
} as never);
const res2 = await POST(tgRequest(callbackUpdate("taken:med-1")));
expect(res2.status).toBe(200);
expect(prisma.$transaction).toHaveBeenCalledTimes(1); // unchanged
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); // unchanged
});
it("'snooze:<medId>:60' updates the medication's snoozedUntil and answers with the locale-translated message", async () => {
@@ -599,6 +637,159 @@ describe("Telegram webhook — text-message dispatch", () => {
});
});
describe("Telegram webhook — slot convergence (v1.16.9)", () => {
// Daily 09:00 Berlin med; the worker pre-minted the pending REMINDER row
// at the canonical slot instant. A Telegram take/skip must converge onto
// that row instead of inserting a second row anchored at `now` — the old
// shape left the pending row open to auto-miss, punishing compliance for
// a dose the user explicitly confirmed.
const SLOT_0900_BERLIN = new Date("2026-06-10T07:00:00.000Z"); // 09:00 CEST
const NOW_0903_BERLIN = new Date("2026-06-10T07:03:00.000Z"); // 09:03 CEST
const scheduleRow = {
id: "sched-1",
windowStart: "09:00",
windowEnd: "09:00",
daysOfWeek: null,
timesOfDay: ["09:00"],
reminderGraceMinutes: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
};
const pendingReminderRow = {
id: "evt-pending-0900",
takenAt: null,
skipped: false,
idempotencyKey: null,
scheduledFor: SLOT_0900_BERLIN,
source: "REMINDER",
createdAt: new Date("2026-06-10T05:00:00.000Z"),
};
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW_0903_BERLIN);
wireMedicationFindFirst([scheduleRow]);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
pendingReminderRow,
] as never);
vi.mocked(prisma.medicationIntakeEvent.update).mockImplementation(((args: {
data: Record<string, unknown>;
}) =>
Promise.resolve({
...pendingReminderRow,
...args.data,
syncVersion: 2,
})) as never);
});
afterEach(() => {
vi.useRealTimers();
});
it("a take converges onto the pending REMINDER row via the reminder's own slot", async () => {
vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue({
date: "2026-06-10",
timeOfDay: "09:00",
} as never);
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
expect(res.status).toBe(200);
// The pending row was updated in place — never a duplicate insert.
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1);
const update = vi.mocked(prisma.medicationIntakeEvent.update).mock
.calls[0][0] as {
where: { id: string };
data: { takenAt: Date; skipped: boolean; autoMissed?: boolean };
};
expect(update.where.id).toBe("evt-pending-0900");
expect(update.data.takenAt?.getTime()).toBe(NOW_0903_BERLIN.getTime());
expect(update.data.skipped).toBe(false);
expect(update.data.autoMissed).toBe(false);
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
evict: true,
});
});
it("a take converges via band attribution when no reminder row matches", async () => {
vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue(null);
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
expect(res.status).toBe(200);
// 09:03 sits inside the 09:00 band → same convergence, no second row.
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1);
expect(
(
vi.mocked(prisma.medicationIntakeEvent.update).mock.calls[0][0] as {
where: { id: string };
}
).where.id,
).toBe("evt-pending-0900");
});
it("a skip converges onto the pending REMINDER row as skipped (no orphan + no miss)", async () => {
vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue({
date: "2026-06-10",
timeOfDay: "09:00",
} as never);
const res = await POST(tgRequest(callbackUpdate("skip:med-1")));
expect(res.status).toBe(200);
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1);
const update = vi.mocked(prisma.medicationIntakeEvent.update).mock
.calls[0][0] as {
where: { id: string };
data: { takenAt: Date | null; skipped: boolean };
};
expect(update.where.id).toBe("evt-pending-0900");
expect(update.data.takenAt).toBeNull();
expect(update.data.skipped).toBe(true);
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
evict: true,
});
});
it("an exotic-zone reminder slot reconstructs on the correct local day", async () => {
// Pacific/Kiritimati (UTC+14): 09:00 on 2026-06-10 local is
// 2026-06-09T19:00Z — a naive UTC-noon reference would land a day off.
const slotKiritimati = new Date("2026-06-09T19:00:00.000Z");
const nowKiritimati = new Date("2026-06-09T19:05:00.000Z");
vi.setSystemTime(nowKiritimati);
vi.mocked(prisma.user.findFirst).mockResolvedValue({
...TG_USER,
timezone: "Pacific/Kiritimati",
} as never);
const pendingRow = {
...pendingReminderRow,
scheduledFor: slotKiritimati,
};
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
pendingRow,
] as never);
vi.mocked(prisma.telegramReminderMessage.findFirst).mockResolvedValue({
date: "2026-06-10",
timeOfDay: "09:00",
} as never);
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
expect(res.status).toBe(200);
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1);
const update = vi.mocked(prisma.medicationIntakeEvent.update).mock
.calls[0][0] as { where: { id: string } };
expect(update.where.id).toBe("evt-pending-0900");
});
});
describe("Telegram webhook — GET verification", () => {
it("returns 200 with a valid header secret", async () => {
const req = new NextRequest("http://localhost/api/telegram/webhook", {
+199 -22
View File
@@ -14,6 +14,15 @@ import { annotate, getEvent } from "@/lib/logging/context";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { locales, type Locale } from "@/lib/i18n/config";
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
import {
applyCanonicalSlotWrite,
resolveForcedSlotForWrite,
resolveSlotForWriteByBand,
} from "@/lib/medications/scheduling/slot-upsert";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { localHmAsUtc } from "@/lib/tz/local-day";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
interface TelegramUpdate {
update_id: number;
@@ -123,12 +132,88 @@ async function findTelegramUser(chatId: string) {
});
}
/**
* v1.16.9 — the slot a Telegram action should converge onto.
*
* The reminder context knows its own slot: the dispatcher records every
* reminder message as a `TelegramReminderMessage` row carrying the
* local `date` + `timeOfDay` of the dose it fired for. When the action
* callback carries the chat/message ids, that row names the EXACT slot
* — preferred over band attribution, which would orphan a confirmation
* tapped hours after the reminder (band closed → ad-hoc) even though
* the user is answering a specific dose prompt.
*
* Returns the canonical band anchor (validated against the schedule's
* real slots), or `null` when no reminder row matches / the instant is
* not a real slot — the caller then falls back to band attribution.
*/
async function resolveReminderSlot(input: {
userId: string;
medicationId: string;
userTz: string;
chatId: string;
messageId: number;
}): Promise<Date | null> {
const reminder = await prisma.telegramReminderMessage.findFirst({
where: {
medicationId: input.medicationId,
chatId: input.chatId,
messageId: input.messageId,
},
select: { date: true, timeOfDay: true },
});
if (!reminder) return null;
const instant = reminderLocalInstant(
reminder.date,
reminder.timeOfDay,
input.userTz,
);
if (!instant) return null;
// Validate against the medication's real band anchors so a stale or
// malformed reminder row can never park a dose on a non-slot instant.
return resolveForcedSlotForWrite({
userId: input.userId,
medicationId: input.medicationId,
userTz: input.userTz,
slotInstant: instant,
});
}
/**
* Mint the UTC instant of local `timeOfDay` on the local calendar day
* `date` (YYYY-MM-DD) in `tz`. Probes a reference instant a day either
* side of UTC noon so the result is correct in every zone offset, then
* derives the DST-correct instant via `localHmAsUtc`.
*/
function reminderLocalInstant(
date: string,
timeOfDay: string,
tz: string,
): Date | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !/^\d{2}:\d{2}$/.test(timeOfDay)) {
return null;
}
const [hour, minute] = timeOfDay.split(":").map(Number);
const base = new Date(`${date}T12:00:00.000Z`);
if (Number.isNaN(base.getTime())) return null;
for (const shiftDays of [0, -1, 1]) {
const ref = new Date(base.getTime() + shiftDays * 24 * 60 * 60 * 1000);
const parts = wallClockInTz(ref, tz);
const localDay = `${parts.year}-${String(parts.month).padStart(2, "0")}-${String(
parts.day,
).padStart(2, "0")}`;
if (localDay === date) return localHmAsUtc(ref, tz, hour, minute);
}
return null;
}
async function markMedicationTaken(
userId: string,
medicationId: string,
idempotencyKey: string,
locale: Locale,
userTz: string | null,
reminderRef?: { chatId: string; messageId: number },
): Promise<{ ok: boolean; message: string; medicationName?: string }> {
const { t } = getServerTranslator(locale);
const medication = await prisma.medication.findFirst({
@@ -146,29 +231,77 @@ async function markMedicationTaken(
let scheduledFor: Date | null = null;
if (!existing) {
scheduledFor = new Date();
await prisma.$transaction([
prisma.medicationIntakeEvent.create({
const takenAt = new Date();
const tz = userTz || DEFAULT_TIMEZONE;
// v1.16.9 — converge onto the slot's canonical row instead of bare-
// creating a second row anchored at `now`. The old shape left the
// worker-minted pending REMINDER row open: the slot later auto-missed
// and compliance punished a dose the user explicitly confirmed.
// Prefer the reminder's own slot (the message names the dose it fired
// for); fall back to band attribution on the tap instant.
let canonicalSlot: Date | null = reminderRef
? await resolveReminderSlot({
userId,
medicationId: medication.id,
userTz: tz,
chatId: reminderRef.chatId,
messageId: reminderRef.messageId,
})
: null;
if (canonicalSlot === null) {
const attribution = await resolveSlotForWriteByBand({
userId,
medicationId: medication.id,
userTz: tz,
takenAt,
});
canonicalSlot = attribution.slotInstant;
}
if (canonicalSlot) {
const applied = await applyCanonicalSlotWrite({
client: prisma,
userId,
medicationId: medication.id,
canonicalSlot,
takenAt,
skipped: false,
isExplicitTaken: true,
isExplicitSkip: false,
idempotencyKey,
createSource: "REMINDER",
attributionSource: "AUTO",
});
scheduledFor = applied.row.scheduledFor;
} else {
// Ad-hoc / PRN — standalone row under the documented contract
// (`scheduledFor = takenAt`).
const created = await prisma.medicationIntakeEvent.create({
data: {
userId,
medicationId: medication.id,
scheduledFor,
takenAt: scheduledFor,
scheduledFor: takenAt,
takenAt,
skipped: false,
source: "REMINDER",
idempotencyKey,
},
}),
prisma.medication.update({
where: { id: medication.id },
data: { snoozedUntil: null },
}),
]);
});
scheduledFor = created.scheduledFor;
}
await prisma.medication.update({
where: { id: medication.id },
data: { snoozedUntil: null },
});
// The confirmation must reflect on the next read (today feed, card
// pill, compliance) rather than wait out the cache TTL.
invalidateUserMedications(userId, { evict: true });
}
// v1.4.39 W-MED — refresh the compliance rollup for the
// affected day so the next read after the cache miss reflects the
// new dose. Only fires when the create-path landed a new row.
// new dose. Only fires when the write landed a row.
if (scheduledFor) {
await recomputeMedicationComplianceForEvent({
userId,
@@ -225,6 +358,7 @@ async function handleCallback(update: TelegramUpdate) {
idempotencyKey,
locale,
user.timezone,
messageId !== undefined ? { chatId, messageId } : undefined,
);
await answerTelegramCallbackQuery(botToken, callback.id, result.message);
if (messageId) {
@@ -313,25 +447,68 @@ async function handleCallback(update: TelegramUpdate) {
if (!existing) {
const endOfDay = new Date();
endOfDay.setHours(23, 59, 59, 999);
skippedScheduledFor = new Date();
const skipMoment = new Date();
const tz = user.timezone || DEFAULT_TIMEZONE;
await prisma.$transaction([
prisma.medicationIntakeEvent.create({
// v1.16.9 — a Telegram skip answers a specific dose prompt, so it
// must converge onto the slot's pending row like a take does. The
// old shape inserted a second row anchored at `now` and left the
// pending REMINDER row open to auto-miss — the user's deliberate
// skip recorded as a miss AND an orphan. Prefer the reminder's own
// slot; fall back to the band the skip moment falls in.
let canonicalSlot: Date | null =
messageId !== undefined
? await resolveReminderSlot({
userId: user.id,
medicationId: medication.id,
userTz: tz,
chatId,
messageId,
})
: null;
if (canonicalSlot === null) {
const attribution = await resolveSlotForWriteByBand({
userId: user.id,
medicationId: medication.id,
userTz: tz,
takenAt: skipMoment,
});
canonicalSlot = attribution.slotInstant;
}
if (canonicalSlot) {
const applied = await applyCanonicalSlotWrite({
client: prisma,
userId: user.id,
medicationId: medication.id,
canonicalSlot,
takenAt: null,
skipped: true,
isExplicitTaken: false,
isExplicitSkip: true,
idempotencyKey,
createSource: "REMINDER",
});
skippedScheduledFor = applied.row.scheduledFor;
} else {
const created = await prisma.medicationIntakeEvent.create({
data: {
userId: user.id,
medicationId: medication.id,
scheduledFor: skippedScheduledFor,
scheduledFor: skipMoment,
takenAt: null,
skipped: true,
source: "REMINDER",
idempotencyKey,
},
}),
prisma.medication.update({
where: { id: medication.id },
data: { snoozedUntil: endOfDay },
}),
]);
});
skippedScheduledFor = created.scheduledFor;
}
await prisma.medication.update({
where: { id: medication.id },
data: { snoozedUntil: endOfDay },
});
invalidateUserMedications(user.id, { evict: true });
}
// v1.4.39 W-MED — refresh the compliance rollup for the skipped