mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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));
|
||||
},
|
||||
);
|
||||
@@ -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));
|
||||
Reference in new issue
Block a user