mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
placeholderData keeps the previous range painted while a health-chart range switch fetches, paired with its truth condition: the container dims and goes inert while isPlaceholderData is true, so the old range cannot masquerade as the new one. The mood chart needs no pairing — its range is a client-side slice over one static-key query and never drops to a skeleton. /checkups and /mood gain the dashboard-template RSC prefetch: the primary list read moves into a shared lib read (route + wrapper consume one query, so filter/order/wire shape cannot drift), the wrapper honours the DASHBOARD_SSR_PREFETCH kill-switch, resolves identity through getUnswitchedSession, JSON-round-trips the dehydrated state, and fails soft to the bare client on every error path. Page-prefetch unit tests pin the seeded keys and the fail-soft arms; the SSR hydration spec gains an assertion per page. The achievement-unlock poll drops to five minutes (in-app writes already invalidate the cell; the poll only covers out-of-band ingest), and the plan-proposal poll gains the advisor-style bounded attempt ceiling so an open thread stops polling after its budget.
137 lines
4.6 KiB
TypeScript
137 lines
4.6 KiB
TypeScript
/**
|
|
* 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, requireRecordAuth } 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";
|
|
import { listMeasurementReminders } from "@/lib/measurement-reminders/list-read";
|
|
|
|
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 requireRecordAuth("read", "measurements");
|
|
|
|
// The query (with its ENCOUNTER-origin exclusion) lives in the shared
|
|
// list read so this route and the `/checkups` SSR prefetch cannot drift.
|
|
const reminders = await listMeasurementReminders(user.id);
|
|
|
|
annotate({
|
|
action: { name: "measurement-reminders.list" },
|
|
meta: { total: reminders.length },
|
|
});
|
|
|
|
return apiSuccess(reminders);
|
|
});
|
|
|
|
async function postReminder(request: NextRequest): Promise<Response> {
|
|
// v1.37.0 — MANAGE. A reminder armed by a manager rings the record's own
|
|
// phone, which is the person the reminder is about, and it is additive: the
|
|
// row is listed, editable and removable by the owner.
|
|
const { user } = await requireRecordAuth("manage", "measurements");
|
|
|
|
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,
|
|
});
|
|
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
|
|
// .create`, because that helper is the only writer that stamps
|
|
// `actorUserId`. Filed under the resolved record either way; without the
|
|
// stamp a manager's malformed payload would read as the owner's own.
|
|
void auditLog("measurement-reminders.create.validation-failed", {
|
|
userId: user.id,
|
|
details: { 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));
|