Let a checkup be skipped or pushed back, and remember every outcome

Refs #223. A due Vorsorge item could only be done, deleted or ignored;
Telegram's postpone moved the date with no trace. Both decisions are
first-class now, and both are honest:

- POST /{id}/skip (no body): the interval restarts from the skip instant
  through the recurrence engine, lastSkippedAt is stamped, skipCount
  increments, any snooze clears. lastSatisfiedAt is NEVER touched — done
  and skipped stay distinguishable forever, enforced in the primitive and
  pinned by tests.
- POST /{id}/snooze ({until: YYYY-MM-DD}): the server resolves the day to
  the notifyHour in the profile timezone and moves snoozedUntil and
  nextDueAt to the same instant, so every due-state consumer follows
  without knowing snooze exists. At least tomorrow, at most five years.
- GET /{id}/history: the new append-only completion ledger
  (MeasurementReminderEvent), one row per satisfy from every engine path
  and per skip, onTime derived at write time against the due instant that
  was current when the event landed.

Two anchor fixes close the backdated-event gaps: the auto-resolve floor is
now the later of last satisfy and last skip (an old reading synced late no
longer resolves the cycle the user just skipped), and a backdated real
result re-anchors the next due at the later of its own instant and the
skip decision.

Screenings stay skippable and snoozable — the satisfy-side 409 guards
claimed fulfilment; a skip claims the opposite. Appointment reminders 404
on all three routes, in the lookup, like every by-id sibling. /checkups
gains the postpone sheet (quick chips, date field, the skip zone with its
own confirm) in both view branches, with muted meta lines and no card
colour change. The MCP preventive-care tool carries the resolved fields;
the OpenAPI contract publishes all three paths and the widened DTO.
This commit is contained in:
Marc-André Bombeck committed 2026-08-14 09:23:58 +02:00
1 parent cd12b3886e
commit 5619783648
35 files changed
+3715 -115

No files matched your search

