merge: measurement-reminder completion route

Add an idempotent, owner-scoped completion endpoint for measurement
reminders that records satisfaction without firing a notification.
This commit is contained in:
Marc-André Bombeck committed 2026-06-18 22:05:22 +02:00
commit 39505a5d35
5 files changed
+321

No files matched your search

+58
View File
@@ -5866,6 +5866,33 @@ paths:
"404": *a7
"422": *a3
"429": *a4
/api/measurement-reminders/{id}/complete:
post:
tags:
- MeasurementReminders
summary: Complete a Vorsorge reminder (v1.18.6)
description: "Explicit user-action completion (iOS #23): the app marks a reminder done server-side instead of only
dismissing it locally. Routes through the same satisfaction primitive as the cron auto-resolve — stamps
lastSatisfiedAt = now, re-anchors nextDueAt, fires no notification. Idempotent: completing an already-completed
/ auto-satisfied reminder returns 200 with completed=false. Owner-scoped."
parameters:
- in: path
name: id
schema:
type: string
required: true
responses:
"200":
description: Completion applied (completed=true) or already-satisfied no-op (completed=false); reminder carries the
canonical post-completion DTO.
content:
application/json:
schema:
$ref: "#/components/schemas/MeasurementReminderCompleteEnvelope"
"401": *a1
"404": *a7
"422": *a3
"429": *a4
/api/labs:
get:
tags:
@@ -18498,6 +18525,37 @@ components:
- data
- error
additionalProperties: false
MeasurementReminderCompleteEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/MeasurementReminderCompletion"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
MeasurementReminderCompletion:
type: object
properties:
completed:
type: boolean
reminder:
$ref: "#/components/schemas/MeasurementReminderDTO"
required:
- completed
- reminder
additionalProperties: false
description: Result of an explicit reminder completion. completed=true when this call advanced lastSatisfiedAt;
completed=false when an earlier satisfy or a matching reading had already fulfilled the current cycle
(idempotent no-op). reminder is the canonical post-completion DTO.
ListLabResultsEnvelope:
type: object
properties:
@@ -0,0 +1,133 @@
/**
* v1.18.6 — explicit-completion route (iOS #23 follow-up).
*
* Covers: marks done via the shared primitive, idempotent no-op surfaces
* completed=false, owner-scoped 404 on a cross-user / tombstoned id, and the
* structural no-double-notify guarantee (the route delegates to
* `satisfyReminder` and never reaches the notification dispatcher).
*/
import { describe, it, expect, vi, beforeEach } 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" } })),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
const satisfyReminderMock = vi.fn();
vi.mock("@/lib/measurement-reminders/satisfy", () => ({
satisfyReminder: (...args: unknown[]) => satisfyReminderMock(...args),
}));
const findFirstMock = vi.fn();
const findUserMock = vi.fn();
const findUniqueOrThrowMock = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
measurementReminder: {
findFirst: (...a: unknown[]) => findFirstMock(...a),
findUniqueOrThrow: (...a: unknown[]) => findUniqueOrThrowMock(...a),
},
user: { findUnique: (...a: unknown[]) => findUserMock(...a) },
},
}));
import { POST } from "../route";
const ROW = {
id: "r1",
userId: "u1",
label: "Blutdruck messen",
measurementType: "BLOOD_PRESSURE_SYS",
intervalDays: 7,
rrule: null,
anchorDate: null,
endsOn: null,
origin: "VORSORGE",
notifyHour: 9,
location: null,
nextDueAt: new Date("2026-06-25T07:00:00Z"),
lastSatisfiedAt: null,
enabled: true,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-18T00:00:00Z"),
deletedAt: null,
};
function makeRequest(): NextRequest {
return new NextRequest("http://localhost/api/measurement-reminders/r1/complete", {
method: "POST",
});
}
const params = { params: Promise.resolve({ id: "r1" }) };
beforeEach(() => {
vi.clearAllMocks();
findUserMock.mockResolvedValue({ timezone: "Europe/Berlin" });
});
describe("POST /api/measurement-reminders/[id]/complete", () => {
it("marks the reminder done via the shared primitive and returns completed=true", async () => {
findFirstMock.mockResolvedValue(ROW);
satisfyReminderMock.mockResolvedValue({
satisfied: true,
nextDueAt: new Date("2026-06-25T07:00:00Z"),
});
findUniqueOrThrowMock.mockResolvedValue({
...ROW,
lastSatisfiedAt: new Date("2026-06-18T08:00:00Z"),
});
const res = await POST(makeRequest(), params);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.error).toBeNull();
expect(body.data.completed).toBe(true);
expect(body.data.reminder.id).toBe("r1");
expect(body.data.reminder.lastSatisfiedAt).toBe("2026-06-18T08:00:00.000Z");
// The route delegates to the ONE shared satisfaction primitive.
expect(satisfyReminderMock).toHaveBeenCalledTimes(1);
});
it("is idempotent: an already-satisfied reminder is a 200 no-op with completed=false", async () => {
findFirstMock.mockResolvedValue({
...ROW,
lastSatisfiedAt: new Date("2026-06-18T08:00:00Z"),
});
// Forward-only guard inside the primitive returns satisfied=false.
satisfyReminderMock.mockResolvedValue({ satisfied: false, nextDueAt: null });
findUniqueOrThrowMock.mockResolvedValue({
...ROW,
lastSatisfiedAt: new Date("2026-06-18T08:00:00Z"),
});
const res = await POST(makeRequest(), params);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.completed).toBe(false);
expect(body.data.reminder.id).toBe("r1");
});
it("is owner-scoped: a cross-user reminder 404s and never satisfies", async () => {
findFirstMock.mockResolvedValue({ ...ROW, userId: "someone-else" });
const res = await POST(makeRequest(), params);
expect(res.status).toBe(404);
expect(satisfyReminderMock).not.toHaveBeenCalled();
});
it("404s a tombstoned / missing reminder", async () => {
findFirstMock.mockResolvedValue(null);
const res = await POST(makeRequest(), params);
expect(res.status).toBe(404);
expect(satisfyReminderMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,81 @@
/**
* v1.18.6 — explicit "complete" for a Vorsorge reminder (iOS #23 follow-up).
*
* The user-action equivalent of the cron auto-satisfy: the iOS app (and web)
* call this when the user actively marks a measurement reminder done, rather
* than only dismissing the notification locally. It routes through the ONE
* shared `satisfyReminder` primitive — the same code the cron auto-resolve
* and the eventful ingest worker use — so it:
*
* - stamps `lastSatisfiedAt = now` and re-anchors the server-authoritative
* `nextDueAt` past now (one engine, no duplicated reschedule logic);
* - fires NO notification of its own (the primitive only writes the row;
* the dispatcher is never invoked on this path);
* - is idempotent via the primitive's forward-only guard — completing an
* already-completed / auto-satisfied reminder is a no-op that still
* returns 200, with `completed: false` so the client can tell the second
* tap apart from the first.
*
* Distinct from `satisfy` only in that the response surfaces the `completed`
* flag the explicit-action flow benefits from; the underlying effect is the
* shared satisfaction primitive either way.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { satisfyReminder } 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) => {
const { user } = await requireAuth();
const { id } = await params;
const existing = await prisma.measurementReminder.findFirst({
where: { id, deletedAt: null },
});
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 now = new Date();
// 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 updated = await prisma.measurementReminder.findUniqueOrThrow({
where: { id },
});
await auditLog("measurementReminder.complete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { reminderId: id, completed: result.satisfied },
});
annotate({
action: { name: "measurement-reminders.complete" },
meta: { reminderId: id, completed: result.satisfied },
});
return apiSuccess({
completed: result.satisfied,
reminder: toMeasurementReminderDto(updated),
});
},
);
@@ -12,6 +12,7 @@ import {
createMeasurementReminderSchema,
updateMeasurementReminderSchema,
measurementReminderDto,
measurementReminderCompletionDto,
} from "@/lib/validations/measurement-reminders";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
@@ -169,4 +170,29 @@ export const measurementReminderPaths: NonNullable<
},
},
},
"/api/measurement-reminders/{id}/complete": {
post: {
tags: ["MeasurementReminders"],
summary: "Complete a Vorsorge reminder (v1.18.6)",
description:
"Explicit user-action completion (iOS #23): the app marks a reminder done server-side instead of only dismissing it locally. Routes through the same satisfaction primitive as the cron auto-resolve — stamps lastSatisfiedAt = now, re-anchors nextDueAt, fires no notification. Idempotent: completing an already-completed / auto-satisfied reminder returns 200 with completed=false. Owner-scoped.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description:
"Completion applied (completed=true) or already-satisfied no-op (completed=false); reminder carries the canonical post-completion DTO.",
content: {
"application/json": {
schema: dataEnvelope(
measurementReminderCompletionDto,
"MeasurementReminderCompleteEnvelope",
),
},
},
},
...reminderNotFound,
...stdResponses,
},
},
},
};
@@ -184,3 +184,26 @@ export const measurementReminderDto = z
description:
"A Vorsorge reminder. nextDueAt is server-computed (server-authoritative). A free-text reminder carries measurementType=null and resolves only on a manual satisfy. origin distinguishes a user-created (VORSORGE) reminder from one minted by a Coach cadence suggestion (COACH); endsOn bounds a finite course window (null = open-ended).",
});
/**
* v1.18.6 — explicit-completion result. The `complete` route is the
* user-action equivalent of the cron auto-satisfy: it stamps
* `lastSatisfiedAt` and re-anchors `nextDueAt` through the SAME shared
* primitive, so it fires no notification of its own and is idempotent.
*
* `completed` reports whether THIS call advanced the reminder (true) or was
* a forward-only no-op because a prior satisfy / matching reading already
* fulfilled the current cycle (false). Either way the response is a 200 so
* a client that completes an already-completed reminder need not special-case
* the second tap. `reminder` carries the canonical post-completion DTO.
*/
export const measurementReminderCompletionDto = z
.object({
completed: z.boolean(),
reminder: measurementReminderDto,
})
.meta({
id: "MeasurementReminderCompletion",
description:
"Result of an explicit reminder completion. completed=true when this call advanced lastSatisfiedAt; completed=false when an earlier satisfy or a matching reading had already fulfilled the current cycle (idempotent no-op). reminder is the canonical post-completion DTO.",
});