merge: W7 coach memory, reminders, action cards into v1.22

This commit is contained in:
Marc-André Bombeck committed 2026-06-27 13:39:45 +02:00
commit e2c6e46f2a
33 files changed
+3913 -5

No files matched your search

+478
View File
@@ -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:
+41
View File
@@ -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."
+41
View File
@@ -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."
@@ -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;
+51
View File
@@ -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
+10
View File
@@ -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"
+178
View File
@@ -0,0 +1,178 @@
/**
* PATCH /api/coach/reminders/[id] — confirm / update a reminder's lifecycle.
* DELETE /api/coach/reminders/[id] — soft-delete one reminder.
*
* v1.22 (B2/B6) — the user-confirm + management surface for the Coach episodic
* reminders. `status` confirms a `proposed` reminder (→ active), marks it
* `done` / `dismissed`, or re-activates it; `when` re-schedules (snooze) via the
* closed grammar. The body never carries the note text, so a client can never
* inject or overwrite a reminder's prose — only its lifecycle / timing.
*
* Ownership + existence privacy: every mutation is scoped
* `where: { id, userId, deletedAt: null }` via `updateMany`, so a cross-user id,
* an unknown id, or an already-deleted reminder all resolve to a `count: 0`
* no-op rather than a P2025 throw — the existence channel never leaks across
* accounts. PATCH on a 0-count match returns 404; DELETE returns the idempotent
* `{ deleted: false }`.
*/
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { resolveWhenGrammar } from "@/lib/ai/coach/reminders";
import { coachReminderPatchSchema } from "@/lib/validations/coach-reminder";
interface RouteCtx {
params: Promise<{ id: string }>;
}
const MUTATE_RATE_LIMIT = 40;
const MUTATE_WINDOW_MS = 60_000;
async function enforceMutateLimit(
op: string,
userId: string,
): Promise<Response | null> {
const rl = await checkRateLimit(
`coach-reminders:${op}:${userId}`,
MUTATE_RATE_LIMIT,
MUTATE_WINDOW_MS,
);
if (rl.allowed) return null;
const response = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "coach");
if (!gate.enabled) return gate.response;
const limited = await enforceMutateLimit("patch", user.id);
if (limited) return limited;
const { id } = await ctx.params;
const { data: body, error: jsonError } = await safeJson(req, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = coachReminderPatchSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
// Field-by-field (no mass assignment): only the lifecycle / timing fields the
// body carried are written; the note + encrypted text are never touched here.
const data: {
status?: string;
dueAt?: Date | null;
triggerKind?: string;
contextCue?: string | null;
} = {};
if (parsed.data.status !== undefined) data.status = parsed.data.status;
if (parsed.data.when !== undefined) {
if (parsed.data.when === null) {
data.dueAt = null;
data.triggerKind = "date";
data.contextCue = null;
} else {
const trigger = resolveWhenGrammar(parsed.data.when, new Date());
if (!trigger) return apiError("coach.reminder.invalidWhen", 422);
data.dueAt = trigger.dueAt;
data.triggerKind = trigger.triggerKind;
data.contextCue = trigger.contextCue;
}
}
const { count } = await prisma.coachReminder.updateMany({
where: { id, userId: user.id, deletedAt: null },
data,
});
if (count === 0) return apiError("Reminder not found", 404);
const row = await prisma.coachReminder.findFirst({
where: { id, userId: user.id },
select: {
id: true,
metric: true,
noteEncrypted: true,
triggerKind: true,
dueAt: true,
contextCue: true,
status: true,
source: true,
createdAt: true,
updatedAt: true,
},
});
if (!row) return apiError("Reminder not found", 404);
let note: string | null = null;
try {
note = decryptFromBytes(row.noteEncrypted);
} catch {
note = null;
}
annotate({
action: { name: "coach.reminders.updated" },
meta: { status: row.status },
});
return apiSuccess({
reminder: {
id: row.id,
note,
metric: row.metric,
triggerKind: row.triggerKind,
dueAt: row.dueAt?.toISOString() ?? null,
contextCue: row.contextCue,
status: row.status,
source: row.source,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
},
});
});
export const DELETE = apiHandler(
async (_request: NextRequest, ctx: RouteCtx) => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "coach");
if (!gate.enabled) return gate.response;
const limited = await enforceMutateLimit("delete", user.id);
if (limited) return limited;
const { id } = await ctx.params;
const { count } = await prisma.coachReminder.updateMany({
where: { id, userId: user.id, deletedAt: null },
data: { deletedAt: new Date() },
});
const deleted = count > 0;
annotate({
action: { name: "coach.reminders.deleted" },
meta: { deleted },
});
return apiSuccess({ deleted });
},
);
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
@@ -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<typeof import("@/lib/logging/context")>();
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<Response>)(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<Response>)(jsonReq(body));
const callPatch = (body: unknown, id = "r1") =>
(
PATCH as unknown as (
req: NextRequest,
ctx: { params: Promise<{ id: string }> },
) => Promise<Response>
)(
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<Response>
)(
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<Record<string, unknown>> };
};
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<string, unknown>;
};
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);
});
});
+202
View File
@@ -0,0 +1,202 @@
/**
* GET /api/coach/reminders — list the caller's Coach episodic reminders.
* POST /api/coach/reminders — create one manually (the ledger "+ add" path).
*
* v1.22 (B2/B6) — the management + in-app-surface read for the durable
* "remind me about X" memory the Coach captures inline via the `---REMEMBER---`
* sentinel. The note free-text is decrypted on the fly; an undecryptable row (a
* key rotated out of the map) is skipped rather than 500ing the whole list.
*
* Ownership: every query is scoped `where: { userId, ... }`, so a caller can
* only ever see / create their own reminders. The owner is always narrowed from
* the session, never the body. Coach-gated by the same
* `requireModuleEnabled(userId, "coach")` kill-switch as the rest of the Coach
* management stack.
*
* `?status=` optionally filters (one status, or a comma set like `due,surfaced`
* for the in-app tile). Omitted returns the non-terminal set (proposed / active
* / due / surfaced), soonest-due first.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit } from "@/lib/rate-limit";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
resolveWhenGrammar,
MAX_REMINDERS_PER_USER,
} from "@/lib/ai/coach/reminders";
import {
coachRemindersListQuerySchema,
coachReminderCreateSchema,
} from "@/lib/validations/coach-reminder";
const NON_TERMINAL = ["proposed", "active", "due", "surfaced"] as const;
interface ReminderRow {
id: string;
metric: string | null;
noteEncrypted: Uint8Array;
triggerKind: string;
dueAt: Date | null;
contextCue: string | null;
status: string;
source: string;
createdAt: Date;
updatedAt: Date;
}
function toDto(row: ReminderRow, note: string) {
return {
id: row.id,
note,
metric: row.metric,
triggerKind: row.triggerKind,
dueAt: row.dueAt?.toISOString() ?? null,
contextCue: row.contextCue,
status: row.status,
source: row.source,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
export const GET = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "coach");
if (!gate.enabled) return gate.response;
const url = new URL(req.url);
const parsed = coachRemindersListQuerySchema.safeParse({
status: url.searchParams.get("status") ?? undefined,
});
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const statusWhere = parsed.data.status
? { status: { in: parsed.data.status } }
: { status: { in: [...NON_TERMINAL] } };
const rows = (await prisma.coachReminder.findMany({
where: { userId: user.id, deletedAt: null, ...statusWhere },
orderBy: [{ dueAt: "asc" }, { updatedAt: "desc" }],
select: {
id: true,
metric: true,
noteEncrypted: true,
triggerKind: true,
dueAt: true,
contextCue: true,
status: true,
source: true,
createdAt: true,
updatedAt: true,
},
})) as ReminderRow[];
const reminders: ReturnType<typeof toDto>[] = [];
for (const row of rows) {
let note: string;
try {
note = decryptFromBytes(row.noteEncrypted);
} catch {
// Fail closed per row — never surface ciphertext, never 500 the list.
continue;
}
reminders.push(toDto(row, note));
}
annotate({
action: { name: "coach.reminders.listed" },
meta: { count: reminders.length },
});
return apiSuccess({ reminders });
});
export const POST = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "coach");
if (!gate.enabled) return gate.response;
const rate = await checkRateLimit(
`coach-reminders:create:${user.id}`,
30,
60_000,
);
if (!rate.allowed) return apiError("Too many requests", 429);
const { data: body, error: jsonError } = await safeJson(req, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = coachReminderCreateSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
// Resolve the optional `when` against the closed grammar; an invalid token
// 422s rather than silently dropping the timing the user asked for.
const now = new Date();
let trigger: ReturnType<typeof resolveWhenGrammar> = null;
if (parsed.data.when) {
trigger = resolveWhenGrammar(parsed.data.when, now);
if (!trigger) return apiError("coach.reminder.invalidWhen", 422);
}
// Per-user cap on the non-terminal set.
const nonTerminal = await prisma.coachReminder.count({
where: {
userId: user.id,
deletedAt: null,
status: { in: [...NON_TERMINAL] },
},
});
if (nonTerminal >= MAX_REMINDERS_PER_USER) {
return apiError("coach.reminder.capReached", 409);
}
// Field-by-field — no mass assignment. Manual reminders are active on create.
const created = await prisma.coachReminder.create({
data: {
userId: user.id,
noteEncrypted: encryptToBytes(parsed.data.note),
metric: parsed.data.metric ?? null,
triggerKind: trigger?.triggerKind ?? "date",
dueAt: trigger?.dueAt ?? null,
contextCue: trigger?.contextCue ?? null,
status: "active",
source: "manual",
},
select: {
id: true,
metric: true,
noteEncrypted: true,
triggerKind: true,
dueAt: true,
contextCue: true,
status: true,
source: true,
createdAt: true,
updatedAt: true,
},
});
annotate({
action: { name: "coach.reminders.created" },
meta: { triggerKind: created.triggerKind, hasDue: created.dueAt != null },
});
return apiSuccess(
{ reminder: toDto(created as ReminderRow, parsed.data.note) },
201,
);
});
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
@@ -0,0 +1,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<typeof import("@/lib/logging/context")>();
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<Response>)(
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);
});
});
@@ -0,0 +1,188 @@
/**
* POST /api/coach/suggested-actions — confirm a generalised Coach action card.
*
* v1.22 (F6) — the confirm half of the generalised propose→confirm moat. The
* Coach proposes an action via the closed `---SUGGEST-ACTION---` sentinel →
* `suggestedAction` SSE frame → `<SuggestedActionCard>`. This endpoint is what
* the card's confirm button calls. It switches on the CLOSED `actionType`
* allowlist and, for each, builds the entity FIELD-BY-FIELD from a
* server-resolved spec:
*
* - `checkup.create` → a preventive-care Vorsorge `MeasurementReminder`
* (free-text label + a closed interval id the SERVER resolves to an RRULE),
* minted through the SAME engine the Vorsorge surface uses.
* - `reminder.note` → a `CoachReminder` (note + optional closed `when`
* grammar + optional metric).
*
* HARD SAFETY: never auto-apply (this endpoint runs only on the explicit
* confirm tap); closed allowlist only (the discriminated-union Zod gate rejects
* any other `actionType`); NEVER a medication / dose / clinical-target change.
* Cookie-auth, coach module-gated, per-user rate-limited, owner-scoped — no
* IDOR, no mass assignment.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
resolveWhenGrammar,
MAX_REMINDERS_PER_USER,
} from "@/lib/ai/coach/reminders";
import { CHECKUP_INTERVAL_CATALOG } from "@/lib/ai/coach/suggest-action";
import { coachSuggestedActionSchema } from "@/lib/validations/coach-reminder";
import {
computeReminderNextDueAt,
type ReminderScheduleInput,
} from "@/lib/measurement-reminders/scheduling";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
const DEFAULT_TIMEZONE = "Europe/Berlin";
const CHECKUP_NOTIFY_HOUR = 9;
async function postAction(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "coach");
if (!gate.enabled) return gate.response;
const rate = await checkRateLimit(
`coach:suggested-action:${user.id}`,
30,
60_000,
);
if (!rate.allowed) return apiError("coach.action.rateLimited", 429);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = coachSuggestedActionSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const action = parsed.data;
// ── checkup.create → a preventive-care Vorsorge reminder ─────
if (action.actionType === "checkup.create") {
// The model named an interval id; the SERVER owns the RRULE so the model
// can never mint an arbitrary schedule.
const preset = CHECKUP_INTERVAL_CATALOG[action.interval];
if (!preset) return apiError("coach.action.unknownInterval", 422);
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { timezone: true },
});
const timezone = row?.timezone || DEFAULT_TIMEZONE;
const now = new Date();
const scheduleInput: ReminderScheduleInput = {
intervalDays: null,
rrule: preset.rrule,
anchorDate: null,
notifyHour: CHECKUP_NOTIFY_HOUR,
lastSatisfiedAt: null,
createdAt: now,
endsOn: null,
};
const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now);
// Field-by-field — no mass assignment. A free-text checklist Vorsorge
// (measurementType null) so it resolves only on a manual "done". origin
// COACH marks the provenance.
const created = await prisma.measurementReminder.create({
data: {
userId: user.id,
label: action.label,
measurementType: null,
intervalDays: null,
rrule: preset.rrule,
anchorDate: null,
endsOn: null,
origin: "COACH",
notifyHour: CHECKUP_NOTIFY_HOUR,
location: null,
enabled: true,
nextDueAt,
},
});
await auditLog("measurementReminder.create.coachAction", {
userId: user.id,
ipAddress: getClientIp(request),
details: { reminderId: created.id, interval: action.interval },
});
annotate({
action: { name: "coach.action.applied" },
meta: { actionType: action.actionType, interval: action.interval },
});
return apiSuccess(
{
ok: true,
actionType: action.actionType,
reminder: toMeasurementReminderDto(created),
},
201,
);
}
// ── reminder.note → a CoachReminder ──────────────────────────
const now = new Date();
let trigger: ReturnType<typeof resolveWhenGrammar> = null;
if (action.when) {
trigger = resolveWhenGrammar(action.when, now);
if (!trigger) return apiError("coach.reminder.invalidWhen", 422);
}
const nonTerminal = await prisma.coachReminder.count({
where: {
userId: user.id,
deletedAt: null,
status: { in: ["proposed", "active", "due", "surfaced"] },
},
});
if (nonTerminal >= MAX_REMINDERS_PER_USER) {
return apiError("coach.reminder.capReached", 409);
}
const created = await prisma.coachReminder.create({
data: {
userId: user.id,
noteEncrypted: encryptToBytes(action.note),
metric: action.metric ?? null,
triggerKind: trigger?.triggerKind ?? "date",
dueAt: trigger?.dueAt ?? null,
contextCue: trigger?.contextCue ?? null,
status: "active",
source: "action",
},
select: { id: true },
});
annotate({
action: { name: "coach.action.applied" },
meta: {
actionType: action.actionType,
hasDue: trigger?.dueAt != null,
},
});
return apiSuccess(
{ ok: true, actionType: action.actionType, reminderId: created.id },
201,
);
}
export const POST = apiHandler(postAction);
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
+72 -1
View File
@@ -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<Response> {
// 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
@@ -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 (
<p
role="status"
data-slot="coach-suggested-action-done"
className="text-muted-foreground flex items-center gap-1.5 text-xs"
>
<Check className="text-dracula-green size-3.5" aria-hidden="true" />
{t(
settled === "applied"
? "coach.suggestedAction.applied"
: "coach.suggestedAction.dismissed",
)}
</p>
);
}
const busy = apply.isPending;
const intervalLabel =
action.params.actionType === "checkup.create"
? t(`coach.suggestedAction.interval.${action.params.interval}`)
: null;
return (
<div
data-slot="coach-suggested-action-card"
className={cn(
"border-border/60 bg-muted/40 flex flex-col gap-2.5 rounded-xl border",
"px-3.5 py-3 text-sm",
)}
>
<div className="flex items-start gap-2">
<div
aria-hidden="true"
className="from-dracula-purple to-dracula-cyan mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
>
<Sparkles className="text-background size-3.5" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-muted-foreground text-xs font-medium">
{t(action.titleKey)}
</span>
<span className="text-foreground leading-relaxed break-words">
{action.summary}
</span>
{intervalLabel && (
<span className="text-muted-foreground text-xs">
{intervalLabel}
</span>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
data-slot="coach-suggested-action-apply"
onClick={() => apply.mutate()}
disabled={busy}
className={cn(
"bg-dracula-purple/90 text-background hover:bg-dracula-purple",
"focus-visible:ring-ring/50 inline-flex min-h-9 items-center gap-1.5",
"rounded-md px-3 py-1.5 text-xs font-medium outline-none",
"focus-visible:ring-2 disabled:opacity-50",
)}
>
{busy ? (
<Loader2
className="size-3.5 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<Check className="size-3.5" aria-hidden="true" />
)}
{t("coach.suggestedAction.apply")}
</button>
<button
type="button"
data-slot="coach-suggested-action-dismiss"
onClick={() => setSettled("dismissed")}
disabled={busy}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-9 items-center rounded-md px-2.5 py-1.5 text-xs outline-none focus-visible:ring-2 disabled:opacity-50"
>
{t("coach.suggestedAction.dismiss")}
</button>
</div>
</div>
);
}
+21 -2
View File
@@ -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() {
)}
>
<Sparkles className="text-background size-6" aria-hidden="true" />
{unread ? (
{showDot ? (
// v1.18.6 (CCH-03) — discreet "the Coach said something" dot.
// Deliberately NOT an alarming red (the medication-card rule:
// status via calm signal, never an alarm tint): a small cyan
@@ -0,0 +1,212 @@
"use client";
/**
* v1.22 (B6) — Settings → AI "Your context: reminders" panel.
*
* The user-facing ledger for the durable "remind me about X" memory the Coach
* captures inline (the `---REMEMBER---` sentinel) and the sweep resurfaces. The
* due / surfaced reminders are highlighted at the top (the in-app surface of the
* sweep output); the rest are listed with view + lifecycle controls so the user
* stays in control of their stored memory:
*
* GET /api/coach/reminders → { data: { reminders: [...] } }
* PATCH /api/coach/reminders/{id} {status} → confirm / done / dismiss
* DELETE /api/coach/reminders/{id} → { data: { deleted } }
*
* Reads unwrap `(await res.json()).data`; every key routes through
* `queryKeys.coachReminders()` so a mutation invalidates the list. Gated on the
* Coach surface like the rest of the memory controls.
*/
import { useMemo } from "react";
import { BellRing, Check, Loader2, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { formatDateOrRelative } from "@/lib/format";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import {
useCoachReminders,
useCoachReminderMutations,
type CoachReminderDTO,
} from "@/hooks/use-coach-reminders";
const DUE_STATUSES = new Set(["due", "surfaced"]);
export function CoachRemindersSection({
isAuthenticated,
}: {
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
const query = useCoachReminders({ enabled: isAuthenticated });
const { setStatus, remove } = useCoachReminderMutations();
const reminders = useMemo(() => query.data ?? [], [query.data]);
const { due, rest } = useMemo(() => {
const due: CoachReminderDTO[] = [];
const rest: CoachReminderDTO[] = [];
for (const r of reminders) {
(DUE_STATUSES.has(r.status) ? due : rest).push(r);
}
return { due, rest };
}, [reminders]);
const pendingId =
(setStatus.isPending && setStatus.variables?.id) ||
(remove.isPending && remove.variables) ||
null;
const row = (r: CoachReminderDTO, highlighted: boolean) => {
const busy = pendingId === r.id;
const isProposed = r.status === "proposed";
return (
<li
key={r.id}
data-testid="settings-coach-reminder"
data-status={r.status}
className={
highlighted
? "border-dracula-purple/40 bg-dracula-purple/5 flex flex-col gap-2 rounded-lg border p-3"
: "border-border bg-background flex flex-col gap-2 rounded-lg border p-3"
}
>
<p className="text-sm break-words">{r.note}</p>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
{r.metric && <span className="uppercase">{r.metric}</span>}
{r.dueAt && (
<span>
{t("settings.ai.coachReminders.duePrefix", {
when: formatDateOrRelative(r.dueAt, t),
})}
</span>
)}
<span>{t(`settings.ai.coachReminders.status.${r.status}`)}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{isProposed && (
<Button
type="button"
variant="outline"
size="sm"
className="min-h-9"
disabled={!isAuthenticated || busy}
data-testid="settings-coach-reminder-confirm"
onClick={() => setStatus.mutate({ id: r.id, status: "active" })}
>
<Check className="size-3.5" aria-hidden />
{t("settings.ai.coachReminders.confirm")}
</Button>
)}
{r.status !== "done" && (
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-9"
disabled={!isAuthenticated || busy}
data-testid="settings-coach-reminder-done"
onClick={() => setStatus.mutate({ id: r.id, status: "done" })}
>
<Check className="size-3.5" aria-hidden />
{t("settings.ai.coachReminders.markDone")}
</Button>
)}
{(r.status === "due" || r.status === "surfaced") && (
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-9"
disabled={!isAuthenticated || busy}
data-testid="settings-coach-reminder-dismiss"
onClick={() =>
setStatus.mutate({ id: r.id, status: "dismissed" })
}
>
<X className="size-3.5" aria-hidden />
{t("settings.ai.coachReminders.dismiss")}
</Button>
)}
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-destructive min-h-9"
disabled={!isAuthenticated || busy}
data-testid="settings-coach-reminder-delete"
aria-label={t("settings.ai.coachReminders.deleteAria")}
onClick={() => remove.mutate(r.id)}
>
{busy ? (
<Loader2
className="size-3.5 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
<Trash2 className="size-3.5" aria-hidden />
)}
</Button>
</div>
</li>
);
};
return (
<SettingsCard
as="section"
aria-labelledby="settings-ai-coach-reminders-title"
data-testid="settings-coach-reminders-card"
className="space-y-4"
>
<SettingsCardHeader
icon={BellRing}
titleId="settings-ai-coach-reminders-title"
title={t("settings.ai.coachReminders.title")}
description={t("settings.ai.coachReminders.description")}
/>
{query.isError && (
<p
role="status"
aria-live="polite"
className="text-destructive text-sm"
>
{t("settings.ai.coachReminders.loadError")}
</p>
)}
{!query.isError && reminders.length === 0 ? (
<p
data-testid="settings-coach-reminders-empty"
className="text-muted-foreground text-sm"
>
{t("settings.ai.coachReminders.empty")}
</p>
) : (
<div className="space-y-5">
{due.length > 0 && (
<div className="space-y-2">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("settings.ai.coachReminders.dueHeading")}
</h3>
<ul className="space-y-2">{due.map((r) => row(r, true))}</ul>
</div>
)}
{rest.length > 0 && (
<div className="space-y-2">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("settings.ai.coachReminders.allHeading")}
</h3>
<ul className="space-y-2">{rest.map((r) => row(r, false))}</ul>
</div>
)}
</div>
)}
<p className="text-muted-foreground border-border border-t pt-3 text-xs">
{t("settings.ai.coachReminders.note")}
</p>
</SettingsCard>
);
}
@@ -29,6 +29,7 @@
import { DisableCoachCard } from "@/components/settings/ai/disable-coach-card";
import { CoachMemorySection } from "@/components/settings/coach-memory-section";
import { CoachRemindersSection } from "@/components/settings/coach-reminders-section";
import { CoachNudgeCard } from "@/components/settings/coach-nudge-card";
import { CoachPrefsSection } from "@/components/settings/coach-prefs-section";
import { useAuth } from "@/hooks/use-auth";
@@ -62,6 +63,8 @@ export function CoachSection() {
)}
{coachEnabled && <CoachMemorySection isAuthenticated={authed} />}
{coachEnabled && <CoachRemindersSection isAuthenticated={authed} />}
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
/**
* v1.22 (B2/B6) — read + mutate the user's Coach episodic reminders.
*
* One shared hook for the in-app reminder tile/indicator and the "Your context"
* ledger. Reads unwrap `(await res.json()).data` per the envelope convention;
* every key routes through `queryKeys.coachReminders(status)` so a lifecycle
* mutation invalidates the matching list. The `status` arg keys the tile read
* (`due,surfaced`) separately from the full ledger so they never share a slot.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query-keys";
import { apiDelete, apiGet, apiPatch } from "@/lib/api/api-fetch";
export interface CoachReminderDTO {
id: string;
note: string;
metric: string | null;
triggerKind: string;
dueAt: string | null;
contextCue: string | null;
status: string;
source: string;
createdAt: string;
updatedAt: string;
}
async function fetchReminders(status?: string): Promise<CoachReminderDTO[]> {
const qs = status ? `?status=${encodeURIComponent(status)}` : "";
const data = await apiGet<{ reminders?: CoachReminderDTO[] } | undefined>(
`/api/coach/reminders${qs}`,
);
return data?.reminders ?? [];
}
/** List the caller's reminders, optionally filtered to a status set. */
export function useCoachReminders(opts?: {
status?: string;
enabled?: boolean;
}) {
const status = opts?.status;
return useQuery({
queryKey: queryKeys.coachReminders(status),
queryFn: () => fetchReminders(status),
enabled: opts?.enabled ?? true,
});
}
/** Lifecycle + delete mutations, invalidating every reminders list slot. */
export function useCoachReminderMutations() {
const queryClient = useQueryClient();
const invalidate = () =>
queryClient.invalidateQueries({
queryKey: queryKeys.coachRemindersAll(),
});
const setStatus = useMutation({
mutationFn: async (args: { id: string; status: string }) => {
await apiPatch(`/api/coach/reminders/${encodeURIComponent(args.id)}`, {
status: args.status,
});
return args.id;
},
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: async (id: string) => {
await apiDelete(`/api/coach/reminders/${encodeURIComponent(id)}`);
return id;
},
onSuccess: invalidate,
});
return { setStatus, remove };
}
@@ -0,0 +1,177 @@
/**
* v1.22 (B2) — Coach episodic reminder capture: grammar + sentinel + write.
*
* Covers the closed `when` grammar (ISO date / relative / context cue / reject),
* the `---REMEMBER---` strip (capture, recall-only note, malformed drop, prose
* preserved), and the cap-aware capture write.
*/
import { describe, it, expect, vi } from "vitest";
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("../bytes-codec", () => ({
encryptToBytes: (s: string) => new Uint8Array(Buffer.from(`enc:${s}`)),
}));
import {
resolveWhenGrammar,
parseRememberSentinel,
captureReminderFromSentinel,
MAX_REMINDERS_PER_USER,
} from "../reminders";
const NOW = new Date("2026-06-27T12:00:00.000Z");
describe("resolveWhenGrammar", () => {
it("parses an ISO date into a date trigger at 09:00 UTC", () => {
const r = resolveWhenGrammar("2026-07-11", NOW);
expect(r).toEqual({
triggerKind: "date",
dueAt: new Date("2026-07-11T09:00:00.000Z"),
contextCue: null,
});
});
it("parses a relative +Nd / +Nw offset from now", () => {
expect(resolveWhenGrammar("+14d", NOW)?.dueAt).toEqual(
new Date("2026-07-11T12:00:00.000Z"),
);
expect(resolveWhenGrammar("+2w", NOW)?.dueAt).toEqual(
new Date("2026-07-11T12:00:00.000Z"),
);
});
it("parses a context cue into a context trigger (no dueAt)", () => {
expect(resolveWhenGrammar("NEXT_BP_LOGGED", NOW)).toEqual({
triggerKind: "context",
dueAt: null,
contextCue: "NEXT_BP_LOGGED",
});
});
it("rejects an unknown / out-of-bounds token", () => {
expect(resolveWhenGrammar("someday", NOW)).toBeNull();
expect(resolveWhenGrammar("+0d", NOW)).toBeNull();
expect(resolveWhenGrammar("+9999d", NOW)).toBeNull();
expect(resolveWhenGrammar("NEXT_MOON", NOW)).toBeNull();
});
});
describe("parseRememberSentinel", () => {
it("captures note + when + metric and strips the block from the prose", () => {
const raw = `Got it — I'll bring that up around the 11th.
---REMEMBER---
note: revisit the evening-walk idea for your sleep
when: 2026-07-11
metric: SLEEP
---END---`;
const r = parseRememberSentinel(raw, NOW);
expect(r.malformed).toBe(false);
expect(r.prose).toBe("Got it — I'll bring that up around the 11th.");
expect(r.reminder?.note).toBe(
"revisit the evening-walk idea for your sleep",
);
expect(r.reminder?.metric).toBe("SLEEP");
expect(r.reminder?.trigger?.dueAt).toEqual(
new Date("2026-07-11T09:00:00.000Z"),
);
});
it("captures a recall-only note when `when` is omitted", () => {
const raw = `Noted.
---REMEMBER---
note: you wanted to discuss your labs
---END---`;
const r = parseRememberSentinel(raw, NOW);
expect(r.malformed).toBe(false);
expect(r.reminder?.note).toBe("you wanted to discuss your labs");
expect(r.reminder?.trigger).toBeNull();
});
it("drops the block (malformed) on a missing note or an invalid when, keeping prose", () => {
const noNote = parseRememberSentinel(
"Sure.\n---REMEMBER---\nwhen: 2026-07-11\n---END---",
NOW,
);
expect(noNote.reminder).toBeNull();
expect(noNote.malformed).toBe(true);
expect(noNote.prose).toBe("Sure.");
const badWhen = parseRememberSentinel(
"Sure.\n---REMEMBER---\nnote: x\nwhen: someday\n---END---",
NOW,
);
expect(badWhen.reminder).toBeNull();
expect(badWhen.malformed).toBe(true);
// The raw marker is never left in the prose.
expect(badWhen.prose).not.toContain("REMEMBER");
});
it("returns the prose untouched when no block is present", () => {
const r = parseRememberSentinel("just a normal reply", NOW);
expect(r).toEqual({
prose: "just a normal reply",
reminder: null,
malformed: false,
});
});
});
describe("captureReminderFromSentinel", () => {
function db(count: number) {
return {
coachReminder: {
count: vi.fn(async () => count),
create: vi.fn(async () => ({ id: "r1" })),
},
};
}
it("writes an active reminder field-by-field with source sentinel", async () => {
const d = db(0);
const id = await captureReminderFromSentinel({
userId: "u1",
conversationId: "c1",
parsed: {
note: "revisit sleep",
trigger: {
triggerKind: "date",
dueAt: new Date("2026-07-11T09:00:00.000Z"),
contextCue: null,
},
metric: "SLEEP",
},
db: d as never,
});
expect(id).toBe("r1");
const createArgs = d.coachReminder.create.mock.calls[0] as unknown as [
{
data: {
userId: string;
status: string;
source: string;
sourceConversationId: string;
metric: string;
};
},
];
const data = createArgs[0].data;
expect(data.userId).toBe("u1");
expect(data.status).toBe("active");
expect(data.source).toBe("sentinel");
expect(data.sourceConversationId).toBe("c1");
expect(data.metric).toBe("SLEEP");
});
it("refuses the write once the per-user cap is reached", async () => {
const d = db(MAX_REMINDERS_PER_USER);
const id = await captureReminderFromSentinel({
userId: "u1",
conversationId: "c1",
parsed: { note: "x", trigger: null, metric: null },
db: d as never,
});
expect(id).toBeNull();
expect(d.coachReminder.create).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,94 @@
/**
* v1.22 (F6) — generalised confirm-card sentinel parse.
*
* Covers the CLOSED allowlist (checkup.create / reminder.note), the
* field-by-field resolution, and the safety property that an unknown action
* type or a malformed block yields NO action (never auto-applies, never mints
* an arbitrary entity) while the prose is preserved and the raw marker stripped.
*/
import { describe, it, expect } from "vitest";
import {
parseSuggestAction,
isSuggestedActionType,
isCheckupIntervalId,
} from "../suggest-action";
describe("parseSuggestAction", () => {
it("resolves a checkup.create action with a closed interval id", () => {
const raw = `You might add an annual panel.
---SUGGEST-ACTION---
action: checkup.create
label: Annual blood panel
interval: yearly
---END---`;
const r = parseSuggestAction(raw);
expect(r.malformed).toBe(false);
expect(r.prose).toBe("You might add an annual panel.");
expect(r.action?.actionType).toBe("checkup.create");
expect(r.action?.summary).toBe("Annual blood panel");
expect(r.action?.params).toEqual({
actionType: "checkup.create",
label: "Annual blood panel",
interval: "yearly",
});
});
it("resolves a reminder.note action with the closed when grammar token", () => {
const raw = `---SUGGEST-ACTION---
action: reminder.note
note: revisit the evening-walk effect on sleep
when: +14d
metric: SLEEP
---END---`;
const r = parseSuggestAction(raw);
expect(r.action?.params).toEqual({
actionType: "reminder.note",
note: "revisit the evening-walk effect on sleep",
when: "+14d",
metric: "SLEEP",
});
});
it("drops an unknown / off-allowlist action type (no entity, prose kept)", () => {
const raw = `ok.
---SUGGEST-ACTION---
action: medication.create
label: start a new drug
interval: monthly
---END---`;
const r = parseSuggestAction(raw);
expect(r.action).toBeNull();
expect(r.malformed).toBe(true);
expect(r.prose).toBe("ok.");
expect(r.prose).not.toContain("SUGGEST-ACTION");
});
it("drops a checkup with an unknown interval id", () => {
const raw = `---SUGGEST-ACTION---
action: checkup.create
label: x
interval: hourly
---END---`;
const r = parseSuggestAction(raw);
expect(r.action).toBeNull();
expect(r.malformed).toBe(true);
});
it("passes the prose through untouched when no block is present", () => {
const r = parseSuggestAction("a normal reply");
expect(r).toEqual({
prose: "a normal reply",
action: null,
malformed: false,
});
});
it("closed-allowlist guards reject anything off-list", () => {
expect(isSuggestedActionType("checkup.create")).toBe(true);
expect(isSuggestedActionType("reminder.note")).toBe(true);
expect(isSuggestedActionType("medication.create")).toBe(false);
expect(isCheckupIntervalId("yearly")).toBe(true);
expect(isCheckupIntervalId("hourly")).toBe(false);
});
});
+29 -2
View File
@@ -39,7 +39,12 @@ import {
} from "@/lib/insights/narrative/period-narrative";
import type { BaselineProfile } from "@/lib/insights/derived";
import { buildCoachFactsBlock } from "./facts";
import { buildCoachPlansBlock, type CoachPlanInjectEntry } from "./plans";
import {
buildCoachPlansBlock,
buildCoachRemindersBlock,
type CoachPlanInjectEntry,
type CoachReminderInjectEntry,
} from "./plans";
/** The period the rolling profile recalls — month is the high-signal beat. */
const MEMORY_PERIOD: NarrativePeriod = "month";
@@ -85,6 +90,13 @@ export interface CoachMemoryBlock {
* still awaiting the user's confirmation. Absent when none.
*/
plans?: CoachPlanInjectEntry[];
/**
* v1.22 (B2/B3) — the user's near/overdue or already-surfaced episodic
* reminders ("remind me about X"), top-N soonest first. Lets the Coach
* reference them unprompted next turn — the progress-reflection reflex.
* Absent when none.
*/
reminders?: CoachReminderInjectEntry[];
}
/** Pull the headline + driver recall off the latest period narrative. */
@@ -185,11 +197,25 @@ export async function buildCoachMemoryBlock(
plans = undefined;
}
// Sub-source 5 (v1.22 B2/B3): the user's near/overdue or surfaced episodic
// reminders. Fault-isolated like the others; the read half lives in plans.ts
// alongside the plan-injection block.
let reminders: CoachReminderInjectEntry[] | undefined;
try {
const remindersBlock = await buildCoachRemindersBlock(userId, now);
if (remindersBlock && remindersBlock.reminders.length > 0) {
reminders = remindersBlock.reminders;
}
} catch {
reminders = undefined;
}
if (
!priorNarrative &&
Object.keys(trendMemory).length === 0 &&
!facts &&
!plans
!plans &&
!reminders
) {
return null;
}
@@ -198,5 +224,6 @@ export async function buildCoachMemoryBlock(
if (priorNarrative) block.priorNarrative = priorNarrative;
if (facts) block.facts = facts;
if (plans) block.plans = plans;
if (reminders) block.reminders = reminders;
return block;
}
+93
View File
@@ -39,6 +39,7 @@ import { runStatusCompletion } from "@/lib/insights/status-provider";
import { annotate } from "@/lib/logging/context";
import { decryptFromBytes, encryptToBytes } from "./bytes-codec";
import { REMINDERS_INJECT_TOP_N } from "./reminders";
/** App-side closed status enum (NOT a DB enum — matches the schema column). */
export const COACH_PLAN_STATUSES = [
@@ -60,6 +61,7 @@ const RECENT_TURNS_CAP = 10;
type RunCompletionFn = typeof runStatusCompletion;
type PrismaLike = Pick<typeof prisma, "coachPlan" | "coachConversation">;
type ReminderPrismaLike = Pick<typeof prisma, "coachReminder">;
interface ExtractOpts {
runCompletion?: RunCompletionFn;
@@ -462,3 +464,94 @@ export async function buildCoachPlansBlock(
if (plans.length === 0) return null;
return { plans };
}
// ---------------------------------------------------------------------------
// v1.22 (B2/B3) — episodic reminder recall sub-block
// ---------------------------------------------------------------------------
/**
* How far ahead of `now` an active reminder enters the recall block. A
* reminder due within this horizon (or already overdue) is "near" enough that
* the Coach should reference it unprompted on the next message; a reminder due
* far in the future stays dormant until its window approaches.
*/
const REMINDER_RECALL_HORIZON_DAYS = 3;
const REMINDER_RECALL_HORIZON_MS = REMINDER_RECALL_HORIZON_DAYS * 86_400_000;
/** One reminder as the snapshot recall block carries it. */
export interface CoachReminderInjectEntry {
note: string;
metric?: string;
/** ISO due moment, when the reminder is date-triggered. */
dueAt?: string;
/** Lifecycle status (active | due | surfaced) for the model's framing. */
status: string;
}
interface InjectReminderRow {
noteEncrypted: Uint8Array;
metric: string | null;
dueAt: Date | null;
status: string;
updatedAt: Date;
}
/**
* Build the recall sub-block: the reminders the Coach should reference next
* time — anything already `due`/`surfaced` (the sweep flipped it and the user
* has not resolved it) plus `active` reminders whose `dueAt` is near or overdue.
* A recall-only `active` note (no `dueAt`) is also surfaced so the Coach honours
* the user's explicit "remember this" even without a fire moment. Decrypt is
* fault-isolated — an undecryptable row is skipped, never thrown. Returns `null`
* when the user has nothing to recall.
*
* This is the read half of the episodic memory type, mirroring
* `buildCoachPlansBlock`; the write half lives in `reminders.ts`.
*/
export async function buildCoachRemindersBlock(
userId: string,
now: Date,
opts?: { prisma?: ReminderPrismaLike },
): Promise<{ reminders: CoachReminderInjectEntry[] } | null> {
const db = opts?.prisma ?? prisma;
const horizon = new Date(now.getTime() + REMINDER_RECALL_HORIZON_MS);
const rows = (await db.coachReminder.findMany({
where: {
userId,
deletedAt: null,
OR: [
// Already surfaced by the sweep and not yet resolved.
{ status: { in: ["due", "surfaced"] } },
// Active + near/overdue date trigger.
{ status: "active", dueAt: { lte: horizon } },
// Active recall-only note (no fire moment) — honour the explicit ask.
{ status: "active", dueAt: null },
],
},
// Soonest-due first, then most-recently touched.
orderBy: [{ dueAt: "asc" }, { updatedAt: "desc" }],
take: REMINDERS_INJECT_TOP_N * 3,
select: {
noteEncrypted: true,
metric: true,
dueAt: true,
status: true,
updatedAt: true,
},
})) as InjectReminderRow[];
const reminders: CoachReminderInjectEntry[] = [];
for (const r of rows) {
if (reminders.length >= REMINDERS_INJECT_TOP_N) break;
const note = decryptOrNull(r.noteEncrypted);
if (note === null) continue;
const entry: CoachReminderInjectEntry = { note, status: r.status };
if (r.metric) entry.metric = r.metric;
if (r.dueAt) entry.dueAt = r.dueAt.toISOString();
reminders.push(entry);
}
if (reminders.length === 0) return null;
return { reminders };
}
+319
View File
@@ -0,0 +1,319 @@
/**
* v1.22 (B2/B6) — Coach episodic reminder memory: capture + grammar.
*
* The missing memory type between `CoachFact` (durable traits) and `CoachPlan`
* (standing if-then habits): a time-anchored "bring this back later" intent the
* user explicitly asked the Coach to remember ("remind me about my sleep next
* week"). Captured inline on EVERY turn via the provider-neutral
* `---REMEMBER---` sentinel — so a casual "remember this" in a SHORT chat is no
* longer lost waiting for the >20-turn memory worker.
*
* Friction tier (matches the design): a plain note the user EXPLICITLY asked
* for is captured silently as `status: "active"` — confirming "should I remember
* what you just told me to remember?" is the annoying anti-pattern. The
* propose→confirm rule stays where it belongs: durable traits/plans the model
* INFERRED (those still flow through the extractor + a confirm card).
*
* Defence-in-depth mirrors `suggest-reminder.ts` / `keyvalues.ts`: a byte cap on
* the payload, a single block kept, the `when` validated against a CLOSED
* grammar (ISO date / relative `+Nd`-`+Nw` / a context-cue enum), the metric
* validated against a loose key pattern, and a malformed block dropped (the user
* never sees the raw marker). The note TEXT is encrypted via `bytes-codec.ts`
* (the same AES-256-GCM codec as `CoachPlan`); `metric`, `status`, the trigger
* fields and the dates stay plain so the sweep + picker filter without a decrypt.
*
* Server-only — the capture path reads `@/lib/db`.
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { encryptToBytes } from "./bytes-codec";
/** App-side closed status enum (NOT a DB enum — matches the schema column). */
export const COACH_REMINDER_STATUSES = [
"proposed",
"active",
"due",
"surfaced",
"done",
"dismissed",
] as const;
export type CoachReminderStatus = (typeof COACH_REMINDER_STATUSES)[number];
/** App-side closed trigger-kind enum (matches the schema column). */
export const COACH_REMINDER_TRIGGER_KINDS = ["date", "context"] as const;
export type CoachReminderTriggerKind =
(typeof COACH_REMINDER_TRIGGER_KINDS)[number];
/**
* Closed context-cue enum. A `context` reminder fires when the cue is satisfied
* (evaluated by the daily sweep against persisted rows). The set is deliberately
* small and deterministic — "next time you log X" / "next time you open the app".
*/
export const COACH_REMINDER_CONTEXT_CUES = [
"NEXT_BP_LOGGED",
"NEXT_WEIGHT_LOGGED",
"NEXT_SLEEP_LOGGED",
"NEXT_APP_OPEN",
] as const;
export type CoachReminderContextCue =
(typeof COACH_REMINDER_CONTEXT_CUES)[number];
/** Provenance of a reminder row (matches the schema `source` column). */
export const COACH_REMINDER_SOURCES = [
"sentinel",
"extractor",
"manual",
"action",
] as const;
export type CoachReminderSource = (typeof COACH_REMINDER_SOURCES)[number];
/** Per-field caps (mirror the Zod gate + the prompt instruction). */
export const REMINDER_NOTE_MAX_CHARS = 280;
export const REMINDER_METRIC_MAX_CHARS = 60;
/** Hard cap on non-terminal reminders per user (active + proposed + due/surfaced). */
export const MAX_REMINDERS_PER_USER = 50;
/** How many reminders the recall sub-block carries into the snapshot. */
export const REMINDERS_INJECT_TOP_N = 6;
/** Bounds on the relative-`+Nd` grammar (1 day .. 1 year out). */
const MIN_RELATIVE_DAYS = 1;
const MAX_RELATIVE_DAYS = 366;
const OPEN_SENTINEL = "---REMEMBER---";
const CLOSE_SENTINEL = "---END---";
/** Hard cap on the sentinel payload before parsing (prompt-injection guard). */
const SENTINEL_BYTE_CAP = 1024;
/** A loose metric key the model may name ("SLEEP", "BLOOD_PRESSURE"). */
const METRIC_KEY_RE = /^[A-Za-z][A-Za-z0-9_]*$/;
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const RELATIVE_RE = /^\+(\d{1,3})([dw])$/i;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/** The resolved trigger a valid `when` grammar maps onto. */
export interface ResolvedTrigger {
triggerKind: CoachReminderTriggerKind;
dueAt: Date | null;
contextCue: CoachReminderContextCue | null;
}
function isContextCue(value: string): value is CoachReminderContextCue {
return (COACH_REMINDER_CONTEXT_CUES as readonly string[]).includes(value);
}
/**
* Resolve the closed `when` grammar into a trigger, or `null` when the token is
* unparseable / out of bounds (the caller drops the reminder). Accepts:
* - an ISO calendar date `YYYY-MM-DD` → date trigger, dueAt 09:00 UTC that day
* - a relative `+Nd` / `+Nw` → date trigger, dueAt now + N days/weeks
* - a context-cue token from the enum → context trigger, dueAt null
*/
export function resolveWhenGrammar(
whenRaw: string,
now: Date,
): ResolvedTrigger | null {
const when = whenRaw.trim();
if (!when) return null;
if (ISO_DATE_RE.test(when)) {
// Anchor at 09:00 UTC on the named day — a deterministic, timezone-stable
// fire moment for the daily sweep (push delivery + per-tz receptivity land
// in a later wave; this is in-app surfacing only).
const dueAt = new Date(`${when}T09:00:00.000Z`);
if (Number.isNaN(dueAt.getTime())) return null;
return { triggerKind: "date", dueAt, contextCue: null };
}
const rel = RELATIVE_RE.exec(when);
if (rel) {
const n = Number.parseInt(rel[1], 10);
const unit = rel[2].toLowerCase();
const days = unit === "w" ? n * 7 : n;
if (days < MIN_RELATIVE_DAYS || days > MAX_RELATIVE_DAYS) return null;
return {
triggerKind: "date",
dueAt: new Date(now.getTime() + days * MS_PER_DAY),
contextCue: null,
};
}
const cue = when.toUpperCase();
if (isContextCue(cue)) {
return { triggerKind: "context", dueAt: null, contextCue: cue };
}
return null;
}
/** The fields a valid `---REMEMBER---` block yields. */
export interface ParsedReminderSentinel {
note: string;
/** Resolved trigger when a valid `when` was given; null = a recall-only note. */
trigger: ResolvedTrigger | null;
metric: string | null;
}
export interface RememberParseResult {
/** The reply with the block stripped (run AFTER the other sentinels). */
prose: string;
reminder: ParsedReminderSentinel | null;
/** True when an opening sentinel was seen but yielded no valid reminder. */
malformed: boolean;
}
/** Pull one `key: value` line out of the block body (first match wins). */
function fieldLine(body: string, key: string): string | null {
for (const rawLine of body.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
const colon = line.indexOf(":");
if (colon < 1) continue;
if (line.slice(0, colon).trim().toLowerCase() !== key) continue;
const value = line
.slice(colon + 1)
.trim()
// Strip a trailing inline comment + surrounding quotes/backticks.
.replace(/\s+#.*$/, "")
.replace(/^["'`]|["'`]$/g, "")
.trim();
if (value) return value;
}
return null;
}
/**
* Strip the `---REMEMBER---` block out of a reply and resolve its fields. Run
* AFTER the KEYVALUES + SUGGEST-REMINDER strips so the prose it receives is
* already free of those blocks. A missing `note`, or a present-but-unparseable
* `when`, drops the reminder (`malformed: true`) while still removing the raw
* marker from the prose.
*/
export function parseRememberSentinel(
raw: string,
now: Date,
): RememberParseResult {
if (!raw) return { prose: "", reminder: null, malformed: false };
const openIdx = raw.indexOf(OPEN_SENTINEL);
if (openIdx === -1) return { prose: raw, reminder: null, malformed: false };
const before = raw.slice(0, openIdx);
const afterOpen = raw.slice(openIdx + OPEN_SENTINEL.length);
const closeRel = afterOpen.indexOf(CLOSE_SENTINEL);
const bodyRaw = closeRel === -1 ? afterOpen : afterOpen.slice(0, closeRel);
const body =
bodyRaw.length > SENTINEL_BYTE_CAP
? bodyRaw.slice(0, SENTINEL_BYTE_CAP)
: bodyRaw;
const after =
closeRel === -1 ? "" : afterOpen.slice(closeRel + CLOSE_SENTINEL.length);
const prose = `${before}${after}`.replace(/^\s+|\s+$/gu, "");
const noteRaw = fieldLine(body, "note");
if (!noteRaw) return { prose, reminder: null, malformed: true };
const note = noteRaw.slice(0, REMINDER_NOTE_MAX_CHARS).trim();
if (!note) return { prose, reminder: null, malformed: true };
// `when` is optional. Present-but-invalid is a malformed drop; absent is a
// recall-only note (active, no due moment).
const whenRaw = fieldLine(body, "when");
let trigger: ResolvedTrigger | null = null;
if (whenRaw) {
trigger = resolveWhenGrammar(whenRaw, now);
if (!trigger) return { prose, reminder: null, malformed: true };
}
let metric: string | null = null;
const metricRaw = fieldLine(body, "metric");
if (metricRaw && METRIC_KEY_RE.test(metricRaw)) {
metric = metricRaw.slice(0, REMINDER_METRIC_MAX_CHARS).toUpperCase();
}
return { prose, reminder: { note, trigger, metric }, malformed: false };
}
/**
* The one system-prompt clause that teaches the model to emit `---REMEMBER---`.
* Appended to the assembled Coach system prompt in the chat route (the canonical
* system-prompt module is owned elsewhere). Provider-neutral; EN + DE only (the
* Coach reasons in de/en).
*/
export function buildRememberAddendum(locale: string | undefined): string {
if (locale?.toLowerCase().startsWith("de")) {
return `WENN die Person dich bittet, dir etwas zu merken oder sie später daran zu erinnern, hänge GENAU EINEN Block ans Ende deiner Antwort:
---REMEMBER---
note: <kurz, in der Formulierung der Person, worum es geht>
when: <optional: ISO-Datum JJJJ-MM-TT | relativ +Nd / +Nw | ein Kontext-Cue: NEXT_BP_LOGGED, NEXT_WEIGHT_LOGGED, NEXT_SLEEP_LOGGED, NEXT_APP_OPEN>
metric: <optional, z. B. SLEEP, BLOOD_PRESSURE>
---END---
Erfinde NIEMALS eine Erinnerung, um die die Person nicht ausdrücklich gebeten hat. Bestätige kurz im Fließtext ("Notiert — ich komme darauf zurück."). Lass "when" weg, wenn keine Zeit genannt wurde.`;
}
return `WHEN the user asks you to remember something or remind them later, append EXACTLY ONE block at the end of your reply:
---REMEMBER---
note: <short, in the user's own framing, what to bring back>
when: <optional: ISO date YYYY-MM-DD | relative +Nd / +Nw | a context cue: NEXT_BP_LOGGED, NEXT_WEIGHT_LOGGED, NEXT_SLEEP_LOGGED, NEXT_APP_OPEN>
metric: <optional, e.g. SLEEP, BLOOD_PRESSURE>
---END---
NEVER invent a reminder the user did not explicitly ask for. Acknowledge briefly in prose ("Got it — I'll bring that up again."). Omit "when" if no time was stated.`;
}
type PrismaLike = Pick<typeof prisma, "coachReminder">;
/**
* Persist a sentinel-captured reminder field-by-field (no mass assignment),
* always `status: "active"` (the user explicitly asked — no confirm gate). The
* per-user cap refuses a write once the non-terminal set is full. Best-effort:
* a failure never breaks the chat turn (the caller fires it fire-and-forget).
*
* Returns the created id, or `null` when the cap displaced it / a write failed.
*/
export async function captureReminderFromSentinel(args: {
userId: string;
conversationId: string;
parsed: ParsedReminderSentinel;
now?: Date;
db?: PrismaLike;
}): Promise<string | null> {
const db = args.db ?? prisma;
const { userId, conversationId, parsed } = args;
const nonTerminal = await db.coachReminder.count({
where: {
userId,
deletedAt: null,
status: { in: ["proposed", "active", "due", "surfaced"] },
},
});
if (nonTerminal >= MAX_REMINDERS_PER_USER) {
annotate({
action: { name: "coach.reminder.capture_capped" },
meta: { conversationId },
});
return null;
}
const created = await db.coachReminder.create({
data: {
userId,
noteEncrypted: encryptToBytes(parsed.note),
metric: parsed.metric,
triggerKind: parsed.trigger?.triggerKind ?? "date",
dueAt: parsed.trigger?.dueAt ?? null,
contextCue: parsed.trigger?.contextCue ?? null,
status: "active",
source: "sentinel",
sourceConversationId: conversationId,
},
select: { id: true },
});
annotate({
action: { name: "coach.reminder.captured" },
meta: {
conversationId,
triggerKind: parsed.trigger?.triggerKind ?? "none",
hasDue: parsed.trigger?.dueAt != null,
},
});
return created.id;
}
+10
View File
@@ -2558,11 +2558,21 @@ function degradeToBudget(
block && Array.isArray(block.plans) && block.plans.length > 0
? block.plans
: null;
// v1.22 (B2/B3) — the episodic reminders survive the drop alongside
// facts/plans: they are the user's own "remember this" asks, tiny by
// construction (top-6, ≤280 chars each), and shedding them would make the
// Coach forget a reminder on exactly the data-heavy accounts that hit the
// char cap.
const reminders =
block && Array.isArray(block.reminders) && block.reminders.length > 0
? block.reminders
: null;
const survivors: Record<string, unknown> = {
omitted: "trimmed for prompt budget",
};
if (facts) survivors.facts = facts;
if (plans) survivors.plans = plans;
if (reminders) survivors.reminders = reminders;
snapshot[key] = survivors;
return true;
};
+252
View File
@@ -0,0 +1,252 @@
/**
* v1.22 (F6) — generalised inline "confirm → apply" Coach action cards.
*
* The propose→confirm moat already ships for ONE action type — the cadence
* reminder (`suggest-reminder.ts` CADENCE_CATALOG → `suggestion` SSE frame →
* `reminder-suggestion-card.tsx` → `POST /api/coach/reminder-suggestions`).
* This module generalises it: the Coach may append a `---SUGGEST-ACTION---`
* block naming ONE action type from a CLOSED allowlist plus bounded,
* server-resolvable params. The route strips it, validates it, and emits an
* additive `suggestedAction` SSE frame the client renders as a one-tap confirm
* card. Confirm POSTs to `POST /api/coach/suggested-actions`, which builds the
* entity FIELD-BY-FIELD from a server-resolved spec.
*
* HARD SAFETY (matches the design §3.6):
* - NEVER auto-apply. The frame is additive; the prose stands alone if
* ignored. Only the user's confirm tap creates anything.
* - CLOSED allowlist only. The model names an action type + bounded params;
* it can never mint an arbitrary entity or widen a schedule (the interval
* is a closed preset id resolved on the server, never a free rrule).
* - NO clinical / medication changes via this path. The allowlist excludes
* anything that creates/edits a medication, a dose, or a clinical target.
*
* Defence-in-depth mirrors the other sentinels: a byte cap, a single block, the
* fields validated, a malformed block dropped (the user never sees the marker).
*/
/**
* The system-prompt clause teaching the model to emit `---SUGGEST-ACTION---`.
* Appended to the assembled Coach system prompt in the chat route. Provider-
* neutral; EN + DE only. Names ONLY the closed allowlist — never medication or
* clinical changes.
*/
export function buildSuggestActionAddendum(locale: string | undefined): string {
if (locale?.toLowerCase().startsWith("de")) {
return `WENN eine deiner Empfehlungen mit einem Tipp umsetzbar ist, biete sie als bestätigbare Aktion an — hänge GENAU EINEN Block ans Ende deiner Antwort. NUR diese geschlossene Liste, NIEMALS Medikamente oder klinische Ziele:
---SUGGEST-ACTION---
action: checkup.create # eine Vorsorge-/Check-up-Erinnerung
label: <kurzer Titel, z. B. Jährliches Blutbild>
interval: <yearly | halfYearly | quarterly | monthly>
---END---
oder
---SUGGEST-ACTION---
action: reminder.note # eine Erinnerung, auf etwas zurückzukommen
note: <kurz, worum es geht>
when: <optional: ISO-Datum | +Nd / +Nw | NEXT_BP_LOGGED …>
metric: <optional>
---END---
Wende NIEMALS etwas selbst an — die Person bestätigt mit einem Tipp. Schlage höchstens eine Aktion pro Antwort vor und nur, wenn sie klar hilfreich ist.`;
}
return `WHEN one of your recommendations is actionable with a reminder, offer it as a confirmable action — append EXACTLY ONE block at the end of your reply. ONLY this closed allowlist, NEVER a medication or clinical target:
---SUGGEST-ACTION---
action: checkup.create # a preventive-care / check-up reminder
label: <short title, e.g. Annual blood panel>
interval: <yearly | halfYearly | quarterly | monthly>
---END---
or
---SUGGEST-ACTION---
action: reminder.note # a reminder to revisit something
note: <short, what to bring back>
when: <optional: ISO date | +Nd / +Nw | NEXT_BP_LOGGED …>
metric: <optional>
---END---
NEVER apply anything yourself — the user confirms with a tap. Suggest at most one action per reply, and only when it is clearly helpful.`;
}
const OPEN_SENTINEL = "---SUGGEST-ACTION---";
const CLOSE_SENTINEL = "---END---";
const SENTINEL_BYTE_CAP = 1024;
/** The CLOSED action-type allowlist. NEVER add a clinical / medication type. */
export const SUGGESTED_ACTION_TYPES = [
"checkup.create",
"reminder.note",
] as const;
export type SuggestedActionType = (typeof SUGGESTED_ACTION_TYPES)[number];
export function isSuggestedActionType(v: string): v is SuggestedActionType {
return (SUGGESTED_ACTION_TYPES as readonly string[]).includes(v);
}
/** Field caps shared with the route Zod gate. */
export const CHECKUP_LABEL_MAX_CHARS = 120;
export const ACTION_NOTE_MAX_CHARS = 280;
export const ACTION_METRIC_MAX_CHARS = 60;
/**
* Closed cadence catalog for `checkup.create`. The model names ONLY a preset
* id; the server owns the RRULE so the model cannot mint an arbitrary schedule.
* A preventive-care item is, by nature, infrequent — the set is deliberately
* coarse (monthly at the tightest).
*/
export const CHECKUP_INTERVAL_CATALOG: Readonly<
Record<string, { id: string; rrule: string; labelKey: string }>
> = Object.freeze({
yearly: {
id: "yearly",
rrule: "FREQ=YEARLY;INTERVAL=1",
labelKey: "coach.suggestedAction.interval.yearly",
},
halfYearly: {
id: "halfYearly",
rrule: "FREQ=MONTHLY;INTERVAL=6",
labelKey: "coach.suggestedAction.interval.halfYearly",
},
quarterly: {
id: "quarterly",
rrule: "FREQ=MONTHLY;INTERVAL=3",
labelKey: "coach.suggestedAction.interval.quarterly",
},
monthly: {
id: "monthly",
rrule: "FREQ=MONTHLY;INTERVAL=1",
labelKey: "coach.suggestedAction.interval.monthly",
},
});
export type CheckupIntervalId = keyof typeof CHECKUP_INTERVAL_CATALOG;
export function isCheckupIntervalId(v: string): v is CheckupIntervalId {
return Object.prototype.hasOwnProperty.call(CHECKUP_INTERVAL_CATALOG, v);
}
/**
* The validated params the SSE frame carries + the confirm route accepts. A
* discriminated union keyed on `actionType`, so the route switch is exhaustive.
*/
export type SuggestedActionParams =
| { actionType: "checkup.create"; label: string; interval: CheckupIntervalId }
| {
actionType: "reminder.note";
note: string;
when?: string;
metric?: string;
};
/** The DTO the `suggestedAction` SSE frame carries (additive; older clients drop it). */
export interface CoachSuggestedAction {
actionType: SuggestedActionType;
/** Plain-text preview of what will be created (the model's own content). */
summary: string;
/** i18n key for the action-type heading on the card. */
titleKey: string;
params: SuggestedActionParams;
}
const METRIC_KEY_RE = /^[A-Za-z][A-Za-z0-9_]*$/;
export interface SuggestActionParseResult {
prose: string;
action: CoachSuggestedAction | null;
malformed: boolean;
}
/** Pull one `key: value` line out of the block body (first match wins). */
function fieldLine(body: string, key: string): string | null {
for (const rawLine of body.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
const colon = line.indexOf(":");
if (colon < 1) continue;
if (line.slice(0, colon).trim().toLowerCase() !== key) continue;
const value = line
.slice(colon + 1)
.trim()
.replace(/\s+#.*$/, "")
.replace(/^["'`]|["'`]$/g, "")
.trim();
if (value) return value;
}
return null;
}
/** Resolve the per-action params from the block body; null = drop (malformed). */
function resolveActionParams(
actionType: SuggestedActionType,
body: string,
): { params: SuggestedActionParams; summary: string; titleKey: string } | null {
if (actionType === "checkup.create") {
const labelRaw = fieldLine(body, "label");
const intervalRaw = fieldLine(body, "interval");
if (!labelRaw || !intervalRaw) return null;
const label = labelRaw.slice(0, CHECKUP_LABEL_MAX_CHARS).trim();
if (!label) return null;
if (!isCheckupIntervalId(intervalRaw)) return null;
return {
params: { actionType, label, interval: intervalRaw },
summary: label,
titleKey: "coach.suggestedAction.checkup.title",
};
}
// reminder.note
const noteRaw = fieldLine(body, "note");
if (!noteRaw) return null;
const note = noteRaw.slice(0, ACTION_NOTE_MAX_CHARS).trim();
if (!note) return null;
const params: SuggestedActionParams = { actionType: "reminder.note", note };
const whenRaw = fieldLine(body, "when");
if (whenRaw) params.when = whenRaw.slice(0, 32);
const metricRaw = fieldLine(body, "metric");
if (metricRaw && METRIC_KEY_RE.test(metricRaw)) {
params.metric = metricRaw.slice(0, ACTION_METRIC_MAX_CHARS).toUpperCase();
}
return {
params,
summary: note,
titleKey: "coach.suggestedAction.note.title",
};
}
/**
* Strip the `---SUGGEST-ACTION---` block and resolve it into a closed action.
* Run AFTER the other sentinel strips. An unknown action type, or a missing /
* invalid param, drops the action (`malformed: true`) while still removing the
* raw marker from the prose.
*/
export function parseSuggestAction(raw: string): SuggestActionParseResult {
if (!raw) return { prose: "", action: null, malformed: false };
const openIdx = raw.indexOf(OPEN_SENTINEL);
if (openIdx === -1) return { prose: raw, action: null, malformed: false };
const before = raw.slice(0, openIdx);
const afterOpen = raw.slice(openIdx + OPEN_SENTINEL.length);
const closeRel = afterOpen.indexOf(CLOSE_SENTINEL);
const bodyRaw = closeRel === -1 ? afterOpen : afterOpen.slice(0, closeRel);
const body =
bodyRaw.length > SENTINEL_BYTE_CAP
? bodyRaw.slice(0, SENTINEL_BYTE_CAP)
: bodyRaw;
const after =
closeRel === -1 ? "" : afterOpen.slice(closeRel + CLOSE_SENTINEL.length);
const prose = `${before}${after}`.replace(/^\s+|\s+$/gu, "");
const actionRaw = fieldLine(body, "action");
if (!actionRaw || !isSuggestedActionType(actionRaw)) {
return { prose, action: null, malformed: true };
}
const resolved = resolveActionParams(actionRaw, body);
if (!resolved) return { prose, action: null, malformed: true };
return {
prose,
action: {
actionType: actionRaw,
summary: resolved.summary,
titleKey: resolved.titleKey,
params: resolved.params,
},
malformed: false,
};
}
+10
View File
@@ -8,6 +8,8 @@
*/
import { z } from "zod/v4";
import type { CoachSuggestedAction } from "./suggest-action";
/**
* Chat-message role. Stored as a free-form string column server-side
* (`coach_messages.role`) but constrained at the application layer so a
@@ -205,6 +207,7 @@ export type CoachStreamEvent =
metricSource: CoachProvenance;
}
| { type: "suggestion"; suggestion: CoachSuggestion }
| { type: "suggestedAction"; suggestedAction: CoachSuggestedAction }
| { type: "reasoning"; text: string }
| {
type: "done";
@@ -338,6 +341,13 @@ export interface CoachProvenance {
* on a conversation reload. Absent on turns that carry no suggestion.
*/
suggestion?: CoachSuggestion;
/**
* v1.22 (F6) — a generalised confirm→apply action card attached to this turn
* (`checkup.create` / `reminder.note`, closed allowlist). Persisted alongside
* the message so the card re-renders on a conversation reload. Absent on turns
* that carry no action.
*/
suggestedAction?: CoachSuggestedAction;
/**
* v1.20.0 (F1) — the retrieval-tool trace for this turn: which tools the
* Coach called and whether each found data (`present`). Metadata only — no
+2
View File
@@ -112,6 +112,8 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
{ model: "CoachPlan", field: "ifCueEncrypted", kind: "bytes" },
{ model: "CoachPlan", field: "thenActionEncrypted", kind: "bytes" },
{ model: "CoachPlan", field: "targetEncrypted", kind: "bytes" },
// v1.22 (B2/B6) — Coach episodic reminder free-text note.
{ model: "CoachReminder", field: "noteEncrypted", kind: "bytes" },
// ───── User health profile (Bytes columns) ─────
{ model: "UserHealthProfile", field: "aboutMeEncrypted", kind: "bytes" },
@@ -0,0 +1,106 @@
/**
* v1.22 (M4) — daily Coach-reminder sweep.
*
* Covers: overdue active date-reminders flip to `due`; a passed CoachPlan
* reviewDate mints a one-off reminder from the plan's own cue→action text and
* clears the reviewDate (activating the dangling B1 column); an undecryptable
* plan is skipped (counted errored) without sinking the tick.
*/
import { describe, it, expect, vi } from "vitest";
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
decryptFromBytes: (buf: Uint8Array) => {
const tag = Buffer.from(buf).toString("utf8");
if (tag === "__bad__") throw new Error("unknown key id");
return `dec:${tag}`;
},
encryptToBytes: (s: string) => new Uint8Array(Buffer.from(`enc:${s}`)),
}));
import { runCoachReminderSweep } from "../coach-reminder-sweep";
const NOW = new Date("2026-06-27T05:20:00.000Z");
function bytes(tag: string): Uint8Array {
return new Uint8Array(Buffer.from(tag, "utf8"));
}
describe("runCoachReminderSweep", () => {
it("flips overdue active date-reminders to due and mints plan-review reminders", async () => {
const prisma = {
coachReminder: {
updateMany: vi.fn(async () => ({ count: 3 })),
create: vi.fn(async () => ({ id: "r-new" })),
},
coachPlan: {
findMany: vi.fn(async () => [
{
id: "p1",
userId: "u1",
metric: "WEIGHT",
ifCueEncrypted: bytes("every morning"),
thenActionEncrypted: bytes("weigh in"),
},
]),
update: vi.fn(async () => ({})),
},
};
const summary = await runCoachReminderSweep(prisma as never, NOW);
expect(summary.remindersDue).toBe(3);
expect(summary.planReviewsMinted).toBe(1);
expect(summary.errored).toBe(0);
// The flip query is scoped to active, date-triggered, overdue rows.
const flipArgs = prisma.coachReminder.updateMany.mock
.calls[0] as unknown as [
{ where: { status: string; triggerKind: string; dueAt: unknown } },
];
const where = flipArgs[0].where;
expect(where.status).toBe("active");
expect(where.triggerKind).toBe("date");
expect(where.dueAt).toEqual({ not: null, lte: NOW });
// The minted reminder carries the plan's own prose + relatedPlanId.
const createArgs = prisma.coachReminder.create.mock.calls[0] as unknown as [
{ data: { relatedPlanId: string; source: string; status: string } },
];
const data = createArgs[0].data;
expect(data.relatedPlanId).toBe("p1");
expect(data.source).toBe("extractor");
expect(data.status).toBe("due");
// The plan's reviewDate is cleared so the review fires exactly once.
expect(prisma.coachPlan.update).toHaveBeenCalledWith({
where: { id: "p1" },
data: { reviewDate: null },
});
});
it("skips an undecryptable plan without sinking the tick", async () => {
const prisma = {
coachReminder: {
updateMany: vi.fn(async () => ({ count: 0 })),
create: vi.fn(async () => ({ id: "r-new" })),
},
coachPlan: {
findMany: vi.fn(async () => [
{
id: "p1",
userId: "u1",
metric: "SLEEP",
ifCueEncrypted: bytes("__bad__"),
thenActionEncrypted: bytes("lights out"),
},
]),
update: vi.fn(async () => ({})),
},
};
const summary = await runCoachReminderSweep(prisma as never, NOW);
expect(summary.planReviewsMinted).toBe(0);
expect(summary.errored).toBe(1);
expect(prisma.coachReminder.create).not.toHaveBeenCalled();
expect(prisma.coachPlan.update).not.toHaveBeenCalled();
});
});
+133
View File
@@ -0,0 +1,133 @@
/**
* v1.22 (B2/M4) — daily Coach-reminder sweep.
*
* The active scheduler the "remind me" complaint needs: a single indexed query
* per tick that brings a reminder back at its chosen moment. Two sources:
*
* 1. `CoachReminder` rows that are date-triggered, `active`, and whose `dueAt`
* has passed → flipped to `due` (the in-app tile + indicator read `due` /
* `surfaced`). `lastSurfacedAt` is stamped + `surfaceCount` bumped.
* 2. `CoachPlan` rows whose dangling `reviewDate` (written nowhere, read
* nowhere until now — see the design §1c) has passed and that are still
* `active` → a one-off `CoachReminder` is minted from the plan's own
* cue→action text (relatedPlanId set, source `extractor`) and the plan's
* `reviewDate` is cleared so the review fires once. This finally activates
* the column the B1 schema anticipated.
*
* In-app surfacing ONLY in this wave: push delivery + the receptivity gate (B4)
* land later. The sweep never calls a provider and never pushes — it only flips
* status + mints the plan-review reminder, both idempotent across re-fires.
*
* Context-cue reminders (`triggerKind: "context"`) are NOT evaluated here — that
* is a follow-on (F4); the column exists and capture supports it, but the date
* sweep is the high-value slice.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
export const COACH_REMINDER_SWEEP_QUEUE = "coach-reminder-sweep";
// Daily at 05:20 Europe/Berlin — just after the 05:15 nudge tick so both
// proactive passes run on settled overnight rows.
export const COACH_REMINDER_SWEEP_CRON = "20 5 * * *";
/** Bound the plan-review fan-out per tick so one sweep can't run unbounded. */
const PLAN_REVIEW_BATCH = 200;
export interface CoachReminderSweepSummary {
remindersDue: number;
planReviewsMinted: number;
errored: number;
}
type SweepPrisma = Pick<PrismaClient, "coachReminder" | "coachPlan">;
/**
* Run one sweep tick. Idempotent: a reminder already `due` is not re-flipped,
* and a plan whose `reviewDate` was cleared on a prior tick is not re-minted.
*/
export async function runCoachReminderSweep(
prisma: SweepPrisma,
now: Date = new Date(),
): Promise<CoachReminderSweepSummary> {
const summary: CoachReminderSweepSummary = {
remindersDue: 0,
planReviewsMinted: 0,
errored: 0,
};
// ── 1. flip overdue active date-reminders → due ──────────────
const due = await prisma.coachReminder.updateMany({
where: {
deletedAt: null,
status: "active",
triggerKind: "date",
dueAt: { not: null, lte: now },
},
data: {
status: "due",
lastSurfacedAt: now,
surfaceCount: { increment: 1 },
},
});
summary.remindersDue = due.count;
// ── 2. mint plan-review reminders for passed reviewDates ──────
const plans = await prisma.coachPlan.findMany({
where: {
deletedAt: null,
status: "active",
reviewDate: { not: null, lte: now },
},
take: PLAN_REVIEW_BATCH,
select: {
id: true,
userId: true,
metric: true,
ifCueEncrypted: true,
thenActionEncrypted: true,
},
});
for (const plan of plans) {
try {
// The note is the plan's OWN cue→action prose (the user's words), never
// fabricated. Decrypt fault-isolated — an undecryptable plan is skipped
// (its reviewDate stays so a future key fix can still surface it).
let note: string;
try {
const ifCue = decryptFromBytes(plan.ifCueEncrypted);
const thenAction = decryptFromBytes(plan.thenActionEncrypted);
note = `${ifCue} → ${thenAction}`.slice(0, 280);
} catch {
summary.errored += 1;
continue;
}
await prisma.coachReminder.create({
data: {
userId: plan.userId,
noteEncrypted: encryptToBytes(note),
metric: plan.metric,
relatedPlanId: plan.id,
triggerKind: "date",
dueAt: now,
status: "due",
source: "extractor",
lastSurfacedAt: now,
surfaceCount: 1,
},
});
// Clear the plan's reviewDate so the review fires exactly once.
await prisma.coachPlan.update({
where: { id: plan.id },
data: { reviewDate: null },
});
summary.planReviewsMinted += 1;
} catch {
summary.errored += 1;
}
}
return summary;
}
+43
View File
@@ -34,6 +34,11 @@ import {
COACH_NUDGE_CRON,
runCoachNudgeTick,
} from "@/lib/jobs/coach-nudge";
import {
COACH_REMINDER_SWEEP_QUEUE,
COACH_REMINDER_SWEEP_CRON,
runCoachReminderSweep,
} from "@/lib/jobs/coach-reminder-sweep";
import {
MEDICATION_LOW_STOCK_QUEUE,
MEDICATION_LOW_STOCK_CRON,
@@ -159,6 +164,9 @@ const allQueues = [
// v1.15.20 — proactive Coach nudge. Without this entry the daily 05:15
// schedule silently no-ops and no nudge ever fires.
COACH_NUDGE_QUEUE,
// v1.22 (M4) — daily Coach-reminder sweep. Without this entry the 05:20
// schedule silently no-ops and no "remind me" reminder ever surfaces.
COACH_REMINDER_SWEEP_QUEUE,
// v1.16.11 — daily medication low-stock pass. Without this entry the 09:00
// schedule silently no-ops and no low-stock alert ever fires.
MEDICATION_LOW_STOCK_QUEUE,
@@ -204,6 +212,10 @@ const schedules: ScheduleEntry[] = [
// the 04:45–04:55 score crons so the recovery-score trigger reads
// settled rows. Deterministic triggers only — no AI call on this path.
[COACH_NUDGE_QUEUE, COACH_NUDGE_CRON],
// v1.22 (M4) — daily 05:20 Europe/Berlin Coach-reminder sweep, just after
// the nudge tick. Flips overdue reminders to `due` + mints plan-review
// reminders from passed CoachPlan.reviewDate. In-app surface only; no push.
[COACH_REMINDER_SWEEP_QUEUE, COACH_REMINDER_SWEEP_CRON],
// v1.16.11 — medication low-stock pass at 09:00 Europe/Berlin: a
// supply alert is an errand prompt, so it fires at a time the user
// can act on it. Once daily; the per-medication stamp keeps it at
@@ -447,5 +459,36 @@ export async function registerStatusQueues(
},
);
// v1.22 (M4) — daily Coach-reminder sweep. Single-flight; flipping an overdue
// reminder to `due` and minting plan-review reminders are both idempotent
// across re-fires (an already-due reminder is not re-flipped; a plan whose
// reviewDate was cleared is not re-minted). No provider call, no push.
await boss.work(
COACH_REMINDER_SWEEP_QUEUE,
{ localConcurrency: 1 },
async () => {
await withBackgroundEvent("job.coach_reminder_sweep", async (evt) => {
try {
const summary = await runCoachReminderSweep(
getWorkerPrisma(),
new Date(),
);
evt.setBackground({
task_name: "job.coach_reminder_sweep",
result: {
reminders_due: summary.remindersDue,
plan_reviews_minted: summary.planReviewsMinted,
errored: summary.errored,
},
});
} catch (err) {
evt.setError(err);
recordError();
throw err;
}
});
},
);
return allQueues;
}
+262
View File
@@ -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
@@ -1058,6 +1128,198 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
};
export const coachReminderPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/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<ZodOpenApiObject["paths"]> =
{
"/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<ZodOpenApiObject["paths"]> = {
"/api/insights/chat/messages/{id}/feedback": {
post: {
+4
View File
@@ -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<ZodOpenApiObject["paths"]> = {
...profilePaths,
...coachPaths,
...coachReminderSuggestionPaths,
...coachReminderPaths,
...coachSuggestedActionPaths,
...adminInvitePaths,
...coachFeedbackPaths,
...adminDiagnosticPaths,
+10
View File
@@ -32,4 +32,14 @@ export const coachKeys = {
* (`GET /api/insights/coach/seeded-question`).
*/
coachSeededQuestion: () => ["coach-seeded-question"] as const,
/**
* v1.22 (B2/B6) — the user's Coach episodic reminders (`GET /api/coach/
* reminders`). The optional `status` arg keys the in-app tile read
* (`due,surfaced`) separately from the full ledger list so the two never
* share a cache slot.
*/
coachReminders: (status?: string) =>
["coach-reminders", status ?? "all"] as const,
/** Prefix key — invalidates every `coachReminders(status)` slot at once. */
coachRemindersAll: () => ["coach-reminders"] as const,
};
+138
View File
@@ -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;