@@ -0,0 +1,314 @@
/**
* v1.37.20 (#223 / iOS #68) — the skip, snooze and history routes.
*
* What this suite pins, per the feature's design:
*
* - 404 for a foreign or ENCOUNTER-origin reminder on all three routes
* (the refusal lives in the lookup, before any write).
* - A SCREENING reminder is skippable and snoozable — deliberately NO 409.
* The satisfy-side 409 guards against CLAIMED fulfilment without an
* assessment; skip and snooze claim the opposite, so refusing them here
* would only push people back to deleting the reminder. This test is the
* pinned boundary between the two rules.
* - Skip: forward-only no-op behind an existing cursor; the write never
* carries `lastSatisfiedAt` (THE invariant); the SKIPPED ledger row.
* - Snooze: the named calendar day resolves to the notifyHour in the
* profile timezone; both cursors move to the SAME instant; a past date
* and a beyond-cap date answer 422; the cadence fields stay untouched.
* - History: paginated newest-first with the meta triple.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
requireRecordAuth: vi.fn(async () => ({
user: { id: "u1", locale: "en" },
actor: { id: "u1", locale: "en" },
grantId: null,
})),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
const findFirstMock = vi.fn();
const updateMock = vi.fn();
const updateManyMock = vi.fn();
const findUniqueOrThrowMock = vi.fn();
const findUserMock = vi.fn();
const eventCreateMock = vi.fn();
const eventFindManyMock = vi.fn();
const eventCountMock = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
measurementReminder: {
findFirst: (...a: unknown[]) => findFirstMock(...a),
update: (...a: unknown[]) => updateMock(...a),
updateMany: (...a: unknown[]) => updateManyMock(...a),
findUniqueOrThrow: (...a: unknown[]) => findUniqueOrThrowMock(...a),
},
measurementReminderEvent: {
create: (...a: unknown[]) => eventCreateMock(...a),
findMany: (...a: unknown[]) => eventFindManyMock(...a),
count: (...a: unknown[]) => eventCountMock(...a),
},
user: { findUnique: (...a: unknown[]) => findUserMock(...a) },
},
}));
import { POST as SKIP } from "../skip/route";
import { POST as SNOOZE } from "../snooze/route";
import { GET as HISTORY } from "../history/route";
const NOW = new Date("2026-06-15T08:00:00.000Z");
const BASE_ROW = {
id: "r1",
userId: "u1",
label: "Blutdruck messen",
measurementType: "BLOOD_PRESSURE_SYS",
intervalDays: 30,
rrule: null as string | null,
anchorDate: null as Date | null,
endsOn: null,
origin: "VORSORGE",
notifyHour: 9,
location: null,
nextDueAt: new Date("2026-06-15T07:00:00Z"),
lastSatisfiedAt: null as Date | null,
snoozedUntil: null as Date | null,
lastSkippedAt: null as Date | null,
skipCount: 0,
enabled: true,
vaccinationAntigen: null,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
deletedAt: null,
};
function params(id = "r1") {
return { params: Promise.resolve({ id }) };
}
function skipRequest(): NextRequest {
return new NextRequest("http://localhost/api/measurement-reminders/r1/skip", {
method: "POST",
});
}
function snoozeRequest(body: unknown): NextRequest {
return new NextRequest(
"http://localhost/api/measurement-reminders/r1/snooze",
{
method: "POST",
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
},
);
}
function historyRequest(query = ""): NextRequest {
return new NextRequest(
`http://localhost/api/measurement-reminders/r1/history${query}`,
{ method: "GET" },
);
}
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
findFirstMock.mockResolvedValue({ ...BASE_ROW });
findUserMock.mockResolvedValue({ timezone: "Europe/Berlin" });
updateManyMock.mockResolvedValue({ count: 1 });
updateMock.mockResolvedValue({ ...BASE_ROW });
findUniqueOrThrowMock.mockResolvedValue({ ...BASE_ROW });
eventCreateMock.mockResolvedValue({ id: "evt-1" });
eventFindManyMock.mockResolvedValue([]);
eventCountMock.mockResolvedValue(0);
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
describe("POST /api/measurement-reminders/[id]/skip", () => {
it("404s a foreign reminder without touching it", async () => {
findFirstMock.mockResolvedValue({ ...BASE_ROW, userId: "someone-else" });
const res = await SKIP(skipRequest(), params());
expect(res.status).toBe(404);
expect(updateManyMock).not.toHaveBeenCalled();
});
it("404s an ENCOUNTER reminder via the lookup filter", async () => {
// The route's findFirst carries `origin: { not: "ENCOUNTER" }`, so the
// DB answers null for an appointment row. Mirror that here.
findFirstMock.mockResolvedValue(null);
const res = await SKIP(skipRequest(), params());
expect(res.status).toBe(404);
const where = findFirstMock.mock.calls[0][0].where;
expect(where.origin).toEqual({ not: "ENCOUNTER" });
expect(updateManyMock).not.toHaveBeenCalled();
});
it("skips a SCREENING reminder — the satisfy-side 409 does not apply", async () => {
findFirstMock.mockResolvedValue({
...BASE_ROW,
measurementType: "PHQ9_SCORE",
});
const res = await SKIP(skipRequest(), params());
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.skipped).toBe(true);
});
it("stamps the skip without ever touching lastSatisfiedAt", async () => {
const res = await SKIP(skipRequest(), params());
expect(res.status).toBe(200);
const { data } = updateManyMock.mock.calls[0][0];
expect(data.lastSkippedAt).toEqual(NOW);
expect(data.skipCount).toEqual({ increment: 1 });
expect(data.snoozedUntil).toBeNull();
expect("lastSatisfiedAt" in data).toBe(false);
// Rolling 30d cadence restarts from the skip instant.
expect(data.nextDueAt.getTime()).toBeGreaterThan(
NOW.getTime() + 29 * 24 * 60 * 60 * 1000,
);
});
it("appends the SKIPPED ledger row", async () => {
await SKIP(skipRequest(), params());
expect(eventCreateMock).toHaveBeenCalledTimes(1);
const { data } = eventCreateMock.mock.calls[0][0];
expect(data).toMatchObject({
userId: "u1",
reminderId: "r1",
kind: "SKIPPED",
source: "skip",
});
});
it("answers skipped=false on a forward-only no-op (double skip)", async () => {
findFirstMock.mockResolvedValue({
...BASE_ROW,
// A cursor already ahead of the clock — the primitive refuses in
// memory, before any write.
lastSkippedAt: new Date("2026-06-16T09:00:00Z"),
});
const res = await SKIP(skipRequest(), params());
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.skipped).toBe(false);
expect(updateManyMock).not.toHaveBeenCalled();
expect(eventCreateMock).not.toHaveBeenCalled();
});
});
describe("POST /api/measurement-reminders/[id]/snooze", () => {
it("404s an ENCOUNTER reminder via the lookup filter", async () => {
findFirstMock.mockResolvedValue(null);
const res = await SNOOZE(snoozeRequest({ until: "2026-09-01" }), params());
expect(res.status).toBe(404);
const where = findFirstMock.mock.calls[0][0].where;
expect(where.origin).toEqual({ not: "ENCOUNTER" });
});
it("snoozes a SCREENING reminder without a 409", async () => {
findFirstMock.mockResolvedValue({
...BASE_ROW,
measurementType: "GAD7_SCORE",
});
const res = await SNOOZE(snoozeRequest({ until: "2026-09-01" }), params());
expect(res.status).toBe(200);
});
it("resolves the named day to the notifyHour in the profile timezone and moves both cursors together", async () => {
const res = await SNOOZE(snoozeRequest({ until: "2026-09-01" }), params());
expect(res.status).toBe(200);
const { data } = updateMock.mock.calls[0][0];
// 2026-09-01 09:00 Europe/Berlin (CEST, UTC+2) = 07:00Z. This is the
// DST-aware wall-clock resolution, not a request-time offset.
const expected = new Date("2026-09-01T07:00:00.000Z");
expect(data.snoozedUntil).toEqual(expected);
expect(data.nextDueAt).toEqual(expected);
// The cadence and its cursors stay untouched.
expect("lastSatisfiedAt" in data).toBe(false);
expect("lastSkippedAt" in data).toBe(false);
expect("intervalDays" in data).toBe(false);
expect("anchorDate" in data).toBe(false);
});
it("422s a past or same-day date", async () => {
const res = await SNOOZE(snoozeRequest({ until: "2026-06-15" }), params());
expect(res.status).toBe(422);
expect(updateMock).not.toHaveBeenCalled();
});
it("422s a date beyond the five-year cap", async () => {
const res = await SNOOZE(snoozeRequest({ until: "2031-07-01" }), params());
expect(res.status).toBe(422);
expect(updateMock).not.toHaveBeenCalled();
});
it("422s a malformed body through the multi-issue envelope", async () => {
const res = await SNOOZE(snoozeRequest({ until: "not-a-date" }), params());
expect(res.status).toBe(422);
});
});
describe("GET /api/measurement-reminders/[id]/history", () => {
it("404s an ENCOUNTER reminder via the lookup filter", async () => {
findFirstMock.mockResolvedValue(null);
const res = await HISTORY(historyRequest(), params());
expect(res.status).toBe(404);
const where = findFirstMock.mock.calls[0][0].where;
expect(where.origin).toEqual({ not: "ENCOUNTER" });
});
it("pages the ledger newest first with the meta triple", async () => {
eventFindManyMock.mockResolvedValue([
{
id: "e2",
kind: "SKIPPED",
occurredAt: new Date("2026-06-10T09:00:00Z"),
onTime: false,
source: "skip",
createdAt: new Date("2026-06-10T09:00:00Z"),
},
{
id: "e1",
kind: "SATISFIED",
occurredAt: new Date("2026-05-10T09:00:00Z"),
onTime: true,
source: "manual",
createdAt: new Date("2026-05-10T09:00:00Z"),
},
]);
eventCountMock.mockResolvedValue(2);
const res = await HISTORY(historyRequest("?limit=10&offset=0"), params());
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.events).toHaveLength(2);
expect(body.data.events[0]).toMatchObject({
id: "e2",
kind: "SKIPPED",
onTime: false,
source: "skip",
});
expect(body.data.meta).toEqual({ total: 2, limit: 10, offset: 0 });
const query = eventFindManyMock.mock.calls[0][0];
expect(query.orderBy).toEqual({ occurredAt: "desc" });
expect(query.where).toEqual({ reminderId: "r1", userId: "u1" });
});
it("422s an out-of-range limit", async () => {
const res = await HISTORY(historyRequest("?limit=1000"), params());
expect(res.status).toBe(422);
});
});
@@ -77,7 +77,13 @@ export const POST = apiHandler(
// Forward-only: a completion strictly after any prior satisfy advances
// the row; a completion at/behind the last satisfy is a no-op. Either
// way no notification is emitted — the primitive only stamps + reschedules.
const result = await satisfyReminder(prisma, existing, timezone, now);
const result = await satisfyReminder(
prisma,
existing,
timezone,
now,
"manual",
);
const updated = await prisma.measurementReminder.findUniqueOrThrow({
where: { id },
@@ -0,0 +1,72 @@
/**
* v1.37.20 (iOS #68) — the completion ledger for one Vorsorge reminder,
* paginated, newest first.
*
* Every satisfy (from any path — manual, auto-resolve, Telegram,
* vaccination booster, visit close) and every skip appends one row via the
* shared primitives; this route only reads. History begins at the release
* that introduced the ledger: the single-cursor engine holds nothing to
* backfill from, which the iOS #68 thread records as agreed.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { listReminderHistorySchema } from "@/lib/validations/measurement-reminders";
type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
// READ — the same level as the reminder list this history hangs off.
const { user } = await requireRecordAuth("read", "measurements");
const { id } = await params;
const searchParams = Object.fromEntries(request.nextUrl.searchParams);
const parsed = listReminderHistorySchema.safeParse(searchParams);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
const { limit, offset } = parsed.data;
// An appointment reminder is not addressable by this family — 404, the
// same refusal every by-id sibling makes.
const existing = await prisma.measurementReminder.findFirst({
where: { id, deletedAt: null, origin: { not: "ENCOUNTER" } },
select: { id: true, userId: true },
});
if (!existing || existing.userId !== user.id) {
return apiError("Measurement reminder not found", 404);
}
const where = { reminderId: id, userId: user.id };
const [rows, total] = await Promise.all([
prisma.measurementReminderEvent.findMany({
where,
orderBy: { occurredAt: "desc" },
take: limit,
skip: offset,
}),
prisma.measurementReminderEvent.count({ where }),
]);
annotate({
action: { name: "measurement-reminders.history" },
meta: { reminderId: id, total },
});
return apiSuccess({
events: rows.map((row) => ({
id: row.id,
kind: row.kind,
occurredAt: row.occurredAt.toISOString(),
onTime: row.onTime,
source: row.source,
createdAt: row.createdAt.toISOString(),
})),
meta: { total, limit, offset },
});
},
);
@@ -189,6 +189,9 @@ export const PATCH = apiHandler(
? existing.lastSatisfiedAt
: now;
updateData.nextDueAt = computeReminderNextDueAt(merged, timezone, after);
// v1.37.20 (#223) — a cadence edit recomputes `nextDueAt`, so it clears
// the snooze cursor: the cycle the snooze pushed back no longer exists.
updateData.snoozedUntil = null;
const updated = await prisma.measurementReminder.update({
where: { id },
@@ -64,7 +64,7 @@ export const POST = apiHandler(
// auto-resolve and the eventful worker use. A manual "Erledigt" is
// always strictly after any prior satisfy, so the forward-only guard
// advances it.
await satisfyReminder(prisma, existing, timezone, now);
await satisfyReminder(prisma, existing, timezone, now, "manual");
const updated = await prisma.measurementReminder.findUniqueOrThrow({
where: { id },
@@ -0,0 +1,77 @@
/**
* v1.37.20 (#223) — honestly skip a Vorsorge reminder's current due cycle.
*
* No body: the skip instant is always the server clock. The interval
* restarts from the skip (rolling: skip + N days at the notify hour; rrule:
* next occurrence strictly after the skip), `lastSkippedAt` is stamped,
* `skipCount` incremented, any snooze cleared, and one SKIPPED row lands in
* the completion ledger. `lastSatisfiedAt` is NEVER touched — a skip is the
* honest alternative to deleting or ignoring a reminder, not a completion.
*
* Screening reminders (PHQ-9 / GAD-7 / WHO-5 / SCI) ARE skippable — the
* satisfy-side 409 guards against CLAIMED fulfilment without an assessment;
* a skip claims the opposite, so refusing it here would only push people
* back to deleting the reminder.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { skipReminder } from "@/lib/measurement-reminders/satisfy";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
type RouteParams = { params: Promise<{ id: string }> };
const DEFAULT_TIMEZONE = "Europe/Berlin";
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
// MANAGE — the same level as satisfy/complete: deciding to let a cycle
// go re-anchors a schedule this level already admits editing.
const { user } = await requireRecordAuth("manage", "measurements");
const { id } = await params;
// An appointment reminder is not addressable here. It belongs to a visit,
// is managed through the visit routes, and sits in a different sharing
// domain — so this family treats one as not found rather than acting on
// it. Skipping an appointment would move a date the visit record still
// believes it owns.
const existing = await prisma.measurementReminder.findFirst({
where: { id, deletedAt: null, origin: { not: "ENCOUNTER" } },
});
if (!existing || existing.userId !== user.id) {
return apiError("Measurement reminder not found", 404);
}
const userRow = await prisma.user.findUnique({
where: { id: user.id },
select: { timezone: true },
});
const timezone = userRow?.timezone || DEFAULT_TIMEZONE;
const result = await skipReminder(prisma, existing, timezone, new Date());
const updated = await prisma.measurementReminder.findUniqueOrThrow({
where: { id },
});
await auditLog("measurementReminder.skip", {
userId: user.id,
ipAddress: getClientIp(request),
details: { reminderId: id, skipped: result.skipped },
});
annotate({
action: { name: "measurement-reminders.skip" },
meta: { reminderId: id, skipped: result.skipped },
});
return apiSuccess({
skipped: result.skipped,
reminder: toMeasurementReminderDto(updated),
});
},
);
@@ -0,0 +1,134 @@
/**
* v1.37.20 (#223) — push a Vorsorge reminder's current due date back to a
* named calendar day.
*
* Body `{ until: "YYYY-MM-DD" }`, day precision on purpose: the server
* resolves the day to the reminder's notifyHour in the profile timezone, so
* no client-supplied instant ever anchors the cadence. Both `snoozedUntil`
* and `nextDueAt` are set to the SAME resolved instant — every due-state
* consumer (digest, dashboard card, cron, AI block) moves with it without
* knowing snooze exists, and the cursor self-expires when the clock passes
* it. The regular interval is untouched: `lastSatisfiedAt`,
* `lastSkippedAt` and the anchor stay where they are, so after the snoozed
* cycle the cadence resumes exactly as configured. Repeated snoozes: the
* last one wins.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
returnAllZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
import { snoozeMeasurementReminderSchema } from "@/lib/validations/measurement-reminders";
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
type RouteParams = { params: Promise<{ id: string }> };
const DEFAULT_TIMEZONE = "Europe/Berlin";
/** Furthest a snooze may reach: five years from now. */
const SNOOZE_CAP_MS = 5 * 365.25 * 24 * 60 * 60 * 1000;
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
// MANAGE — the same level as satisfy/complete: pushing a due date back
// re-anchors a schedule this level already admits editing.
const { user } = await requireRecordAuth("manage", "measurements");
const { id } = await params;
const body = await safeJson(request);
if (body.error) return body.error;
const parsed = snoozeMeasurementReminderSchema.safeParse(body.data);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
// An appointment reminder is not addressable here — see the sibling
// routes; snoozing one would move a date the visit record still owns.
const existing = await prisma.measurementReminder.findFirst({
where: { id, deletedAt: null, origin: { not: "ENCOUNTER" } },
});
if (!existing || existing.userId !== user.id) {
return apiError("Measurement reminder not found", 404);
}
const userRow = await prisma.user.findUnique({
where: { id: user.id },
select: { timezone: true },
});
const timezone = userRow?.timezone || DEFAULT_TIMEZONE;
// Resolve the named local day to its notifyHour instant in the profile
// timezone (DST-settled at the target day, not at the request instant).
const [year, month, day] = parsed.data.until.split("-").map(Number);
const until = zonedWallClockToUtc(
{ year, month, day, hour: existing.notifyHour, minute: 0, second: 0 },
timezone,
);
// Range gate: at least tomorrow (a snooze to today or the past is a
// no-op wearing a confirmation), at most five years out.
const now = new Date();
const startOfTomorrow = zonedWallClockToUtc(
(() => {
const probe = new Date(now.getTime() + 24 * 60 * 60 * 1000);
const parts = new Intl.DateTimeFormat("sv-SE", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
})
.format(probe)
.split("-")
.map(Number);
return {
year: parts[0],
month: parts[1],
day: parts[2],
hour: 0,
minute: 0,
second: 0,
};
})(),
timezone,
);
if (until.getTime() < startOfTomorrow.getTime()) {
return apiError("Snooze date must be at least tomorrow", 422);
}
if (until.getTime() > now.getTime() + SNOOZE_CAP_MS) {
return apiError("Snooze date must be within five years", 422);
}
// One write, both cursors, same instant. lastSatisfiedAt/lastSkippedAt
// and the cadence fields are deliberately untouched.
await prisma.measurementReminder.update({
where: { id },
data: { snoozedUntil: until, nextDueAt: until },
});
const updated = await prisma.measurementReminder.findUniqueOrThrow({
where: { id },
});
await auditLog("measurementReminder.snooze", {
userId: user.id,
ipAddress: getClientIp(request),
details: { reminderId: id, until: until.toISOString() },
});
annotate({
action: { name: "measurement-reminders.snooze" },
meta: { reminderId: id, until: until.toISOString() },
});
return apiSuccess({ reminder: toMeasurementReminderDto(updated) });
},
);