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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 23:33:13 +02:00
1 parent 988ba1196e
commit 0a4f00f850
3 files changed
+419

No files matched your search

@@ -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<string> {
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<string, unknown> = {};
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 });
},
);
@@ -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));
},
);
+137
View File
@@ -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<string> {
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<Response> {
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));