From 0a4f00f850bf16a8276a907ec2aa5d05319d9bd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:33:13 +0200 Subject: [PATCH] feat(reminders): CRUD + manual satisfy routes for preventive-care reminders Add /api/measurement-reminders (list, create) and /{id} (get, patch, delete) plus /{id}/satisfy. Every route wraps in apiHandler, parses with Zod, narrows userId from the session, and builds the data object field-by-field. Create and patch recompute the server-authoritative nextDueAt; satisfy stamps lastSatisfiedAt and re-anchors the next-due. Delete soft-deletes for tombstone parity. --- .../api/measurement-reminders/[id]/route.ts | 209 ++++++++++++++++++ .../[id]/satisfy/route.ts | 73 ++++++ src/app/api/measurement-reminders/route.ts | 137 ++++++++++++ 3 files changed, 419 insertions(+) create mode 100644 src/app/api/measurement-reminders/[id]/route.ts create mode 100644 src/app/api/measurement-reminders/[id]/satisfy/route.ts create mode 100644 src/app/api/measurement-reminders/route.ts diff --git a/src/app/api/measurement-reminders/[id]/route.ts b/src/app/api/measurement-reminders/[id]/route.ts new file mode 100644 index 000000000..5813cca22 --- /dev/null +++ b/src/app/api/measurement-reminders/[id]/route.ts @@ -0,0 +1,209 @@ +/** + * v1.17.1 — Vorsorge (measurement) reminder by id: get + patch + delete. + * + * PATCH re-derives the server-authoritative `nextDueAt` after applying + * the (mutually-exclusive cadence) edit. DELETE soft-deletes (tombstone) + * for parity with the rest of the tree. + */ +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, + returnAllZodIssues, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { updateMeasurementReminderSchema } from "@/lib/validations/measurement-reminders"; +import { + computeReminderNextDueAt, + type ReminderScheduleInput, +} from "@/lib/measurement-reminders/scheduling"; +import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; + +type RouteParams = { params: Promise<{ id: string }> }; + +const DEFAULT_TIMEZONE = "Europe/Berlin"; + +async function resolveTimezone(userId: string): Promise { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { timezone: true }, + }); + return row?.timezone || DEFAULT_TIMEZONE; +} + +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const reminder = await prisma.measurementReminder.findFirst({ + where: { id, deletedAt: null }, + }); + if (!reminder || reminder.userId !== user.id) { + return apiError("Measurement reminder not found", 404); + } + + annotate({ + action: { name: "measurement-reminders.get" }, + meta: { reminderId: id }, + }); + + return apiSuccess(toMeasurementReminderDto(reminder)); + }, +); + +export const PATCH = 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 { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = updateMeasurementReminderSchema.safeParse(body); + if (!parsed.success) { + const issues = sanitiseZodIssues(parsed.error.issues); + annotate({ + action: { name: "measurement-reminders.update.validation-failed" }, + meta: { issue_count: issues.length, reminderId: id }, + }); + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "measurement-reminders.update.validation-failed", + details: JSON.stringify({ issues: auditIssues, reminderId: id }), + }, + }) + .catch(() => { + /* swallow — 422 response is the contract */ + }); + return returnAllZodIssues(parsed.error, 422); + } + + const data = parsed.data; + + // Field-by-field — no mass assignment. + const updateData: Record = {}; + if (data.label !== undefined) updateData.label = data.label; + if (data.measurementType !== undefined) { + updateData.measurementType = data.measurementType; + } + if (data.intervalDays !== undefined) { + updateData.intervalDays = data.intervalDays; + // Setting one cadence clears the other so the engine reads exactly + // one dispatch family. + if (data.intervalDays !== null && data.rrule === undefined) { + updateData.rrule = null; + } + } + if (data.rrule !== undefined) { + updateData.rrule = data.rrule; + if (data.rrule !== null && data.intervalDays === undefined) { + updateData.intervalDays = null; + } + } + if (data.anchorDate !== undefined) { + updateData.anchorDate = + data.anchorDate != null ? new Date(data.anchorDate) : null; + } + if (data.notifyHour !== undefined) updateData.notifyHour = data.notifyHour; + if (data.location !== undefined) updateData.location = data.location; + if (data.enabled !== undefined) updateData.enabled = data.enabled; + + // Recompute next-due against the merged cadence. Floor the search at + // the last-satisfied instant (or now) so a cadence edit re-anchors + // cleanly off the user's last fulfilment. + const timezone = await resolveTimezone(user.id); + const now = new Date(); + const merged: ReminderScheduleInput = { + intervalDays: + (updateData.intervalDays as number | null | undefined) ?? + existing.intervalDays, + rrule: (updateData.rrule as string | null | undefined) ?? existing.rrule, + anchorDate: + (updateData.anchorDate as Date | null | undefined) ?? + existing.anchorDate, + notifyHour: + (updateData.notifyHour as number | undefined) ?? existing.notifyHour, + lastSatisfiedAt: existing.lastSatisfiedAt, + createdAt: existing.createdAt, + }; + const after = + existing.lastSatisfiedAt && existing.lastSatisfiedAt > now + ? existing.lastSatisfiedAt + : now; + updateData.nextDueAt = computeReminderNextDueAt(merged, timezone, after); + + const updated = await prisma.measurementReminder.update({ + where: { id }, + data: updateData, + }); + + await auditLog("measurementReminder.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { reminderId: id }, + }); + + annotate({ + action: { name: "measurement-reminders.update" }, + meta: { reminderId: id }, + }); + + return apiSuccess(toMeasurementReminderDto(updated)); + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const existing = await prisma.measurementReminder.findUnique({ + where: { id }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Measurement reminder not found", 404); + } + + // Soft-delete (tombstone) parity with the rest of the tree. A + // re-delete of an already-tombstoned row is idempotent. + await prisma.measurementReminder.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + await auditLog("measurementReminder.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { reminderId: id }, + }); + + annotate({ + action: { name: "measurement-reminders.delete" }, + meta: { reminderId: id }, + }); + + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/measurement-reminders/[id]/satisfy/route.ts b/src/app/api/measurement-reminders/[id]/satisfy/route.ts new file mode 100644 index 000000000..574a88edd --- /dev/null +++ b/src/app/api/measurement-reminders/[id]/satisfy/route.ts @@ -0,0 +1,73 @@ +/** + * v1.17.1 — manual "Erledigt" for a Vorsorge reminder. + * + * Stamps `lastSatisfiedAt = now` and recomputes the server-authoritative + * `nextDueAt` past now. Free-text (no measurementType) reminders resolve + * ONLY through this path; typed reminders auto-resolve in the cron when a + * matching reading lands, but a manual satisfy still works for them. + */ +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 { + computeReminderNextDueAt, + type ReminderScheduleInput, +} from "@/lib/measurement-reminders/scheduling"; +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(); + const scheduleInput: ReminderScheduleInput = { + intervalDays: existing.intervalDays, + rrule: existing.rrule, + anchorDate: existing.anchorDate, + notifyHour: existing.notifyHour, + lastSatisfiedAt: now, + createdAt: existing.createdAt, + }; + const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now); + + const updated = await prisma.measurementReminder.update({ + where: { id }, + data: { lastSatisfiedAt: now, nextDueAt }, + }); + + await auditLog("measurementReminder.satisfy", { + userId: user.id, + ipAddress: getClientIp(request), + details: { reminderId: id }, + }); + + annotate({ + action: { name: "measurement-reminders.satisfy" }, + meta: { reminderId: id }, + }); + + return apiSuccess(toMeasurementReminderDto(updated)); + }, +); diff --git a/src/app/api/measurement-reminders/route.ts b/src/app/api/measurement-reminders/route.ts new file mode 100644 index 000000000..0a27715ba --- /dev/null +++ b/src/app/api/measurement-reminders/route.ts @@ -0,0 +1,137 @@ +/** + * v1.17.1 — Vorsorge (measurement) reminders CRUD: list + create. + * + * Server-authoritative `nextDueAt` is computed via the canonical + * medication recurrence engine so web ↔ iOS read identical numbers. + */ +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, + getClientIp, + returnAllZodIssues, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { withIdempotency } from "@/lib/idempotency"; +import { createMeasurementReminderSchema } from "@/lib/validations/measurement-reminders"; +import { + computeReminderNextDueAt, + type ReminderScheduleInput, +} from "@/lib/measurement-reminders/scheduling"; +import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; + +const DEFAULT_TIMEZONE = "Europe/Berlin"; + +async function resolveTimezone(userId: string): Promise { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { timezone: true }, + }); + return row?.timezone || DEFAULT_TIMEZONE; +} + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const reminders = await prisma.measurementReminder.findMany({ + where: { userId: user.id, deletedAt: null }, + // Most-urgent first; a null next-due (uncomputable / disabled) sinks + // to the end so the actionable items float to the top. + orderBy: [{ nextDueAt: { sort: "asc", nulls: "last" } }, { createdAt: "asc" }], + }); + + annotate({ + action: { name: "measurement-reminders.list" }, + meta: { total: reminders.length }, + }); + + return apiSuccess(reminders.map(toMeasurementReminderDto)); +}); + +async function postReminder(request: NextRequest): Promise { + const { user } = await requireAuth(); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = createMeasurementReminderSchema.safeParse(body); + if (!parsed.success) { + const issues = sanitiseZodIssues(parsed.error.issues); + annotate({ + action: { name: "measurement-reminders.create.validation-failed" }, + meta: { issue_count: issues.length }, + }); + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "measurement-reminders.create.validation-failed", + details: JSON.stringify({ issues: auditIssues }), + }, + }) + .catch(() => { + /* swallow — 422 response is the contract */ + }); + return returnAllZodIssues(parsed.error, 422); + } + + const data = parsed.data; + + const timezone = await resolveTimezone(user.id); + const now = new Date(); + const anchorDate = + data.anchorDate != null ? new Date(data.anchorDate) : null; + + // Server-authoritative next-due. No satisfy yet → anchors on + // anchorDate ?? createdAt (createdAt ≈ now for a fresh row). + const scheduleInput: ReminderScheduleInput = { + intervalDays: data.intervalDays ?? null, + rrule: data.rrule ?? null, + anchorDate, + notifyHour: data.notifyHour, + lastSatisfiedAt: null, + createdAt: now, + }; + const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now); + + // Field-by-field — no mass assignment. + const created = await prisma.measurementReminder.create({ + data: { + userId: user.id, + label: data.label, + measurementType: data.measurementType ?? null, + intervalDays: data.intervalDays ?? null, + rrule: data.rrule ?? null, + anchorDate, + notifyHour: data.notifyHour, + location: data.location ?? null, + enabled: data.enabled, + nextDueAt, + }, + }); + + await auditLog("measurementReminder.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { reminderId: created.id }, + }); + + annotate({ + action: { name: "measurement-reminders.create" }, + meta: { reminderId: created.id, hasType: data.measurementType != null }, + }); + + return apiSuccess(toMeasurementReminderDto(created), 201); +} + +export const POST = apiHandler(withIdempotency<[NextRequest]>(postReminder));