diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1138e83df..c5fbb84ec 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4836,6 +4836,189 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + /api/coach/reminders: + get: + tags: + - Insights + summary: List the caller's Coach reminders + description: v1.22 (B2/B6) — the durable "remind me about X" memory the Coach captured inline, decrypted on the fly, + soonest-due first. Pass `?status=` (one status or a comma set like `due,surfaced` for the in-app tile); omitted + returns the non-terminal set (proposed + active + due + surfaced). Coach-gated + (`requireModuleEnabled("coach")`). Auth via cookie or Bearer; the owner is narrowed from the session. + Undecryptable rows are omitted. + parameters: + - name: status + in: query + required: false + schema: + type: string + description: Filter to one status or a comma-separated set (proposed | active | due | surfaced | done | dismissed). Omit + for the non-terminal set. + responses: + "200": + description: The caller's reminders. + content: + application/json: + schema: + $ref: "#/components/schemas/CoachRemindersList" + "401": *a1 + "403": + description: Coach surface disabled. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + post: + tags: + - Insights + summary: Create a Coach reminder manually + description: v1.22 (B6) — create a reminder from the ledger. `note` is the only writable content; `when` is the closed + grammar (ISO date | +Nd / +Nw | a context cue) resolved into the trigger server-side; `metric` optional. The + per-user cap returns 409 when the non-terminal set is full. Coach-gated. Auth via cookie or Bearer; the owner is + narrowed from the session. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CoachReminderCreate" + responses: + "201": + description: The created reminder. + content: + application/json: + schema: + $ref: "#/components/schemas/CoachReminderCreated" + "401": *a1 + "403": + description: Coach surface disabled. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: The per-user reminder cap is full. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/coach/reminders/{id}: + patch: + tags: + - Insights + summary: Confirm or update a Coach reminder's lifecycle + description: v1.22 (B6) — confirm a proposed reminder (→ active), mark it done / dismissed, or re-schedule via the + closed `when` grammar (null clears the due moment). The body never carries the note text. A foreign / unknown / + already-deleted id maps to 404 (never 403) so the existence channel does not leak. Coach-gated. Auth via cookie + or Bearer. + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Reminder id. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CoachReminderPatch" + responses: + "200": + description: The reminder after the lifecycle update. + content: + application/json: + schema: + $ref: "#/components/schemas/CoachReminderUpdated" + "401": *a1 + "403": + description: Coach surface disabled. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "404": + description: Reminder not found or not owned by the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + delete: + tags: + - Insights + summary: Soft-delete one Coach reminder + description: "v1.22 (B6) — soft-deletes a single reminder owned by the caller. An unknown / cross-user / already-deleted + id is an idempotent no-op returning `{ deleted: false }`. Coach-gated. Auth via cookie or Bearer." + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Reminder id. + responses: + "200": + description: "The reminder was soft-deleted (`deleted: true`) or the id matched nothing the caller owns (`deleted: + false`)." + content: + application/json: + schema: + $ref: "#/components/schemas/CoachReminderDeleted" + "401": *a1 + "403": + description: Coach surface disabled. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/coach/suggested-actions: + post: + tags: + - Insights + summary: Confirm a Coach action card + description: v1.22 (F6) — the confirm half of the generalised propose→confirm moat. `actionType` is from the CLOSED + allowlist (checkup.create | reminder.note) — NEVER a medication or clinical change. `checkup.create` builds a + preventive-care Vorsorge `MeasurementReminder` (free-text label + a closed interval id resolved to an RRULE + server-side); `reminder.note` builds a `CoachReminder`. Nothing is created without this explicit confirm. + Coach-gated (`requireModuleEnabled("coach")`); per-user rate-limited (429). Auth via cookie or Bearer; the owner + is narrowed from the session, built field-by-field — no mass assignment, no IDOR. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CoachSuggestedAction" + responses: + "201": + description: The action was applied (entity created server-side). + content: + application/json: + schema: + $ref: "#/components/schemas/CoachSuggestedActionResultCreated" + "401": *a1 + "403": + description: Coach surface disabled. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: The per-user reminder cap is full (reminder.note). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 /api/admin/invites: get: tags: @@ -8538,6 +8721,99 @@ components: compatibility and are normalised to their English equivalents before persisting; GET responses always carry the canonical English ids. Section ids are English-only. The legacy tile ids are deprecated and will be removed in a future major version." + CoachReminderCreate: + type: object + properties: + note: + type: string + minLength: 1 + maxLength: 280 + when: + type: string + minLength: 1 + maxLength: 32 + metric: + type: string + minLength: 1 + maxLength: 60 + pattern: ^[A-Za-z][A-Za-z0-9_]*$ + required: + - note + additionalProperties: false + description: "Create a Coach reminder manually. `note` is free text (the only writable content). `when` is an optional + closed grammar token — an ISO date (YYYY-MM-DD), a relative offset (+Nd / +Nw), or a context cue + (NEXT_BP_LOGGED, NEXT_WEIGHT_LOGGED, NEXT_SLEEP_LOGGED, NEXT_APP_OPEN) — resolved into the trigger server-side. + Omit `when` for a recall-only note. Strict: unknown keys 422." + CoachReminderPatch: + type: object + properties: + status: + type: string + enum: + - proposed + - active + - due + - surfaced + - done + - dismissed + when: + anyOf: + - type: string + minLength: 1 + maxLength: 32 + - type: "null" + additionalProperties: false + description: "Update a Coach reminder's lifecycle. `status` confirms (proposed → active), or marks done / dismissed. + `when` re-schedules via the closed grammar (null clears the due moment). Never carries the note text. Strict: + unknown keys 422." + CoachSuggestedAction: + oneOf: + - type: object + properties: + actionType: + type: string + const: checkup.create + label: + type: string + minLength: 1 + maxLength: 120 + interval: + type: string + enum: + - yearly + - halfYearly + - quarterly + - monthly + required: + - actionType + - label + - interval + - type: object + properties: + actionType: + type: string + const: reminder.note + note: + type: string + minLength: 1 + maxLength: 280 + when: + type: string + minLength: 1 + maxLength: 32 + metric: + type: string + minLength: 1 + maxLength: 60 + pattern: ^[A-Za-z][A-Za-z0-9_]*$ + required: + - actionType + - note + description: Confirm a Coach action card. `actionType` is from the closed allowlist (checkup.create, reminder.note); + NEVER a medication or clinical change. `checkup.create` builds a preventive-care Vorsorge reminder (free-text + label + a closed interval id resolved to an RRULE server-side); `reminder.note` builds a CoachReminder (note + + optional closed `when` grammar + optional metric). Nothing is created without this explicit confirm. + type: object CoachMessageFeedbackRequest: type: object properties: @@ -17064,6 +17340,208 @@ components: - data - error additionalProperties: false + CoachRemindersList: + type: object + properties: + data: + type: object + properties: + reminders: + type: array + items: + $ref: "#/components/schemas/CoachReminder" + description: The caller's reminders, soonest-due first. Undecryptable rows are omitted. No `status` filter returns the + non-terminal set (proposed + active + due + surfaced). + required: + - reminders + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CoachReminder: + type: object + properties: + id: + type: string + note: + anyOf: + - type: string + - type: "null" + description: Decrypted note (the user's own framing). Null when the row's key id is no longer in the map. + metric: + anyOf: + - type: string + - type: "null" + triggerKind: + type: string + enum: + - date + - context + dueAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + contextCue: + anyOf: + - type: string + - type: "null" + status: + type: string + enum: + - proposed + - active + - due + - surfaced + - done + - dismissed + source: + type: string + enum: + - sentinel + - extractor + - manual + - action + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - id + - note + - metric + - triggerKind + - dueAt + - contextCue + - status + - source + - createdAt + - updatedAt + additionalProperties: false + description: One Coach episodic reminder, decrypted server-side. The note reads null only when the encryption key id has + rotated out of the map. + CoachReminderCreated: + type: object + properties: + data: + type: object + properties: + reminder: + $ref: "#/components/schemas/CoachReminder" + required: + - reminder + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CoachReminderUpdated: + type: object + properties: + data: + type: object + properties: + reminder: + $ref: "#/components/schemas/CoachReminder" + required: + - reminder + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CoachReminderDeleted: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + description: True when a reminder owned by the caller was soft-deleted; false for an unknown / cross-user / + already-deleted id (idempotent no-op). + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CoachSuggestedActionResultCreated: + type: object + properties: + data: + $ref: "#/components/schemas/CoachSuggestedActionResult" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CoachSuggestedActionResult: + type: object + properties: + ok: + type: boolean + const: true + actionType: + type: string + enum: + - checkup.create + - reminder.note + reminder: {} + reminderId: + type: string + required: + - ok + - actionType + additionalProperties: false + description: Outcome of confirming a Coach action card. `checkup.create` returns the created MeasurementReminder DTO; + `reminder.note` returns the created CoachReminder id. AdminInviteList: type: object properties: diff --git a/src/app/api/coach/reminders/[id]/route.ts b/src/app/api/coach/reminders/[id]/route.ts new file mode 100644 index 000000000..8c702a04b --- /dev/null +++ b/src/app/api/coach/reminders/[id]/route.ts @@ -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 { + 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"; diff --git a/src/app/api/coach/reminders/route.ts b/src/app/api/coach/reminders/route.ts new file mode 100644 index 000000000..c69985795 --- /dev/null +++ b/src/app/api/coach/reminders/route.ts @@ -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[] = []; + 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 = 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"; diff --git a/src/app/api/coach/suggested-actions/route.ts b/src/app/api/coach/suggested-actions/route.ts new file mode 100644 index 000000000..8302bc33c --- /dev/null +++ b/src/app/api/coach/suggested-actions/route.ts @@ -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 → ``. 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 { + 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 = 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"; diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts index b74c987bd..fb03aa650 100644 --- a/src/lib/openapi/routes/coach.ts +++ b/src/lib/openapi/routes/coach.ts @@ -22,6 +22,12 @@ import { coachChatRequestSchema } from "@/lib/ai/coach/types"; import { exportSelectionSchema } from "@/lib/validations/health-record-export"; import { createShareLinkSchema } from "@/lib/validations/clinician-share-link"; import { coachReminderSuggestionActionSchema } from "@/lib/validations/coach-reminder-suggestion"; +import { COACH_REMINDER_STATUSES } from "@/lib/ai/coach/reminders"; +import { + coachReminderCreateSchema, + coachReminderPatchSchema, + coachSuggestedActionSchema, +} from "@/lib/validations/coach-reminder"; import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; // ── Coach cadence suggestions (v1.18.1) ────────────────────────────── @@ -316,6 +322,70 @@ coachPlansListQuerySchema.meta({ "Optional `?status=` filter for the plans list (proposed | active | met | abandoned). Omitted returns the non-terminal set (proposed + active).", }); +// ── Coach episodic reminders (v1.22 B2/B6) ────────────────────────────── +const coachReminderItem = z + .object({ + id: z.string(), + note: z + .string() + .nullable() + .describe( + "Decrypted note (the user's own framing). Null when the row's key id is no longer in the map.", + ), + metric: z.string().nullable(), + triggerKind: z.enum(["date", "context"]), + dueAt: z.iso.datetime({ offset: true }).nullable(), + contextCue: z.string().nullable(), + status: z.enum(COACH_REMINDER_STATUSES), + source: z.enum(["sentinel", "extractor", "manual", "action"]), + createdAt: z.iso.datetime({ offset: true }), + updatedAt: z.iso.datetime({ offset: true }), + }) + .meta({ + id: "CoachReminder", + description: + "One Coach episodic reminder, decrypted server-side. The note reads null only when the encryption key id has rotated out of the map.", + }); + +const coachRemindersListResponse = z.object({ + reminders: z + .array(coachReminderItem) + .describe( + "The caller's reminders, soonest-due first. Undecryptable rows are omitted. No `status` filter returns the non-terminal set (proposed + active + due + surfaced).", + ), +}); + +const coachReminderCreatedResponse = z.object({ + reminder: coachReminderItem, +}); + +const coachReminderUpdatedResponse = z.object({ + reminder: coachReminderItem, +}); + +const coachReminderDeletedResponse = z.object({ + deleted: z + .boolean() + .describe( + "True when a reminder owned by the caller was soft-deleted; false for an unknown / cross-user / already-deleted id (idempotent no-op).", + ), +}); + +const coachSuggestedActionResultResponse = z + .object({ + ok: z.literal(true), + actionType: z.enum(["checkup.create", "reminder.note"]), + reminder: z.unknown().optional(), + reminderId: z.string().optional(), + }) + .meta({ + id: "CoachSuggestedActionResult", + description: + "Outcome of confirming a Coach action card. `checkup.create` returns the created MeasurementReminder DTO; `reminder.note` returns the created CoachReminder id.", + }); +// The request bodies carry their `.meta({ id })` from +// `@/lib/validations/coach-reminder` (single-source with the runtime parse). + // ── Coach conversation history (v1.18.0) ───────────────────────────── // List + detail + delete surface for the Coach's persisted chat // conversations. Bodies are stored encrypted at rest; the detail @@ -1052,6 +1122,198 @@ export const coachPaths: NonNullable = { }, }; +export const coachReminderPaths: NonNullable = { + "/api/coach/reminders": { + get: { + tags: ["Insights"], + summary: "List the caller's Coach reminders", + description: + 'v1.22 (B2/B6) — the durable "remind me about X" memory the Coach captured inline, decrypted on the fly, soonest-due first. Pass `?status=` (one status or a comma set like `due,surfaced` for the in-app tile); omitted returns the non-terminal set (proposed + active + due + surfaced). Coach-gated (`requireModuleEnabled("coach")`). Auth via cookie or Bearer; the owner is narrowed from the session. Undecryptable rows are omitted.', + parameters: [ + { + name: "status", + in: "query", + required: false, + schema: { type: "string" }, + description: + "Filter to one status or a comma-separated set (proposed | active | due | surfaced | done | dismissed). Omit for the non-terminal set.", + }, + ], + responses: { + "200": { + description: "The caller's reminders.", + content: { + "application/json": { + schema: dataEnvelope( + coachRemindersListResponse, + "CoachRemindersList", + ), + }, + }, + }, + "403": { + description: "Coach surface disabled.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + post: { + tags: ["Insights"], + summary: "Create a Coach reminder manually", + description: + "v1.22 (B6) — create a reminder from the ledger. `note` is the only writable content; `when` is the closed grammar (ISO date | +Nd / +Nw | a context cue) resolved into the trigger server-side; `metric` optional. The per-user cap returns 409 when the non-terminal set is full. Coach-gated. Auth via cookie or Bearer; the owner is narrowed from the session.", + requestBody: { + required: true, + content: { + "application/json": { schema: coachReminderCreateSchema }, + }, + }, + responses: { + "201": { + description: "The created reminder.", + content: { + "application/json": { + schema: dataEnvelope( + coachReminderCreatedResponse, + "CoachReminderCreated", + ), + }, + }, + }, + "403": { + description: "Coach surface disabled.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "409": { + description: "The per-user reminder cap is full.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/coach/reminders/{id}": { + patch: { + tags: ["Insights"], + summary: "Confirm or update a Coach reminder's lifecycle", + description: + "v1.22 (B6) — confirm a proposed reminder (→ active), mark it done / dismissed, or re-schedule via the closed `when` grammar (null clears the due moment). The body never carries the note text. A foreign / unknown / already-deleted id maps to 404 (never 403) so the existence channel does not leak. Coach-gated. Auth via cookie or Bearer.", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + description: "Reminder id.", + }, + ], + requestBody: { + required: true, + content: { + "application/json": { schema: coachReminderPatchSchema }, + }, + }, + responses: { + "200": { + description: "The reminder after the lifecycle update.", + content: { + "application/json": { + schema: dataEnvelope( + coachReminderUpdatedResponse, + "CoachReminderUpdated", + ), + }, + }, + }, + "403": { + description: "Coach surface disabled.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "404": { + description: "Reminder not found or not owned by the caller.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Insights"], + summary: "Soft-delete one Coach reminder", + description: + "v1.22 (B6) — soft-deletes a single reminder owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`. Coach-gated. Auth via cookie or Bearer.", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + description: "Reminder id.", + }, + ], + responses: { + "200": { + description: + "The reminder was soft-deleted (`deleted: true`) or the id matched nothing the caller owns (`deleted: false`).", + content: { + "application/json": { + schema: dataEnvelope( + coachReminderDeletedResponse, + "CoachReminderDeleted", + ), + }, + }, + }, + "403": { + description: "Coach surface disabled.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, +}; + +export const coachSuggestedActionPaths: NonNullable = + { + "/api/coach/suggested-actions": { + post: { + tags: ["Insights"], + summary: "Confirm a Coach action card", + description: + 'v1.22 (F6) — the confirm half of the generalised propose→confirm moat. `actionType` is from the CLOSED allowlist (checkup.create | reminder.note) — NEVER a medication or clinical change. `checkup.create` builds a preventive-care Vorsorge `MeasurementReminder` (free-text label + a closed interval id resolved to an RRULE server-side); `reminder.note` builds a `CoachReminder`. Nothing is created without this explicit confirm. Coach-gated (`requireModuleEnabled("coach")`); per-user rate-limited (429). Auth via cookie or Bearer; the owner is narrowed from the session, built field-by-field — no mass assignment, no IDOR.', + requestBody: { + required: true, + content: { + "application/json": { schema: coachSuggestedActionSchema }, + }, + }, + responses: { + "201": { + description: "The action was applied (entity created server-side).", + content: { + "application/json": { + schema: dataEnvelope( + coachSuggestedActionResultResponse, + "CoachSuggestedActionResultCreated", + ), + }, + }, + }, + "403": { + description: "Coach surface disabled.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "409": { + description: "The per-user reminder cap is full (reminder.note).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + }; + export const coachFeedbackPaths: NonNullable = { "/api/insights/chat/messages/{id}/feedback": { post: { diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 8eb903034..b4b5a9c91 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -27,6 +27,8 @@ import { coachFeedbackPaths, coachPaths, coachReminderSuggestionPaths, + coachReminderPaths, + coachSuggestedActionPaths, } from "./coach"; import { consentPaths } from "./consent"; import { cyclePaths } from "./cycle"; @@ -62,6 +64,8 @@ export const openApiPaths: NonNullable = { ...profilePaths, ...coachPaths, ...coachReminderSuggestionPaths, + ...coachReminderPaths, + ...coachSuggestedActionPaths, ...adminInvitePaths, ...coachFeedbackPaths, ...adminDiagnosticPaths, diff --git a/src/lib/validations/coach-reminder.ts b/src/lib/validations/coach-reminder.ts new file mode 100644 index 000000000..9a1eccccb --- /dev/null +++ b/src/lib/validations/coach-reminder.ts @@ -0,0 +1,138 @@ +/** + * v1.22 (B2/B6/F6) — request schemas for the Coach episodic-reminder surface + * (`/api/coach/reminders` + `/[id]`) and the generalised confirm-card endpoint + * (`/api/coach/suggested-actions`). + * + * Lives outside the route files so the OpenAPI registry can import it without + * touching the route modules (route files may only export handlers + config). + * + * No `userId` field anywhere — the owner is always narrowed from the session. + * The note free-text is the only user-writable content; everything timing is + * resolved SERVER-side from a closed grammar / catalog, so a client can never + * mint an arbitrary schedule. + */ +import { z } from "zod/v4"; + +import { + COACH_REMINDER_STATUSES, + REMINDER_NOTE_MAX_CHARS, + REMINDER_METRIC_MAX_CHARS, +} from "@/lib/ai/coach/reminders"; +import { + SUGGESTED_ACTION_TYPES, + CHECKUP_LABEL_MAX_CHARS, + ACTION_NOTE_MAX_CHARS, + ACTION_METRIC_MAX_CHARS, +} from "@/lib/ai/coach/suggest-action"; + +/** A `when` grammar token: ISO date | relative +Nd/+Nw | a context cue. */ +const whenToken = z.string().trim().min(1).max(32); +const metricToken = z + .string() + .trim() + .min(1) + .max(REMINDER_METRIC_MAX_CHARS) + .regex(/^[A-Za-z][A-Za-z0-9_]*$/, "metric must be a metric key"); + +/** + * `?status=` filter on the list endpoint. Accepts one status or a + * comma-separated set (e.g. `due,surfaced` for the in-app tile). Each token is + * validated against the closed status enum; an unknown token 422s. + */ +export const coachRemindersListQuerySchema = z.object({ + status: z + .string() + .optional() + .transform((v) => + v + ? v + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : undefined, + ) + .pipe(z.array(z.enum(COACH_REMINDER_STATUSES)).min(1).optional()), +}); + +/** + * Manual create (the ledger "+ add a reminder" path). The note is the only + * free text; `when` is the closed grammar (resolved server-side into the + * trigger), `metric` optional. Strict: unknown keys 422. + */ +export const coachReminderCreateSchema = z + .object({ + note: z.string().trim().min(1).max(REMINDER_NOTE_MAX_CHARS), + when: whenToken.optional(), + metric: metricToken.optional(), + }) + .strict() + .meta({ + id: "CoachReminderCreate", + description: + "Create a Coach reminder manually. `note` is free text (the only writable content). `when` is an optional closed grammar token — an ISO date (YYYY-MM-DD), a relative offset (+Nd / +Nw), or a context cue (NEXT_BP_LOGGED, NEXT_WEIGHT_LOGGED, NEXT_SLEEP_LOGGED, NEXT_APP_OPEN) — resolved into the trigger server-side. Omit `when` for a recall-only note. Strict: unknown keys 422.", + }); + +/** + * The lifecycle PATCH. `status` confirms a proposed reminder (→ active), marks + * it done / dismissed, or re-activates it. `when` optionally re-schedules + * (snooze) via the same closed grammar. The body never carries the note text, + * so a client can never overwrite a reminder's prose — only its lifecycle. + */ +export const coachReminderPatchSchema = z + .object({ + status: z.enum(COACH_REMINDER_STATUSES).optional(), + when: whenToken.nullable().optional(), + }) + .strict() + .refine( + (v) => v.status !== undefined || v.when !== undefined, + "At least one of status or when must be provided", + ) + .meta({ + id: "CoachReminderPatch", + description: + "Update a Coach reminder's lifecycle. `status` confirms (proposed → active), or marks done / dismissed. `when` re-schedules via the closed grammar (null clears the due moment). Never carries the note text. Strict: unknown keys 422.", + }); + +// ── Suggested-action confirm endpoint (F6) ────────────────────────────── + +const checkupParams = z.object({ + actionType: z.literal("checkup.create"), + label: z.string().trim().min(1).max(CHECKUP_LABEL_MAX_CHARS), + interval: z.enum(["yearly", "halfYearly", "quarterly", "monthly"]), +}); + +const reminderNoteParams = z.object({ + actionType: z.literal("reminder.note"), + note: z.string().trim().min(1).max(ACTION_NOTE_MAX_CHARS), + when: whenToken.optional(), + metric: z + .string() + .trim() + .min(1) + .max(ACTION_METRIC_MAX_CHARS) + .regex(/^[A-Za-z][A-Za-z0-9_]*$/) + .optional(), +}); + +/** + * The confirm-card POST body. `actionType` names a closed-allowlist action; the + * params are a discriminated union, validated field-by-field. The server resolves + * every schedule from a closed catalog and builds the entity field-by-field — + * the client can never mint an arbitrary entity, widen a cadence, or touch a + * medication / clinical target. + */ +export const coachSuggestedActionSchema = z + .discriminatedUnion("actionType", [checkupParams, reminderNoteParams]) + .meta({ + id: "CoachSuggestedAction", + description: + "Confirm a Coach action card. `actionType` is from the closed allowlist (checkup.create, reminder.note); NEVER a medication or clinical change. `checkup.create` builds a preventive-care Vorsorge reminder (free-text label + a closed interval id resolved to an RRULE server-side); `reminder.note` builds a CoachReminder (note + optional closed `when` grammar + optional metric). Nothing is created without this explicit confirm.", + }); + +export type CoachSuggestedActionInput = z.infer< + typeof coachSuggestedActionSchema +>; + +/** Stable list of confirmable action types (mirrors the lib allowlist). */ +export const COACH_SUGGESTED_ACTION_TYPES = SUGGESTED_ACTION_TYPES;