diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0e1b9250f..912e31f49 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: @@ -17071,6 +17347,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/messages/de.json b/messages/de.json index c0247a8cf..be3cf59b6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4419,6 +4419,28 @@ "clearedToast": "{count} gespeicherte Angaben vergessen.", "clearError": "Konnte die gespeicherten Angaben nicht löschen. Bitte erneut versuchen." }, + "coachReminders": { + "title": "Deine Erinnerungen", + "description": "Dinge, um die du den Coach gebeten hast, später darauf zurückzukommen. Fällige erscheinen hier und beim Coach.", + "empty": "Noch keine Erinnerungen. Bitte den Coach, dich an etwas zu erinnern — dann erscheint es hier.", + "loadError": "Konnte deine Erinnerungen nicht laden.", + "dueHeading": "Jetzt fällig", + "allHeading": "Gespeichert", + "duePrefix": "fällig {when}", + "confirm": "Behalten", + "markDone": "Erledigt", + "dismiss": "Verwerfen", + "deleteAria": "Diese Erinnerung löschen", + "note": "Erinnerungen werden verschlüsselt gespeichert. Lösche eine jederzeit — sie wird aus dem entfernt, was der Coach sehen kann.", + "status": { + "proposed": "Vorgeschlagen", + "active": "Gespeichert", + "due": "Fällig", + "surfaced": "Angezeigt", + "done": "Erledigt", + "dismissed": "Verworfen" + } + }, "aboutMe": { "title": "Über mich", "description": "Persönlicher Kontext, den Coach und Tagesbriefing zusätzlich zu deinen Daten lesen.", @@ -7054,6 +7076,25 @@ "glucoseStructured": "Miss deine Glukose nach einem strukturierten Tagesplan" } }, + "suggestedAction": { + "apply": "Hinzufügen", + "dismiss": "Jetzt nicht", + "applied": "Hinzugefügt", + "dismissed": "Verworfen", + "failed": "Konnte das nicht hinzufügen — bitte erneut versuchen", + "checkup": { + "title": "Vorsorge-Erinnerung hinzufügen?" + }, + "note": { + "title": "Erinnerung speichern?" + }, + "interval": { + "yearly": "Jedes Jahr", + "halfYearly": "Alle sechs Monate", + "quarterly": "Alle drei Monate", + "monthly": "Jeden Monat" + } + }, "settings": { "reminderSuggestionsLabel": "Kadenz-Vorschläge", "reminderSuggestionsDescription": "Lass den Coach gelegentlich eine evidenzbasierte Erinnerung anbieten, eine Metrik regelmäßiger zu messen." diff --git a/messages/en.json b/messages/en.json index e62b0ffd7..0186d745a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4419,6 +4419,28 @@ "clearedToast": "Forgot {count} stored facts.", "clearError": "Could not clear the stored facts. Please try again." }, + "coachReminders": { + "title": "Your reminders", + "description": "Things you asked the Coach to bring back later. Due ones show up here and on the Coach.", + "empty": "No reminders yet. Ask the Coach to remind you about something and it will appear here.", + "loadError": "Could not load your reminders.", + "dueHeading": "Due now", + "allHeading": "Saved", + "duePrefix": "due {when}", + "confirm": "Keep", + "markDone": "Done", + "dismiss": "Dismiss", + "deleteAria": "Delete this reminder", + "note": "Reminders are stored encrypted. Delete one any time — it is removed from what the Coach can see.", + "status": { + "proposed": "Suggested", + "active": "Saved", + "due": "Due", + "surfaced": "Surfaced", + "done": "Done", + "dismissed": "Dismissed" + } + }, "aboutMe": { "title": "About me", "description": "Personal context the Coach and the daily briefing read alongside your data.", @@ -7054,6 +7076,25 @@ "glucoseStructured": "Test your glucose on a structured daily schedule" } }, + "suggestedAction": { + "apply": "Add", + "dismiss": "Not now", + "applied": "Added", + "dismissed": "Dismissed", + "failed": "Couldn't add that — please try again", + "checkup": { + "title": "Add a check-up reminder?" + }, + "note": { + "title": "Save a reminder?" + }, + "interval": { + "yearly": "Every year", + "halfYearly": "Every six months", + "quarterly": "Every three months", + "monthly": "Every month" + } + }, "settings": { "reminderSuggestionsLabel": "Cadence suggestions", "reminderSuggestionsDescription": "Let the Coach offer an occasional, evidence-based reminder to measure a metric more regularly." diff --git a/prisma/migrations/0196_coach_reminder/migration.sql b/prisma/migrations/0196_coach_reminder/migration.sql new file mode 100644 index 000000000..d11bcd3a0 --- /dev/null +++ b/prisma/migrations/0196_coach_reminder/migration.sql @@ -0,0 +1,42 @@ +-- v1.22 (B2/B6) — Coach episodic reminder memory. +-- +-- A time-anchored "bring this back later" intent the Coach captured from a +-- conversation. The missing memory type between coach_facts (durable traits) +-- and coach_plans (standing if-then habits): an episode with a due moment and +-- a done state. The free-text note is AES-256-GCM ciphertext stored as `bytea` +-- via the shared Bytes codec — the same shape as coach_plans.if_cue_encrypted. +-- metric / status / trigger fields / dates / source stay plain so the daily +-- sweep and the injection picker can rank and filter without a per-row decrypt. +-- status (proposed | active | due | surfaced | done | dismissed) and +-- trigger_kind (date | context) are app-side closed enums, deliberately NOT DB +-- enums so this migration stays purely additive. Soft-delete via deleted_at. +CREATE TABLE "coach_reminders" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "note_encrypted" BYTEA NOT NULL, + "metric" TEXT, + "related_plan_id" TEXT, + "trigger_kind" TEXT NOT NULL DEFAULT 'date', + "due_at" TIMESTAMP(3), + "context_cue" TEXT, + "status" TEXT NOT NULL DEFAULT 'active', + "source" TEXT NOT NULL, + "source_conversation_id" TEXT, + "last_surfaced_at" TIMESTAMP(3), + "surface_count" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "coach_reminders_pkey" PRIMARY KEY ("id") +); + +-- The daily sweep scans a user's active reminders whose due_at has passed; the +-- status + deleted_at columns are part of that predicate, so the composite +-- index lets the planner serve the sweep + the injection read index-backed. +CREATE INDEX "coach_reminders_user_id_status_due_at_deleted_at_idx" ON "coach_reminders"("user_id", "status", "due_at", "deleted_at"); + +-- The context-cue evaluation pass filters by trigger_kind + status per user. +CREATE INDEX "coach_reminders_user_id_trigger_kind_status_idx" ON "coach_reminders"("user_id", "trigger_kind", "status"); + +ALTER TABLE "coach_reminders" ADD CONSTRAINT "coach_reminders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bd9779f55..dc4333ff4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -571,6 +571,7 @@ model User { coachConversations CoachConversation[] coachFacts CoachFact[] coachPlans CoachPlan[] + coachReminders CoachReminder[] coachUsage CoachUsage[] // v1.4.25 W8d — workout passthrough (Apple HKWorkout / Withings // getworkouts / future Strava). Schema lands now so the v1.5 iOS @@ -3938,6 +3939,56 @@ model CoachPlan { @@map("coach_plans") } +// v1.22 (B2/B6) — durable, user-scoped EPISODIC reminder memory: a +// time-anchored "bring this back later" intent the Coach captured from a +// conversation ("remind me about my sleep next week"). This is the middle +// memory type that neither CoachFact (durable traits) nor CoachPlan (standing +// if-then habits) covers — an episode with a due moment and a done state. +// +// The free-text note is the user's own framing, encrypted at rest (AES-256-GCM +// via the shared Bytes codec, exactly like CoachPlan's cue/action). `metric`, +// `status`, the trigger fields and the dates stay plain so the daily sweep and +// the injection picker can rank / filter without paying a per-row decrypt. +// +// `status` is an APP-SIDE closed enum (NOT a DB enum, to keep the migration +// purely additive): proposed | active | due | surfaced | done | dismissed. +// `triggerKind` is likewise app-side closed: date | context. Soft-delete via +// `deletedAt` so a "forget this" action is reversible / auditable. +model CoachReminder { + id String @id @default(cuid()) + userId String @map("user_id") + // What to bring back — the user's own framing, encrypted at rest. + noteEncrypted Bytes @map("note_encrypted") + // Optional link to the metric / plan it concerns; plain for filtering. + metric String? + relatedPlanId String? @map("related_plan_id") + // Trigger model — app-side closed enum (date | context). For `date`, `dueAt` + // carries the fire moment; for `context`, `contextCue` names a closed cue. + triggerKind String @default("date") @map("trigger_kind") + dueAt DateTime? @map("due_at") + contextCue String? @map("context_cue") + // Lifecycle: proposed | active | due | surfaced | done | dismissed. + status String @default("active") + // Provenance — how it was captured (sentinel | extractor | manual | action). + source String + sourceConversationId String? @map("source_conversation_id") + lastSurfacedAt DateTime? @map("last_surfaced_at") + // How many times the sweep has surfaced this without the user acting; capped + // before auto-dismiss so an ignored reminder cannot nag indefinitely. + surfaceCount Int @default(0) @map("surface_count") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + // The daily sweep scans active reminders whose dueAt has passed; the status + + // deleted_at columns are part of the predicate so the read stays index-backed. + @@index([userId, status, dueAt, deletedAt]) + @@index([userId, triggerKind, status]) + @@map("coach_reminders") +} + // v1.15.20 — user-authored "about me" self-description the AI surfaces // (Coach system prompt + daily-briefing user prompt) inject as a clearly // delimited, user-provided context block. 1:1 with User and lazily diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 53149ef99..7f786aeb6 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -328,6 +328,16 @@ async function main() { results.push(await rotateBytesColumn("CoachPlan", field, prisma.coachPlan)); } + // ───── CoachReminder (Bytes column) ───── + // "noteEncrypted" + results.push( + await rotateBytesColumn( + "CoachReminder", + "noteEncrypted", + prisma.coachReminder, + ), + ); + // ───── UserHealthProfile (Bytes columns) ───── // "aboutMeEncrypted" "conditionsEncrypted" "allergiesEncrypted" // "coachFocusEncrypted" "pendingQuestionsEncrypted" 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/__tests__/route.test.ts b/src/app/api/coach/reminders/__tests__/route.test.ts new file mode 100644 index 000000000..5f3fd26dd --- /dev/null +++ b/src/app/api/coach/reminders/__tests__/route.test.ts @@ -0,0 +1,312 @@ +/** + * v1.22 (B2/B6) — `/api/coach/reminders` (+ `/[id]`) routes. + * + * Covers: + * - GET lists the caller's reminders decrypted, scoped to the user; an + * undecryptable row is skipped rather than 500ing the list. + * - POST creates a reminder field-by-field (source manual, active), resolving + * the closed `when` grammar server-side; an invalid `when` 422s. + * - PATCH confirms proposed → active field-by-field; a 0-count match 404s + * (no-IDOR: the cross-user / unknown id never leaks existence). + * - DELETE soft-deletes the caller's reminder; a cross-user id is a 0-count + * no-op returning `{ deleted: false }`. + * - the coach module gate runs on every verb. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + coachReminder: { + findMany: vi.fn(), + count: vi.fn(), + create: vi.fn(), + updateMany: vi.fn(), + findFirst: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ + allowed: true, + remaining: 39, + resetAt: Date.now() + 60_000, + })), + rateLimitHeaders: vi.fn(() => ({})), +})); +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + decryptFromBytes: vi.fn((buf: Uint8Array) => { + const tag = Buffer.from(buf).toString("utf8"); + if (tag === "__bad__") throw new Error("unknown key id"); + return `dec:${tag}`; + }), + encryptToBytes: vi.fn((s: string) => new Uint8Array(Buffer.from(`enc:${s}`))), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/context", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, annotate: vi.fn() }; +}); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, POST } from "../route"; +import { PATCH, DELETE as DELETE_ONE } from "../[id]/route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; + +const SESSION_OK = { + session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "t", + role: "USER" as const, + displayName: null, + }, +}; + +function bytes(tag: string): Uint8Array { + return new Uint8Array(Buffer.from(tag, "utf8")); +} + +const callGet = (url = "http://localhost/api/coach/reminders") => + (GET as unknown as (req: Request) => Promise)(new Request(url)); + +function jsonReq(body: unknown, method = "POST"): NextRequest { + return new NextRequest("http://localhost/api/coach/reminders", { + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} +const callPost = (body: unknown) => + (POST as unknown as (req: Request) => Promise)(jsonReq(body)); + +const callPatch = (body: unknown, id = "r1") => + ( + PATCH as unknown as ( + req: NextRequest, + ctx: { params: Promise<{ id: string }> }, + ) => Promise + )( + new NextRequest("http://localhost/api/coach/reminders/x", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ id }) }, + ); + +const callDelete = (id = "r1") => + ( + DELETE_ONE as unknown as ( + req: NextRequest, + ctx: { params: Promise<{ id: string }> }, + ) => Promise + )( + new NextRequest("http://localhost/api/coach/reminders/x", { + method: "DELETE", + }), + { params: Promise.resolve({ id }) }, + ); + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); +}); + +describe("GET /api/coach/reminders", () => { + it("lists the caller's reminders decrypted, scoped to the user", async () => { + vi.mocked(prisma.coachReminder.findMany).mockResolvedValue([ + { + id: "r1", + metric: "SLEEP", + noteEncrypted: bytes("revisit sleep"), + triggerKind: "date", + dueAt: new Date("2026-07-11T09:00:00Z"), + contextCue: null, + status: "active", + source: "sentinel", + createdAt: new Date("2026-06-01T00:00:00Z"), + updatedAt: new Date("2026-06-02T00:00:00Z"), + }, + ] as never); + + const res = await callGet(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { reminders: Array> }; + }; + expect(body.data.reminders[0]).toMatchObject({ + id: "r1", + note: "dec:revisit sleep", + status: "active", + }); + const where = vi.mocked(prisma.coachReminder.findMany).mock.calls[0]?.[0] + ?.where as { userId: string; deletedAt: null }; + expect(where.userId).toBe("user-1"); + expect(where.deletedAt).toBeNull(); + expect(requireModuleEnabled).toHaveBeenCalledWith("user-1", "coach"); + }); + + it("skips an undecryptable row rather than 500ing", async () => { + vi.mocked(prisma.coachReminder.findMany).mockResolvedValue([ + { + id: "r1", + metric: null, + noteEncrypted: bytes("__bad__"), + triggerKind: "date", + dueAt: null, + contextCue: null, + status: "active", + source: "manual", + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: "r2", + metric: null, + noteEncrypted: bytes("ok"), + triggerKind: "date", + dueAt: null, + contextCue: null, + status: "active", + source: "manual", + createdAt: new Date(), + updatedAt: new Date(), + }, + ] as never); + const res = await callGet(); + const body = (await res.json()) as { + data: { reminders: Array<{ id: string }> }; + }; + expect(body.data.reminders).toHaveLength(1); + expect(body.data.reminders[0]?.id).toBe("r2"); + }); +}); + +describe("POST /api/coach/reminders", () => { + it("creates a reminder field-by-field, resolving the when grammar", async () => { + vi.mocked(prisma.coachReminder.count).mockResolvedValue(0 as never); + vi.mocked(prisma.coachReminder.create).mockResolvedValue({ + id: "r9", + metric: "SLEEP", + noteEncrypted: bytes("x"), + triggerKind: "date", + dueAt: new Date(), + contextCue: null, + status: "active", + source: "manual", + createdAt: new Date(), + updatedAt: new Date(), + } as never); + + const res = await callPost({ + note: "revisit sleep", + when: "+7d", + metric: "SLEEP", + }); + expect(res.status).toBe(201); + const data = vi.mocked(prisma.coachReminder.create).mock.calls[0]?.[0] + ?.data as { + userId: string; + status: string; + source: string; + }; + expect(data.userId).toBe("user-1"); + expect(data.status).toBe("active"); + expect(data.source).toBe("manual"); + }); + + it("422s on an invalid when token", async () => { + vi.mocked(prisma.coachReminder.count).mockResolvedValue(0 as never); + const res = await callPost({ note: "x", when: "someday" }); + expect(res.status).toBe(422); + expect(prisma.coachReminder.create).not.toHaveBeenCalled(); + }); + + it("422s on an unknown key (strict)", async () => { + const res = await callPost({ note: "x", userId: "evil" }); + expect(res.status).toBe(422); + }); +}); + +describe("PATCH /api/coach/reminders/[id]", () => { + it("confirms proposed → active field-by-field, owner-scoped", async () => { + vi.mocked(prisma.coachReminder.updateMany).mockResolvedValue({ + count: 1, + } as never); + vi.mocked(prisma.coachReminder.findFirst).mockResolvedValue({ + id: "r1", + metric: null, + noteEncrypted: bytes("x"), + triggerKind: "date", + dueAt: null, + contextCue: null, + status: "active", + source: "sentinel", + createdAt: new Date(), + updatedAt: new Date(), + } as never); + const res = await callPatch({ status: "active" }); + expect(res.status).toBe(200); + const arg = vi.mocked(prisma.coachReminder.updateMany).mock + .calls[0]?.[0] as { + where: { id: string; userId: string; deletedAt: null }; + data: Record; + }; + expect(arg.where.userId).toBe("user-1"); + expect(Object.keys(arg.data)).toEqual(["status"]); + }); + + it("404s on a 0-count (cross-user / unknown) match — no IDOR leak", async () => { + vi.mocked(prisma.coachReminder.updateMany).mockResolvedValue({ + count: 0, + } as never); + const res = await callPatch({ status: "done" }, "someone-elses"); + expect(res.status).toBe(404); + expect(prisma.coachReminder.findFirst).not.toHaveBeenCalled(); + }); + + it("422s on an empty body", async () => { + const res = await callPatch({}); + expect(res.status).toBe(422); + }); +}); + +describe("DELETE /api/coach/reminders/[id]", () => { + it("soft-deletes the caller's reminder", async () => { + vi.mocked(prisma.coachReminder.updateMany).mockResolvedValue({ + count: 1, + } as never); + const res = await callDelete(); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { deleted: boolean } }; + expect(body.data.deleted).toBe(true); + }); + + it("returns deleted:false for a cross-user id (0-count no-op)", async () => { + vi.mocked(prisma.coachReminder.updateMany).mockResolvedValue({ + count: 0, + } as never); + const res = await callDelete("someone-elses"); + const body = (await res.json()) as { data: { deleted: boolean } }; + expect(body.data.deleted).toBe(false); + }); +}); 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/__tests__/route.test.ts b/src/app/api/coach/suggested-actions/__tests__/route.test.ts new file mode 100644 index 000000000..f38a76c2c --- /dev/null +++ b/src/app/api/coach/suggested-actions/__tests__/route.test.ts @@ -0,0 +1,169 @@ +/** + * v1.22 (F6) — `POST /api/coach/suggested-actions`. + * + * Covers the closed allowlist confirm endpoint: + * - checkup.create builds a Vorsorge MeasurementReminder field-by-field + * (origin COACH, server-resolved RRULE from the closed interval id). + * - reminder.note builds a CoachReminder field-by-field (source action). + * - an off-allowlist actionType (e.g. medication.create) 422s — the moat + * never auto-applies or mints an arbitrary entity. + * - the owner is always the session user (no IDOR, no body userId). + * - the coach module gate runs. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + measurementReminder: { create: vi.fn() }, + coachReminder: { count: vi.fn(), create: vi.fn() }, + }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ + allowed: true, + resetAt: Date.now() + 1000, + })), +})); +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + encryptToBytes: (s: string) => new Uint8Array(Buffer.from(`enc:${s}`)), +})); +vi.mock("@/lib/measurement-reminders/scheduling", () => ({ + computeReminderNextDueAt: vi.fn(() => new Date("2027-06-27T09:00:00Z")), +})); +vi.mock("@/lib/measurement-reminders/dto", () => ({ + toMeasurementReminderDto: vi.fn((r: { id: string }) => ({ id: r.id })), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/context", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, annotate: vi.fn() }; +}); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; + +const SESSION_OK = { + session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "t", + role: "USER" as const, + displayName: null, + }, +}; + +const call = (body: unknown) => + (POST as unknown as (req: Request) => Promise)( + new NextRequest("http://localhost/api/coach/suggested-actions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); +}); + +describe("POST /api/coach/suggested-actions", () => { + it("checkup.create builds a Vorsorge reminder field-by-field (origin COACH, server RRULE)", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + timezone: "Europe/Berlin", + } as never); + vi.mocked(prisma.measurementReminder.create).mockResolvedValue({ + id: "mr1", + } as never); + + const res = await call({ + actionType: "checkup.create", + label: "Annual blood panel", + interval: "yearly", + }); + expect(res.status).toBe(201); + const data = vi.mocked(prisma.measurementReminder.create).mock.calls[0]?.[0] + ?.data as { + userId: string; + origin: string; + rrule: string; + measurementType: null; + label: string; + }; + expect(data.userId).toBe("user-1"); // owner from session, never the body + expect(data.origin).toBe("COACH"); + expect(data.rrule).toBe("FREQ=YEARLY;INTERVAL=1"); + expect(data.measurementType).toBeNull(); + expect(data.label).toBe("Annual blood panel"); + expect(requireModuleEnabled).toHaveBeenCalledWith("user-1", "coach"); + }); + + it("reminder.note builds a CoachReminder field-by-field (source action)", async () => { + vi.mocked(prisma.coachReminder.count).mockResolvedValue(0 as never); + vi.mocked(prisma.coachReminder.create).mockResolvedValue({ + id: "cr1", + } as never); + + const res = await call({ + actionType: "reminder.note", + note: "revisit evening walk", + when: "+14d", + metric: "SLEEP", + }); + expect(res.status).toBe(201); + const data = vi.mocked(prisma.coachReminder.create).mock.calls[0]?.[0] + ?.data as { + userId: string; + source: string; + status: string; + }; + expect(data.userId).toBe("user-1"); + expect(data.source).toBe("action"); + expect(data.status).toBe("active"); + }); + + it("422s an off-allowlist actionType — never auto-applies a clinical/med change", async () => { + const res = await call({ + actionType: "medication.create", + label: "start a drug", + interval: "monthly", + }); + expect(res.status).toBe(422); + expect(prisma.measurementReminder.create).not.toHaveBeenCalled(); + expect(prisma.coachReminder.create).not.toHaveBeenCalled(); + }); + + it("403s when the coach module is disabled", async () => { + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: false, + response: new Response(null, { status: 403 }), + } as never); + const res = await call({ + actionType: "reminder.note", + note: "x", + }); + expect(res.status).toBe(403); + }); +}); 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/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index c43ad8afa..8ba4f7e7f 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -93,6 +93,16 @@ import { import { scrubUnknownLearnLinks } from "@/lib/ai/coach/learn-link-guard"; import { parseSuggestReminder } from "@/lib/ai/coach/suggest-reminder"; import { gateSuggestion } from "@/lib/ai/coach/suggest-gate"; +import { + parseRememberSentinel, + captureReminderFromSentinel, + buildRememberAddendum, +} from "@/lib/ai/coach/reminders"; +import { + parseSuggestAction, + buildSuggestActionAddendum, + type CoachSuggestedAction, +} from "@/lib/ai/coach/suggest-action"; import { parseCoachPrefs, DEFAULT_REMINDER_SUGGESTION_PREFS, @@ -371,7 +381,12 @@ async function handleChatRequest(request: NextRequest): Promise { // v1.16.0 — composed self-context: structured questionnaire fields // plus age/gender merged in from the User profile. const aboutMe = await getSelfContextTextForUser(userId, locale); - const systemPrompt = getCoachSystemPrompt(locale, coachPrefs, aboutMe); + // v1.22 (B2/F6) — the canonical system-prompt module is owned elsewhere, so + // the two memory/action clauses are appended at assembly time here: one + // teaches the model to emit `---REMEMBER---` (durable "remind me" capture), + // one teaches the closed `---SUGGEST-ACTION---` confirm-card allowlist. Both + // are provider-neutral sentinels stripped from the prose before it streams. + const systemPrompt = `${getCoachSystemPrompt(locale, coachPrefs, aboutMe)}\n\n${buildRememberAddendum(locale)}\n\n${buildSuggestActionAddendum(locale)}`; const allTurns: CoachTurn[] = [ ...priorTurns, { role: "user", content: message }, @@ -755,6 +770,39 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch const suggestParse = parseSuggestReminder(proseAfterStrip); let replyText = suggestParse.prose.trim() || proseAfterStrip; + // v1.22 (B2) — strip the optional `---REMEMBER---` block and capture the + // reminder INLINE on this turn (not in the >20-turn memory worker), so a + // casual "remind me about X" in a SHORT chat is no longer lost. The note is + // the user's OWN explicit request → captured silently as `active` (no confirm + // gate); the model acknowledges it in prose. A missing note / invalid `when` + // drops the block (the user never sees the raw marker). Fire-and-forget: the + // capture write must never break the chat turn. + const rememberParse = parseRememberSentinel(replyText, new Date()); + replyText = rememberParse.prose.trim() || replyText; + if (rememberParse.reminder) { + const capture = rememberParse.reminder; + void captureReminderFromSentinel({ + userId, + conversationId: workingConversationId, + parsed: capture, + }).catch(() => { + // Reminder capture is best-effort; never sink the turn. + }); + } else if (rememberParse.malformed) { + annotate({ + action: { name: "coach.reminder.capture_malformed" }, + meta: { conversationId: workingConversationId }, + }); + } + + // v1.22 (F6) — strip the optional `---SUGGEST-ACTION---` block (the + // generalised confirm→apply moat). The model names ONE action from the closed + // allowlist (`checkup.create` / `reminder.note`); the card surfaces additively + // and NOTHING is created until the user taps confirm (the entity is built + // server-side, field-by-field, by `POST /api/coach/suggested-actions`). + const actionParse = parseSuggestAction(replyText); + replyText = actionParse.prose.trim() || replyText; + // v1.18.10 (HIGH-2) — OUTBOUND safety screen on the assembled assistant // reply, before persistence and streaming. The inbound `detectRefusal` // guards the user's message; this guards the model's reply for a @@ -888,8 +936,21 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch }); } } + // v1.22 (F6) — surface the confirm-card action when the turn was not blocked. + // Additive: the prose already stands alone; the card only offers the one-tap + // confirm. Nothing is created server-side until the user taps it. + let surfacedAction: CoachSuggestedAction | null = null; + if (!outbound.block && actionParse.action) { + surfacedAction = actionParse.action; + annotate({ + action: { name: "coach.action.suggested" }, + meta: { actionType: actionParse.action.actionType }, + }); + } + const enrichedProvenance: typeof snapshot.provenance = { ...snapshot.provenance, + ...(surfacedAction ? { suggestedAction: surfacedAction } : {}), // v1.18.10 (HIGH-2) — a blocked turn carries the fallback prose, so the // key-values from the discarded reply must not ride along as provenance. ...(!outbound.block && sentinel.keyValues.length > 0 @@ -1018,6 +1079,16 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch encodeFrame({ type: "suggestion", suggestion: surfacedSuggestion }), ); } + // v1.22 (F6) — additive `suggestedAction` frame. Older clients ignore it; + // newer ones render the generic one-tap confirm card. + if (surfacedAction) { + controller.enqueue( + encodeFrame({ + type: "suggestedAction", + suggestedAction: surfacedAction, + }), + ); + } // v1.18.9 — additive `usage` envelope on the `done` frame so the client // can paint the quiet per-message token footer the instant the stream // closes. Server-authoritative: the client renders these numbers, never diff --git a/src/components/insights/coach-panel/suggested-action-card.tsx b/src/components/insights/coach-panel/suggested-action-card.tsx new file mode 100644 index 000000000..5ac6f57c9 --- /dev/null +++ b/src/components/insights/coach-panel/suggested-action-card.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Check, Loader2, Sparkles } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { useTranslations } from "@/lib/i18n/context"; +import { apiPost } from "@/lib/api/api-fetch"; +import type { CoachSuggestedAction } from "@/lib/ai/coach/suggest-action"; + +/** + * v1.22 (F6) — the generalised one-tap "confirm → apply" Coach action card. + * + * Rendered under an assistant bubble when the turn carried a `suggestedAction` + * (live from the SSE frame, or persisted on the message provenance). The card + * shows a localized heading per action type (`titleKey`) + the model's plain-text + * summary, and two actions: + * + * - "Add" → POSTs the validated params to `POST /api/coach/suggested-actions`, + * which builds the entity SERVER-side, field-by-field. NEVER auto-applied. + * - "Not now" → dismisses the card locally (no server write). + * + * RENDER dedup mirrors the cadence card: once settled, the card collapses to a + * confirmation so a stale stream + persisted twin can't double-prompt. The + * STATIC copy is i18n; the dynamic summary/label is the user's own content + * (plain text — no markdown, matching the XSS posture). + */ +export function SuggestedActionCard({ + action, +}: { + action: CoachSuggestedAction; +}) { + const { t } = useTranslations(); + const [settled, setSettled] = useState<"applied" | "dismissed" | null>(null); + + const apply = useMutation({ + mutationFn: async () => { + await apiPost("/api/coach/suggested-actions", action.params); + }, + onSuccess: () => setSettled("applied"), + onError: () => toast.error(t("coach.suggestedAction.failed")), + }); + + if (settled) { + return ( +

+

+ ); + } + + const busy = apply.isPending; + const intervalLabel = + action.params.actionType === "checkup.create" + ? t(`coach.suggestedAction.interval.${action.params.interval}`) + : null; + + return ( +
+
+ +
+ + {t(action.titleKey)} + + + {action.summary} + + {intervalLabel && ( + + {intervalLabel} + + )} +
+
+
+ + +
+
+ ); +} diff --git a/src/components/insights/layout-coach-fab.tsx b/src/components/insights/layout-coach-fab.tsx index 29412062e..00c925615 100644 --- a/src/components/insights/layout-coach-fab.tsx +++ b/src/components/insights/layout-coach-fab.tsx @@ -122,8 +122,27 @@ export function LayoutCoachFab() { }, }); + // v1.22 (M4) — surface a due/surfaced Coach reminder on the FAB. Additive: + // the dot also lights when the daily sweep has flipped a "remind me" reminder + // to due, independent of the nudge seen-stamp (resolving the reminder in the + // ledger clears it). Same cheap, gated, stale-while-revalidate read shape. + const { data: dueReminders } = useQuery({ + queryKey: queryKeys.coachReminders("due,surfaced"), + queryFn: async () => { + const data = await apiGet<{ reminders?: unknown[] } | undefined>( + "/api/coach/reminders?status=due,surfaced", + ); + return data?.reminders ?? []; + }, + enabled: coachAvailable && !onCoachPage, + staleTime: 5 * 60 * 1000, + }); + const hasDueReminders = (dueReminders?.length ?? 0) > 0; + const nudgedAt = status?.nudgedAt ?? null; const unread = isNudgeUnread(status, seenStamp); + // The visual dot lights for either an unread nudge OR a due reminder. + const showDot = unread || hasDueReminders; // The unread dot is visual-only (`aria-hidden`) and the swapped // `aria-label` is not announced on mutation — a screen-reader user @@ -212,7 +231,7 @@ export function LayoutCoachFab() { type="button" size="icon" data-slot="coach-fab" - data-unread={unread ? "true" : undefined} + data-unread={showDot ? "true" : undefined} onClick={handleOpen} aria-label={accessibleLabel} title={accessibleLabel} @@ -257,7 +276,7 @@ export function LayoutCoachFab() { )} >