feat(coach): add reminder and suggested-action endpoints

Add the management surface for the reminder memory: list, create, confirm,
done/dismiss, and soft-delete, every query scoped to the session owner with
the same no-IDOR posture as the plans routes (a cross-user or unknown id is
a 0-count no-op, never a leaked existence). The note is the only writable
field; timing is resolved server-side from the closed grammar.

Add the confirm endpoint behind the generalised action card. It switches on
a closed action-type allowlist and builds each entity field-by-field from a
server-resolved spec: a preventive-care reminder whose schedule comes from a
closed interval catalog, or a reminder note. It never auto-applies, and the
allowlist excludes any medication or clinical change.

Regenerate the OpenAPI contract for the new routes.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 13:38:15 +02:00
1 parent 27a8a59d67
commit 6f407388b4
7 files changed
+1450

No files matched your search

+178
View File
@@ -0,0 +1,178 @@
/**
* PATCH /api/coach/reminders/[id] — confirm / update a reminder's lifecycle.
* DELETE /api/coach/reminders/[id] — soft-delete one reminder.
*
* v1.22 (B2/B6) — the user-confirm + management surface for the Coach episodic
* reminders. `status` confirms a `proposed` reminder (→ active), marks it
* `done` / `dismissed`, or re-activates it; `when` re-schedules (snooze) via the
* closed grammar. The body never carries the note text, so a client can never
* inject or overwrite a reminder's prose — only its lifecycle / timing.
*
* Ownership + existence privacy: every mutation is scoped
* `where: { id, userId, deletedAt: null }` via `updateMany`, so a cross-user id,
* an unknown id, or an already-deleted reminder all resolve to a `count: 0`
* no-op rather than a P2025 throw — the existence channel never leaks across
* accounts. PATCH on a 0-count match returns 404; DELETE returns the idempotent
* `{ deleted: false }`.
*/
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { resolveWhenGrammar } from "@/lib/ai/coach/reminders";
import { coachReminderPatchSchema } from "@/lib/validations/coach-reminder";
interface RouteCtx {
params: Promise<{ id: string }>;
}
const MUTATE_RATE_LIMIT = 40;
const MUTATE_WINDOW_MS = 60_000;
async function enforceMutateLimit(
op: string,
userId: string,
): Promise<Response | null> {
const rl = await checkRateLimit(
`coach-reminders:${op}:${userId}`,
MUTATE_RATE_LIMIT,
MUTATE_WINDOW_MS,
);
if (rl.allowed) return null;
const response = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "coach");
if (!gate.enabled) return gate.response;
const limited = await enforceMutateLimit("patch", user.id);
if (limited) return limited;
const { id } = await ctx.params;
const { data: body, error: jsonError } = await safeJson(req, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = coachReminderPatchSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
// Field-by-field (no mass assignment): only the lifecycle / timing fields the
// body carried are written; the note + encrypted text are never touched here.
const data: {
status?: string;
dueAt?: Date | null;
triggerKind?: string;
contextCue?: string | null;
} = {};
if (parsed.data.status !== undefined) data.status = parsed.data.status;
if (parsed.data.when !== undefined) {
if (parsed.data.when === null) {
data.dueAt = null;
data.triggerKind = "date";
data.contextCue = null;
} else {
const trigger = resolveWhenGrammar(parsed.data.when, new Date());
if (!trigger) return apiError("coach.reminder.invalidWhen", 422);
data.dueAt = trigger.dueAt;
data.triggerKind = trigger.triggerKind;
data.contextCue = trigger.contextCue;
}
}
const { count } = await prisma.coachReminder.updateMany({
where: { id, userId: user.id, deletedAt: null },
data,
});
if (count === 0) return apiError("Reminder not found", 404);
const row = await prisma.coachReminder.findFirst({
where: { id, userId: user.id },
select: {
id: true,
metric: true,
noteEncrypted: true,
triggerKind: true,
dueAt: true,
contextCue: true,
status: true,
source: true,
createdAt: true,
updatedAt: true,
},
});
if (!row) return apiError("Reminder not found", 404);
let note: string | null = null;
try {
note = decryptFromBytes(row.noteEncrypted);
} catch {
note = null;
}
annotate({
action: { name: "coach.reminders.updated" },
meta: { status: row.status },
});
return apiSuccess({
reminder: {
id: row.id,
note,
metric: row.metric,
triggerKind: row.triggerKind,
dueAt: row.dueAt?.toISOString() ?? null,
contextCue: row.contextCue,
status: row.status,
source: row.source,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
},
});
});
export const DELETE = apiHandler(
async (_request: NextRequest, ctx: RouteCtx) => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "coach");
if (!gate.enabled) return gate.response;
const limited = await enforceMutateLimit("delete", user.id);
if (limited) return limited;
const { id } = await ctx.params;
const { count } = await prisma.coachReminder.updateMany({
where: { id, userId: user.id, deletedAt: null },
data: { deletedAt: new Date() },
});
const deleted = count > 0;
annotate({
action: { name: "coach.reminders.deleted" },
meta: { deleted },
});
return apiSuccess({ deleted });
},
);
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
+202
View File
@@ -0,0 +1,202 @@
/**
* GET /api/coach/reminders — list the caller's Coach episodic reminders.
* POST /api/coach/reminders — create one manually (the ledger "+ add" path).
*
* v1.22 (B2/B6) — the management + in-app-surface read for the durable
* "remind me about X" memory the Coach captures inline via the `---REMEMBER---`
* sentinel. The note free-text is decrypted on the fly; an undecryptable row (a
* key rotated out of the map) is skipped rather than 500ing the whole list.
*
* Ownership: every query is scoped `where: { userId, ... }`, so a caller can
* only ever see / create their own reminders. The owner is always narrowed from
* the session, never the body. Coach-gated by the same
* `requireModuleEnabled(userId, "coach")` kill-switch as the rest of the Coach
* management stack.
*
* `?status=` optionally filters (one status, or a comma set like `due,surfaced`
* for the in-app tile). Omitted returns the non-terminal set (proposed / active
* / due / surfaced), soonest-due first.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit } from "@/lib/rate-limit";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
resolveWhenGrammar,
MAX_REMINDERS_PER_USER,
} from "@/lib/ai/coach/reminders";
import {
coachRemindersListQuerySchema,
coachReminderCreateSchema,
} from "@/lib/validations/coach-reminder";
const NON_TERMINAL = ["proposed", "active", "due", "surfaced"] as const;
interface ReminderRow {
id: string;
metric: string | null;
noteEncrypted: Uint8Array;
triggerKind: string;
dueAt: Date | null;
contextCue: string | null;
status: string;
source: string;
createdAt: Date;
updatedAt: Date;
}
function toDto(row: ReminderRow, note: string) {
return {
id: row.id,
note,
metric: row.metric,
triggerKind: row.triggerKind,
dueAt: row.dueAt?.toISOString() ?? null,
contextCue: row.contextCue,
status: row.status,
source: row.source,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
export const GET = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "coach");
if (!gate.enabled) return gate.response;
const url = new URL(req.url);
const parsed = coachRemindersListQuerySchema.safeParse({
status: url.searchParams.get("status") ?? undefined,
});
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const statusWhere = parsed.data.status
? { status: { in: parsed.data.status } }
: { status: { in: [...NON_TERMINAL] } };
const rows = (await prisma.coachReminder.findMany({
where: { userId: user.id, deletedAt: null, ...statusWhere },
orderBy: [{ dueAt: "asc" }, { updatedAt: "desc" }],
select: {
id: true,
metric: true,
noteEncrypted: true,
triggerKind: true,
dueAt: true,
contextCue: true,
status: true,
source: true,
createdAt: true,
updatedAt: true,
},
})) as ReminderRow[];
const reminders: ReturnType<typeof toDto>[] = [];
for (const row of rows) {
let note: string;
try {
note = decryptFromBytes(row.noteEncrypted);
} catch {
// Fail closed per row — never surface ciphertext, never 500 the list.
continue;
}
reminders.push(toDto(row, note));
}
annotate({
action: { name: "coach.reminders.listed" },
meta: { count: reminders.length },
});
return apiSuccess({ reminders });
});
export const POST = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "coach");
if (!gate.enabled) return gate.response;
const rate = await checkRateLimit(
`coach-reminders:create:${user.id}`,
30,
60_000,
);
if (!rate.allowed) return apiError("Too many requests", 429);
const { data: body, error: jsonError } = await safeJson(req, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = coachReminderCreateSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
// Resolve the optional `when` against the closed grammar; an invalid token
// 422s rather than silently dropping the timing the user asked for.
const now = new Date();
let trigger: ReturnType<typeof resolveWhenGrammar> = null;
if (parsed.data.when) {
trigger = resolveWhenGrammar(parsed.data.when, now);
if (!trigger) return apiError("coach.reminder.invalidWhen", 422);
}
// Per-user cap on the non-terminal set.
const nonTerminal = await prisma.coachReminder.count({
where: {
userId: user.id,
deletedAt: null,
status: { in: [...NON_TERMINAL] },
},
});
if (nonTerminal >= MAX_REMINDERS_PER_USER) {
return apiError("coach.reminder.capReached", 409);
}
// Field-by-field — no mass assignment. Manual reminders are active on create.
const created = await prisma.coachReminder.create({
data: {
userId: user.id,
noteEncrypted: encryptToBytes(parsed.data.note),
metric: parsed.data.metric ?? null,
triggerKind: trigger?.triggerKind ?? "date",
dueAt: trigger?.dueAt ?? null,
contextCue: trigger?.contextCue ?? null,
status: "active",
source: "manual",
},
select: {
id: true,
metric: true,
noteEncrypted: true,
triggerKind: true,
dueAt: true,
contextCue: true,
status: true,
source: true,
createdAt: true,
updatedAt: true,
},
});
annotate({
action: { name: "coach.reminders.created" },
meta: { triggerKind: created.triggerKind, hasDue: created.dueAt != null },
});
return apiSuccess(
{ reminder: toDto(created as ReminderRow, parsed.data.note) },
201,
);
});
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
@@ -0,0 +1,188 @@
/**
* POST /api/coach/suggested-actions — confirm a generalised Coach action card.
*
* v1.22 (F6) — the confirm half of the generalised propose→confirm moat. The
* Coach proposes an action via the closed `---SUGGEST-ACTION---` sentinel →
* `suggestedAction` SSE frame → `<SuggestedActionCard>`. This endpoint is what
* the card's confirm button calls. It switches on the CLOSED `actionType`
* allowlist and, for each, builds the entity FIELD-BY-FIELD from a
* server-resolved spec:
*
* - `checkup.create` → a preventive-care Vorsorge `MeasurementReminder`
* (free-text label + a closed interval id the SERVER resolves to an RRULE),
* minted through the SAME engine the Vorsorge surface uses.
* - `reminder.note` → a `CoachReminder` (note + optional closed `when`
* grammar + optional metric).
*
* HARD SAFETY: never auto-apply (this endpoint runs only on the explicit
* confirm tap); closed allowlist only (the discriminated-union Zod gate rejects
* any other `actionType`); NEVER a medication / dose / clinical-target change.
* Cookie-auth, coach module-gated, per-user rate-limited, owner-scoped — no
* IDOR, no mass assignment.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
resolveWhenGrammar,
MAX_REMINDERS_PER_USER,
} from "@/lib/ai/coach/reminders";
import { CHECKUP_INTERVAL_CATALOG } from "@/lib/ai/coach/suggest-action";
import { coachSuggestedActionSchema } from "@/lib/validations/coach-reminder";
import {
computeReminderNextDueAt,
type ReminderScheduleInput,
} from "@/lib/measurement-reminders/scheduling";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
const DEFAULT_TIMEZONE = "Europe/Berlin";
const CHECKUP_NOTIFY_HOUR = 9;
async function postAction(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "coach");
if (!gate.enabled) return gate.response;
const rate = await checkRateLimit(
`coach:suggested-action:${user.id}`,
30,
60_000,
);
if (!rate.allowed) return apiError("coach.action.rateLimited", 429);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = coachSuggestedActionSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const action = parsed.data;
// ── checkup.create → a preventive-care Vorsorge reminder ─────
if (action.actionType === "checkup.create") {
// The model named an interval id; the SERVER owns the RRULE so the model
// can never mint an arbitrary schedule.
const preset = CHECKUP_INTERVAL_CATALOG[action.interval];
if (!preset) return apiError("coach.action.unknownInterval", 422);
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { timezone: true },
});
const timezone = row?.timezone || DEFAULT_TIMEZONE;
const now = new Date();
const scheduleInput: ReminderScheduleInput = {
intervalDays: null,
rrule: preset.rrule,
anchorDate: null,
notifyHour: CHECKUP_NOTIFY_HOUR,
lastSatisfiedAt: null,
createdAt: now,
endsOn: null,
};
const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now);
// Field-by-field — no mass assignment. A free-text checklist Vorsorge
// (measurementType null) so it resolves only on a manual "done". origin
// COACH marks the provenance.
const created = await prisma.measurementReminder.create({
data: {
userId: user.id,
label: action.label,
measurementType: null,
intervalDays: null,
rrule: preset.rrule,
anchorDate: null,
endsOn: null,
origin: "COACH",
notifyHour: CHECKUP_NOTIFY_HOUR,
location: null,
enabled: true,
nextDueAt,
},
});
await auditLog("measurementReminder.create.coachAction", {
userId: user.id,
ipAddress: getClientIp(request),
details: { reminderId: created.id, interval: action.interval },
});
annotate({
action: { name: "coach.action.applied" },
meta: { actionType: action.actionType, interval: action.interval },
});
return apiSuccess(
{
ok: true,
actionType: action.actionType,
reminder: toMeasurementReminderDto(created),
},
201,
);
}
// ── reminder.note → a CoachReminder ──────────────────────────
const now = new Date();
let trigger: ReturnType<typeof resolveWhenGrammar> = null;
if (action.when) {
trigger = resolveWhenGrammar(action.when, now);
if (!trigger) return apiError("coach.reminder.invalidWhen", 422);
}
const nonTerminal = await prisma.coachReminder.count({
where: {
userId: user.id,
deletedAt: null,
status: { in: ["proposed", "active", "due", "surfaced"] },
},
});
if (nonTerminal >= MAX_REMINDERS_PER_USER) {
return apiError("coach.reminder.capReached", 409);
}
const created = await prisma.coachReminder.create({
data: {
userId: user.id,
noteEncrypted: encryptToBytes(action.note),
metric: action.metric ?? null,
triggerKind: trigger?.triggerKind ?? "date",
dueAt: trigger?.dueAt ?? null,
contextCue: trigger?.contextCue ?? null,
status: "active",
source: "action",
},
select: { id: true },
});
annotate({
action: { name: "coach.action.applied" },
meta: {
actionType: action.actionType,
hasDue: trigger?.dueAt != null,
},
});
return apiSuccess(
{ ok: true, actionType: action.actionType, reminderId: created.id },
201,
);
}
export const POST = apiHandler(postAction);
export const dynamic = "force-dynamic";
export const runtime = "nodejs";