diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3b6ddd195..b632c1c2b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/src/app/api/measurement-reminders/[id]/complete/__tests__/route.test.ts b/src/app/api/measurement-reminders/[id]/complete/__tests__/route.test.ts new file mode 100644 index 000000000..78778d154 --- /dev/null +++ b/src/app/api/measurement-reminders/[id]/complete/__tests__/route.test.ts @@ -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: 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(); + }); +}); diff --git a/src/app/api/measurement-reminders/[id]/complete/route.ts b/src/app/api/measurement-reminders/[id]/complete/route.ts new file mode 100644 index 000000000..408bf12c8 --- /dev/null +++ b/src/app/api/measurement-reminders/[id]/complete/route.ts @@ -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), + }); + }, +); diff --git a/src/lib/openapi/routes/measurement-reminders.ts b/src/lib/openapi/routes/measurement-reminders.ts index 47952eec3..796c87fd5 100644 --- a/src/lib/openapi/routes/measurement-reminders.ts +++ b/src/lib/openapi/routes/measurement-reminders.ts @@ -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, + }, + }, + }, }; diff --git a/src/lib/validations/measurement-reminders.ts b/src/lib/validations/measurement-reminders.ts index fc4f55b50..3c17f2ea4 100644 --- a/src/lib/validations/measurement-reminders.ts +++ b/src/lib/validations/measurement-reminders.ts @@ -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.", + });