From 9b61769a32d881c4fc613a671b194f783e448a4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 13:37:50 +0200 Subject: [PATCH 1/5] feat(coach): add the CoachReminder episodic-memory model Introduce a third Coach memory type alongside facts (durable traits) and plans (standing if-then habits): a time-anchored "bring this back later" reminder with a due moment and a done state. The free-text note is AES-256-GCM ciphertext via the shared Bytes codec; metric, status, the trigger fields and the dates stay plain so the sweep and the injection picker rank and filter without a per-row decrypt. Migration 0196 is purely additive (status and trigger_kind are app-side closed enums, not DB enums). Register the new note_encrypted column in the encrypted-columns registry and the rotation script so a key rotation can never silently skip it. --- .../0196_coach_reminder/migration.sql | 42 +++++++++++++++ prisma/schema.prisma | 51 +++++++++++++++++++ scripts/rotate-encryption-key.ts | 10 ++++ src/lib/crypto/encrypted-columns.ts | 2 + 4 files changed, 105 insertions(+) create mode 100644 prisma/migrations/0196_coach_reminder/migration.sql diff --git a/prisma/migrations/0196_coach_reminder/migration.sql b/prisma/migrations/0196_coach_reminder/migration.sql new file mode 100644 index 000000000..d11bcd3a0 --- /dev/null +++ b/prisma/migrations/0196_coach_reminder/migration.sql @@ -0,0 +1,42 @@ +-- v1.22 (B2/B6) — Coach episodic reminder memory. +-- +-- A time-anchored "bring this back later" intent the Coach captured from a +-- conversation. The missing memory type between coach_facts (durable traits) +-- and coach_plans (standing if-then habits): an episode with a due moment and +-- a done state. The free-text note is AES-256-GCM ciphertext stored as `bytea` +-- via the shared Bytes codec — the same shape as coach_plans.if_cue_encrypted. +-- metric / status / trigger fields / dates / source stay plain so the daily +-- sweep and the injection picker can rank and filter without a per-row decrypt. +-- status (proposed | active | due | surfaced | done | dismissed) and +-- trigger_kind (date | context) are app-side closed enums, deliberately NOT DB +-- enums so this migration stays purely additive. Soft-delete via deleted_at. +CREATE TABLE "coach_reminders" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "note_encrypted" BYTEA NOT NULL, + "metric" TEXT, + "related_plan_id" TEXT, + "trigger_kind" TEXT NOT NULL DEFAULT 'date', + "due_at" TIMESTAMP(3), + "context_cue" TEXT, + "status" TEXT NOT NULL DEFAULT 'active', + "source" TEXT NOT NULL, + "source_conversation_id" TEXT, + "last_surfaced_at" TIMESTAMP(3), + "surface_count" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "coach_reminders_pkey" PRIMARY KEY ("id") +); + +-- The daily sweep scans a user's active reminders whose due_at has passed; the +-- status + deleted_at columns are part of that predicate, so the composite +-- index lets the planner serve the sweep + the injection read index-backed. +CREATE INDEX "coach_reminders_user_id_status_due_at_deleted_at_idx" ON "coach_reminders"("user_id", "status", "due_at", "deleted_at"); + +-- The context-cue evaluation pass filters by trigger_kind + status per user. +CREATE INDEX "coach_reminders_user_id_trigger_kind_status_idx" ON "coach_reminders"("user_id", "trigger_kind", "status"); + +ALTER TABLE "coach_reminders" ADD CONSTRAINT "coach_reminders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bd9779f55..dc4333ff4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -571,6 +571,7 @@ model User { coachConversations CoachConversation[] coachFacts CoachFact[] coachPlans CoachPlan[] + coachReminders CoachReminder[] coachUsage CoachUsage[] // v1.4.25 W8d — workout passthrough (Apple HKWorkout / Withings // getworkouts / future Strava). Schema lands now so the v1.5 iOS @@ -3938,6 +3939,56 @@ model CoachPlan { @@map("coach_plans") } +// v1.22 (B2/B6) — durable, user-scoped EPISODIC reminder memory: a +// time-anchored "bring this back later" intent the Coach captured from a +// conversation ("remind me about my sleep next week"). This is the middle +// memory type that neither CoachFact (durable traits) nor CoachPlan (standing +// if-then habits) covers — an episode with a due moment and a done state. +// +// The free-text note is the user's own framing, encrypted at rest (AES-256-GCM +// via the shared Bytes codec, exactly like CoachPlan's cue/action). `metric`, +// `status`, the trigger fields and the dates stay plain so the daily sweep and +// the injection picker can rank / filter without paying a per-row decrypt. +// +// `status` is an APP-SIDE closed enum (NOT a DB enum, to keep the migration +// purely additive): proposed | active | due | surfaced | done | dismissed. +// `triggerKind` is likewise app-side closed: date | context. Soft-delete via +// `deletedAt` so a "forget this" action is reversible / auditable. +model CoachReminder { + id String @id @default(cuid()) + userId String @map("user_id") + // What to bring back — the user's own framing, encrypted at rest. + noteEncrypted Bytes @map("note_encrypted") + // Optional link to the metric / plan it concerns; plain for filtering. + metric String? + relatedPlanId String? @map("related_plan_id") + // Trigger model — app-side closed enum (date | context). For `date`, `dueAt` + // carries the fire moment; for `context`, `contextCue` names a closed cue. + triggerKind String @default("date") @map("trigger_kind") + dueAt DateTime? @map("due_at") + contextCue String? @map("context_cue") + // Lifecycle: proposed | active | due | surfaced | done | dismissed. + status String @default("active") + // Provenance — how it was captured (sentinel | extractor | manual | action). + source String + sourceConversationId String? @map("source_conversation_id") + lastSurfacedAt DateTime? @map("last_surfaced_at") + // How many times the sweep has surfaced this without the user acting; capped + // before auto-dismiss so an ignored reminder cannot nag indefinitely. + surfaceCount Int @default(0) @map("surface_count") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + // The daily sweep scans active reminders whose dueAt has passed; the status + + // deleted_at columns are part of the predicate so the read stays index-backed. + @@index([userId, status, dueAt, deletedAt]) + @@index([userId, triggerKind, status]) + @@map("coach_reminders") +} + // v1.15.20 — user-authored "about me" self-description the AI surfaces // (Coach system prompt + daily-briefing user prompt) inject as a clearly // delimited, user-provided context block. 1:1 with User and lazily diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 53149ef99..7f786aeb6 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -328,6 +328,16 @@ async function main() { results.push(await rotateBytesColumn("CoachPlan", field, prisma.coachPlan)); } + // ───── CoachReminder (Bytes column) ───── + // "noteEncrypted" + results.push( + await rotateBytesColumn( + "CoachReminder", + "noteEncrypted", + prisma.coachReminder, + ), + ); + // ───── UserHealthProfile (Bytes columns) ───── // "aboutMeEncrypted" "conditionsEncrypted" "allergiesEncrypted" // "coachFocusEncrypted" "pendingQuestionsEncrypted" diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index 75613e154..1cf71c46d 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -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" }, From 27a8a59d67c7cd65de32a3477ad3f57843cb5cc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 13:38:04 +0200 Subject: [PATCH 2/5] feat(coach): capture and resurface "remind me" reminders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture the casual "remember this / remind me about X" utterance the Coach used to drop. The model emits a provider-neutral `---REMEMBER---` sentinel that the chat route strips inline on EVERY turn — so a short conversation, where the request usually happens, no longer waits for the long-chat memory worker. The note is the user's own explicit request, so it is stored silently as active; a validated closed grammar (ISO date, relative +Nd/+Nw, or a context cue) sets the trigger, and a malformed block is dropped. Surface the stored reminders two ways. A snapshot recall sub-block injects near, overdue, and already-surfaced reminders so the Coach references them unprompted next turn. A daily sweep flips overdue reminders to due and mints a one-off review reminder from a plan whose review date has passed, finally activating the plan review-date column. In-app surfacing only here; push delivery follows later. Also generalise the propose-then-confirm action card: a closed `---SUGGEST-ACTION---` allowlist (a preventive-care item or a reminder note) rides an additive SSE frame. Nothing is created until the user confirms. --- src/app/api/insights/chat/route.ts | 73 +++++- src/lib/ai/coach/memory-snapshot.ts | 31 ++- src/lib/ai/coach/plans.ts | 93 +++++++ src/lib/ai/coach/reminders.ts | 319 +++++++++++++++++++++++ src/lib/ai/coach/snapshot.ts | 10 + src/lib/ai/coach/suggest-action.ts | 252 ++++++++++++++++++ src/lib/ai/coach/types.ts | 10 + src/lib/jobs/coach-reminder-sweep.ts | 133 ++++++++++ src/lib/jobs/reminder/register-status.ts | 43 +++ 9 files changed, 961 insertions(+), 3 deletions(-) create mode 100644 src/lib/ai/coach/reminders.ts create mode 100644 src/lib/ai/coach/suggest-action.ts create mode 100644 src/lib/jobs/coach-reminder-sweep.ts diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index c43ad8afa..8ba4f7e7f 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -93,6 +93,16 @@ import { import { scrubUnknownLearnLinks } from "@/lib/ai/coach/learn-link-guard"; import { parseSuggestReminder } from "@/lib/ai/coach/suggest-reminder"; import { gateSuggestion } from "@/lib/ai/coach/suggest-gate"; +import { + parseRememberSentinel, + captureReminderFromSentinel, + buildRememberAddendum, +} from "@/lib/ai/coach/reminders"; +import { + parseSuggestAction, + buildSuggestActionAddendum, + type CoachSuggestedAction, +} from "@/lib/ai/coach/suggest-action"; import { parseCoachPrefs, DEFAULT_REMINDER_SUGGESTION_PREFS, @@ -371,7 +381,12 @@ async function handleChatRequest(request: NextRequest): Promise { // v1.16.0 — composed self-context: structured questionnaire fields // plus age/gender merged in from the User profile. const aboutMe = await getSelfContextTextForUser(userId, locale); - const systemPrompt = getCoachSystemPrompt(locale, coachPrefs, aboutMe); + // v1.22 (B2/F6) — the canonical system-prompt module is owned elsewhere, so + // the two memory/action clauses are appended at assembly time here: one + // teaches the model to emit `---REMEMBER---` (durable "remind me" capture), + // one teaches the closed `---SUGGEST-ACTION---` confirm-card allowlist. Both + // are provider-neutral sentinels stripped from the prose before it streams. + const systemPrompt = `${getCoachSystemPrompt(locale, coachPrefs, aboutMe)}\n\n${buildRememberAddendum(locale)}\n\n${buildSuggestActionAddendum(locale)}`; const allTurns: CoachTurn[] = [ ...priorTurns, { role: "user", content: message }, @@ -755,6 +770,39 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch const suggestParse = parseSuggestReminder(proseAfterStrip); let replyText = suggestParse.prose.trim() || proseAfterStrip; + // v1.22 (B2) — strip the optional `---REMEMBER---` block and capture the + // reminder INLINE on this turn (not in the >20-turn memory worker), so a + // casual "remind me about X" in a SHORT chat is no longer lost. The note is + // the user's OWN explicit request → captured silently as `active` (no confirm + // gate); the model acknowledges it in prose. A missing note / invalid `when` + // drops the block (the user never sees the raw marker). Fire-and-forget: the + // capture write must never break the chat turn. + const rememberParse = parseRememberSentinel(replyText, new Date()); + replyText = rememberParse.prose.trim() || replyText; + if (rememberParse.reminder) { + const capture = rememberParse.reminder; + void captureReminderFromSentinel({ + userId, + conversationId: workingConversationId, + parsed: capture, + }).catch(() => { + // Reminder capture is best-effort; never sink the turn. + }); + } else if (rememberParse.malformed) { + annotate({ + action: { name: "coach.reminder.capture_malformed" }, + meta: { conversationId: workingConversationId }, + }); + } + + // v1.22 (F6) — strip the optional `---SUGGEST-ACTION---` block (the + // generalised confirm→apply moat). The model names ONE action from the closed + // allowlist (`checkup.create` / `reminder.note`); the card surfaces additively + // and NOTHING is created until the user taps confirm (the entity is built + // server-side, field-by-field, by `POST /api/coach/suggested-actions`). + const actionParse = parseSuggestAction(replyText); + replyText = actionParse.prose.trim() || replyText; + // v1.18.10 (HIGH-2) — OUTBOUND safety screen on the assembled assistant // reply, before persistence and streaming. The inbound `detectRefusal` // guards the user's message; this guards the model's reply for a @@ -888,8 +936,21 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch }); } } + // v1.22 (F6) — surface the confirm-card action when the turn was not blocked. + // Additive: the prose already stands alone; the card only offers the one-tap + // confirm. Nothing is created server-side until the user taps it. + let surfacedAction: CoachSuggestedAction | null = null; + if (!outbound.block && actionParse.action) { + surfacedAction = actionParse.action; + annotate({ + action: { name: "coach.action.suggested" }, + meta: { actionType: actionParse.action.actionType }, + }); + } + const enrichedProvenance: typeof snapshot.provenance = { ...snapshot.provenance, + ...(surfacedAction ? { suggestedAction: surfacedAction } : {}), // v1.18.10 (HIGH-2) — a blocked turn carries the fallback prose, so the // key-values from the discarded reply must not ride along as provenance. ...(!outbound.block && sentinel.keyValues.length > 0 @@ -1018,6 +1079,16 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch encodeFrame({ type: "suggestion", suggestion: surfacedSuggestion }), ); } + // v1.22 (F6) — additive `suggestedAction` frame. Older clients ignore it; + // newer ones render the generic one-tap confirm card. + if (surfacedAction) { + controller.enqueue( + encodeFrame({ + type: "suggestedAction", + suggestedAction: surfacedAction, + }), + ); + } // v1.18.9 — additive `usage` envelope on the `done` frame so the client // can paint the quiet per-message token footer the instant the stream // closes. Server-authoritative: the client renders these numbers, never diff --git a/src/lib/ai/coach/memory-snapshot.ts b/src/lib/ai/coach/memory-snapshot.ts index 0875c3c17..5909dfa2f 100644 --- a/src/lib/ai/coach/memory-snapshot.ts +++ b/src/lib/ai/coach/memory-snapshot.ts @@ -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; } diff --git a/src/lib/ai/coach/plans.ts b/src/lib/ai/coach/plans.ts index 78ef99474..67a276eec 100644 --- a/src/lib/ai/coach/plans.ts +++ b/src/lib/ai/coach/plans.ts @@ -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; +type ReminderPrismaLike = Pick; 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 }; +} diff --git a/src/lib/ai/coach/reminders.ts b/src/lib/ai/coach/reminders.ts new file mode 100644 index 000000000..e66d8c907 --- /dev/null +++ b/src/lib/ai/coach/reminders.ts @@ -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: +when: +metric: +---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: +when: +metric: +---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; + +/** + * 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 { + 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; +} diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index e6abd0889..515e78b66 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -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 = { 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; }; diff --git a/src/lib/ai/coach/suggest-action.ts b/src/lib/ai/coach/suggest-action.ts new file mode 100644 index 000000000..e466b55c0 --- /dev/null +++ b/src/lib/ai/coach/suggest-action.ts @@ -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: +interval: +---END--- +oder +---SUGGEST-ACTION--- +action: reminder.note # eine Erinnerung, auf etwas zurückzukommen +note: +when: +metric: +---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: +interval: +---END--- +or +---SUGGEST-ACTION--- +action: reminder.note # a reminder to revisit something +note: +when: +metric: +---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 +> = 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, + }; +} diff --git a/src/lib/ai/coach/types.ts b/src/lib/ai/coach/types.ts index 11862a186..1618d5ceb 100644 --- a/src/lib/ai/coach/types.ts +++ b/src/lib/ai/coach/types.ts @@ -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 diff --git a/src/lib/jobs/coach-reminder-sweep.ts b/src/lib/jobs/coach-reminder-sweep.ts new file mode 100644 index 000000000..5fde7451c --- /dev/null +++ b/src/lib/jobs/coach-reminder-sweep.ts @@ -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; + +/** + * 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 { + 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; +} diff --git a/src/lib/jobs/reminder/register-status.ts b/src/lib/jobs/reminder/register-status.ts index 0f0c2132f..d6d210138 100644 --- a/src/lib/jobs/reminder/register-status.ts +++ b/src/lib/jobs/reminder/register-status.ts @@ -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; } From 6f407388b45265fe6da54da14ab6362db1419fcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 13:38:15 +0200 Subject: [PATCH 3/5] feat(coach): add reminder and suggested-action endpoints Add the management surface for the reminder memory: list, create, confirm, done/dismiss, and soft-delete, every query scoped to the session owner with the same no-IDOR posture as the plans routes (a cross-user or unknown id is a 0-count no-op, never a leaked existence). The note is the only writable field; timing is resolved server-side from the closed grammar. Add the confirm endpoint behind the generalised action card. It switches on a closed action-type allowlist and builds each entity field-by-field from a server-resolved spec: a preventive-care reminder whose schedule comes from a closed interval catalog, or a reminder note. It never auto-applies, and the allowlist excludes any medication or clinical change. Regenerate the OpenAPI contract for the new routes. --- docs/api/openapi.yaml | 478 +++++++++++++++++++ src/app/api/coach/reminders/[id]/route.ts | 178 +++++++ src/app/api/coach/reminders/route.ts | 202 ++++++++ src/app/api/coach/suggested-actions/route.ts | 188 ++++++++ src/lib/openapi/routes/coach.ts | 262 ++++++++++ src/lib/openapi/routes/index.ts | 4 + src/lib/validations/coach-reminder.ts | 138 ++++++ 7 files changed, 1450 insertions(+) create mode 100644 src/app/api/coach/reminders/[id]/route.ts create mode 100644 src/app/api/coach/reminders/route.ts create mode 100644 src/app/api/coach/suggested-actions/route.ts create mode 100644 src/lib/validations/coach-reminder.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1138e83df..c5fbb84ec 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4836,6 +4836,189 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + /api/coach/reminders: + get: + tags: + - Insights + summary: List the caller's Coach reminders + description: v1.22 (B2/B6) — the durable "remind me about X" memory the Coach captured inline, decrypted on the fly, + soonest-due first. Pass `?status=` (one status or a comma set like `due,surfaced` for the in-app tile); omitted + returns the non-terminal set (proposed + active + due + surfaced). Coach-gated + (`requireModuleEnabled("coach")`). Auth via cookie or Bearer; the owner is narrowed from the session. + Undecryptable rows are omitted. + parameters: + - name: status + in: query + required: false + schema: + type: string + description: Filter to one status or a comma-separated set (proposed | active | due | surfaced | done | dismissed). Omit + for the non-terminal set. + responses: + "200": + description: The caller's reminders. + content: + application/json: + schema: + $ref: "#/components/schemas/CoachRemindersList" + "401": *a1 + "403": + description: Coach surface disabled. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + post: + tags: + - Insights + summary: Create a Coach reminder manually + description: v1.22 (B6) — create a reminder from the ledger. `note` is the only writable content; `when` is the closed + grammar (ISO date | +Nd / +Nw | a context cue) resolved into the trigger server-side; `metric` optional. The + per-user cap returns 409 when the non-terminal set is full. Coach-gated. Auth via cookie or Bearer; the owner is + narrowed from the session. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CoachReminderCreate" + responses: + "201": + description: The created reminder. + content: + application/json: + schema: + $ref: "#/components/schemas/CoachReminderCreated" + "401": *a1 + "403": + description: Coach surface disabled. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: The per-user reminder cap is full. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/coach/reminders/{id}: + patch: + tags: + - Insights + summary: Confirm or update a Coach reminder's lifecycle + description: v1.22 (B6) — confirm a proposed reminder (→ active), mark it done / dismissed, or re-schedule via the + closed `when` grammar (null clears the due moment). The body never carries the note text. A foreign / unknown / + already-deleted id maps to 404 (never 403) so the existence channel does not leak. Coach-gated. Auth via cookie + or Bearer. + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Reminder id. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CoachReminderPatch" + responses: + "200": + description: The reminder after the lifecycle update. + content: + application/json: + schema: + $ref: "#/components/schemas/CoachReminderUpdated" + "401": *a1 + "403": + description: Coach surface disabled. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "404": + description: Reminder not found or not owned by the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + delete: + tags: + - Insights + summary: Soft-delete one Coach reminder + description: "v1.22 (B6) — soft-deletes a single reminder owned by the caller. An unknown / cross-user / already-deleted + id is an idempotent no-op returning `{ deleted: false }`. Coach-gated. Auth via cookie or Bearer." + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Reminder id. + responses: + "200": + description: "The reminder was soft-deleted (`deleted: true`) or the id matched nothing the caller owns (`deleted: + false`)." + content: + application/json: + schema: + $ref: "#/components/schemas/CoachReminderDeleted" + "401": *a1 + "403": + description: Coach surface disabled. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/coach/suggested-actions: + post: + tags: + - Insights + summary: Confirm a Coach action card + description: v1.22 (F6) — the confirm half of the generalised propose→confirm moat. `actionType` is from the CLOSED + allowlist (checkup.create | reminder.note) — NEVER a medication or clinical change. `checkup.create` builds a + preventive-care Vorsorge `MeasurementReminder` (free-text label + a closed interval id resolved to an RRULE + server-side); `reminder.note` builds a `CoachReminder`. Nothing is created without this explicit confirm. + Coach-gated (`requireModuleEnabled("coach")`); per-user rate-limited (429). Auth via cookie or Bearer; the owner + is narrowed from the session, built field-by-field — no mass assignment, no IDOR. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CoachSuggestedAction" + responses: + "201": + description: The action was applied (entity created server-side). + content: + application/json: + schema: + $ref: "#/components/schemas/CoachSuggestedActionResultCreated" + "401": *a1 + "403": + description: Coach surface disabled. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: The per-user reminder cap is full (reminder.note). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 /api/admin/invites: get: tags: @@ -8538,6 +8721,99 @@ components: compatibility and are normalised to their English equivalents before persisting; GET responses always carry the canonical English ids. Section ids are English-only. The legacy tile ids are deprecated and will be removed in a future major version." + CoachReminderCreate: + type: object + properties: + note: + type: string + minLength: 1 + maxLength: 280 + when: + type: string + minLength: 1 + maxLength: 32 + metric: + type: string + minLength: 1 + maxLength: 60 + pattern: ^[A-Za-z][A-Za-z0-9_]*$ + required: + - note + additionalProperties: false + description: "Create a Coach reminder manually. `note` is free text (the only writable content). `when` is an optional + closed grammar token — an ISO date (YYYY-MM-DD), a relative offset (+Nd / +Nw), or a context cue + (NEXT_BP_LOGGED, NEXT_WEIGHT_LOGGED, NEXT_SLEEP_LOGGED, NEXT_APP_OPEN) — resolved into the trigger server-side. + Omit `when` for a recall-only note. Strict: unknown keys 422." + CoachReminderPatch: + type: object + properties: + status: + type: string + enum: + - proposed + - active + - due + - surfaced + - done + - dismissed + when: + anyOf: + - type: string + minLength: 1 + maxLength: 32 + - type: "null" + additionalProperties: false + description: "Update a Coach reminder's lifecycle. `status` confirms (proposed → active), or marks done / dismissed. + `when` re-schedules via the closed grammar (null clears the due moment). Never carries the note text. Strict: + unknown keys 422." + CoachSuggestedAction: + oneOf: + - type: object + properties: + actionType: + type: string + const: checkup.create + label: + type: string + minLength: 1 + maxLength: 120 + interval: + type: string + enum: + - yearly + - halfYearly + - quarterly + - monthly + required: + - actionType + - label + - interval + - type: object + properties: + actionType: + type: string + const: reminder.note + note: + type: string + minLength: 1 + maxLength: 280 + when: + type: string + minLength: 1 + maxLength: 32 + metric: + type: string + minLength: 1 + maxLength: 60 + pattern: ^[A-Za-z][A-Za-z0-9_]*$ + required: + - actionType + - note + description: Confirm a Coach action card. `actionType` is from the closed allowlist (checkup.create, reminder.note); + NEVER a medication or clinical change. `checkup.create` builds a preventive-care Vorsorge reminder (free-text + label + a closed interval id resolved to an RRULE server-side); `reminder.note` builds a CoachReminder (note + + optional closed `when` grammar + optional metric). Nothing is created without this explicit confirm. + type: object CoachMessageFeedbackRequest: type: object properties: @@ -17064,6 +17340,208 @@ components: - data - error additionalProperties: false + CoachRemindersList: + type: object + properties: + data: + type: object + properties: + reminders: + type: array + items: + $ref: "#/components/schemas/CoachReminder" + description: The caller's reminders, soonest-due first. Undecryptable rows are omitted. No `status` filter returns the + non-terminal set (proposed + active + due + surfaced). + required: + - reminders + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CoachReminder: + type: object + properties: + id: + type: string + note: + anyOf: + - type: string + - type: "null" + description: Decrypted note (the user's own framing). Null when the row's key id is no longer in the map. + metric: + anyOf: + - type: string + - type: "null" + triggerKind: + type: string + enum: + - date + - context + dueAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + contextCue: + anyOf: + - type: string + - type: "null" + status: + type: string + enum: + - proposed + - active + - due + - surfaced + - done + - dismissed + source: + type: string + enum: + - sentinel + - extractor + - manual + - action + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - id + - note + - metric + - triggerKind + - dueAt + - contextCue + - status + - source + - createdAt + - updatedAt + additionalProperties: false + description: One Coach episodic reminder, decrypted server-side. The note reads null only when the encryption key id has + rotated out of the map. + CoachReminderCreated: + type: object + properties: + data: + type: object + properties: + reminder: + $ref: "#/components/schemas/CoachReminder" + required: + - reminder + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CoachReminderUpdated: + type: object + properties: + data: + type: object + properties: + reminder: + $ref: "#/components/schemas/CoachReminder" + required: + - reminder + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CoachReminderDeleted: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + description: True when a reminder owned by the caller was soft-deleted; false for an unknown / cross-user / + already-deleted id (idempotent no-op). + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CoachSuggestedActionResultCreated: + type: object + properties: + data: + $ref: "#/components/schemas/CoachSuggestedActionResult" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CoachSuggestedActionResult: + type: object + properties: + ok: + type: boolean + const: true + actionType: + type: string + enum: + - checkup.create + - reminder.note + reminder: {} + reminderId: + type: string + required: + - ok + - actionType + additionalProperties: false + description: Outcome of confirming a Coach action card. `checkup.create` returns the created MeasurementReminder DTO; + `reminder.note` returns the created CoachReminder id. AdminInviteList: type: object properties: diff --git a/src/app/api/coach/reminders/[id]/route.ts b/src/app/api/coach/reminders/[id]/route.ts new file mode 100644 index 000000000..8c702a04b --- /dev/null +++ b/src/app/api/coach/reminders/[id]/route.ts @@ -0,0 +1,178 @@ +/** + * PATCH /api/coach/reminders/[id] — confirm / update a reminder's lifecycle. + * DELETE /api/coach/reminders/[id] — soft-delete one reminder. + * + * v1.22 (B2/B6) — the user-confirm + management surface for the Coach episodic + * reminders. `status` confirms a `proposed` reminder (→ active), marks it + * `done` / `dismissed`, or re-activates it; `when` re-schedules (snooze) via the + * closed grammar. The body never carries the note text, so a client can never + * inject or overwrite a reminder's prose — only its lifecycle / timing. + * + * Ownership + existence privacy: every mutation is scoped + * `where: { id, userId, deletedAt: null }` via `updateMany`, so a cross-user id, + * an unknown id, or an already-deleted reminder all resolve to a `count: 0` + * no-op rather than a P2025 throw — the existence channel never leaks across + * accounts. PATCH on a 0-count match returns 404; DELETE returns the idempotent + * `{ deleted: false }`. + */ +import type { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { prisma } from "@/lib/db"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { resolveWhenGrammar } from "@/lib/ai/coach/reminders"; +import { coachReminderPatchSchema } from "@/lib/validations/coach-reminder"; + +interface RouteCtx { + params: Promise<{ id: string }>; +} + +const MUTATE_RATE_LIMIT = 40; +const MUTATE_WINDOW_MS = 60_000; + +async function enforceMutateLimit( + op: string, + userId: string, +): Promise { + const rl = await checkRateLimit( + `coach-reminders:${op}:${userId}`, + MUTATE_RATE_LIMIT, + MUTATE_WINDOW_MS, + ); + if (rl.allowed) return null; + const response = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; +} + +export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "coach"); + if (!gate.enabled) return gate.response; + + const limited = await enforceMutateLimit("patch", user.id); + if (limited) return limited; + + const { id } = await ctx.params; + + const { data: body, error: jsonError } = await safeJson(req, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = coachReminderPatchSchema.safeParse(body); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + // Field-by-field (no mass assignment): only the lifecycle / timing fields the + // body carried are written; the note + encrypted text are never touched here. + const data: { + status?: string; + dueAt?: Date | null; + triggerKind?: string; + contextCue?: string | null; + } = {}; + if (parsed.data.status !== undefined) data.status = parsed.data.status; + if (parsed.data.when !== undefined) { + if (parsed.data.when === null) { + data.dueAt = null; + data.triggerKind = "date"; + data.contextCue = null; + } else { + const trigger = resolveWhenGrammar(parsed.data.when, new Date()); + if (!trigger) return apiError("coach.reminder.invalidWhen", 422); + data.dueAt = trigger.dueAt; + data.triggerKind = trigger.triggerKind; + data.contextCue = trigger.contextCue; + } + } + + const { count } = await prisma.coachReminder.updateMany({ + where: { id, userId: user.id, deletedAt: null }, + data, + }); + + if (count === 0) return apiError("Reminder not found", 404); + + const row = await prisma.coachReminder.findFirst({ + where: { id, userId: user.id }, + select: { + id: true, + metric: true, + noteEncrypted: true, + triggerKind: true, + dueAt: true, + contextCue: true, + status: true, + source: true, + createdAt: true, + updatedAt: true, + }, + }); + if (!row) return apiError("Reminder not found", 404); + + let note: string | null = null; + try { + note = decryptFromBytes(row.noteEncrypted); + } catch { + note = null; + } + + annotate({ + action: { name: "coach.reminders.updated" }, + meta: { status: row.status }, + }); + + return apiSuccess({ + reminder: { + id: row.id, + note, + metric: row.metric, + triggerKind: row.triggerKind, + dueAt: row.dueAt?.toISOString() ?? null, + contextCue: row.contextCue, + status: row.status, + source: row.source, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }, + }); +}); + +export const DELETE = apiHandler( + async (_request: NextRequest, ctx: RouteCtx) => { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "coach"); + if (!gate.enabled) return gate.response; + + const limited = await enforceMutateLimit("delete", user.id); + if (limited) return limited; + + const { id } = await ctx.params; + + const { count } = await prisma.coachReminder.updateMany({ + where: { id, userId: user.id, deletedAt: null }, + data: { deletedAt: new Date() }, + }); + + const deleted = count > 0; + annotate({ + action: { name: "coach.reminders.deleted" }, + meta: { deleted }, + }); + return apiSuccess({ deleted }); + }, +); + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; diff --git a/src/app/api/coach/reminders/route.ts b/src/app/api/coach/reminders/route.ts new file mode 100644 index 000000000..c69985795 --- /dev/null +++ b/src/app/api/coach/reminders/route.ts @@ -0,0 +1,202 @@ +/** + * GET /api/coach/reminders — list the caller's Coach episodic reminders. + * POST /api/coach/reminders — create one manually (the ledger "+ add" path). + * + * v1.22 (B2/B6) — the management + in-app-surface read for the durable + * "remind me about X" memory the Coach captures inline via the `---REMEMBER---` + * sentinel. The note free-text is decrypted on the fly; an undecryptable row (a + * key rotated out of the map) is skipped rather than 500ing the whole list. + * + * Ownership: every query is scoped `where: { userId, ... }`, so a caller can + * only ever see / create their own reminders. The owner is always narrowed from + * the session, never the body. Coach-gated by the same + * `requireModuleEnabled(userId, "coach")` kill-switch as the rest of the Coach + * management stack. + * + * `?status=` optionally filters (one status, or a comma set like `due,surfaced` + * for the in-app tile). Omitted returns the non-terminal set (proposed / active + * / due / surfaced), soonest-due first. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { prisma } from "@/lib/db"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { + resolveWhenGrammar, + MAX_REMINDERS_PER_USER, +} from "@/lib/ai/coach/reminders"; +import { + coachRemindersListQuerySchema, + coachReminderCreateSchema, +} from "@/lib/validations/coach-reminder"; + +const NON_TERMINAL = ["proposed", "active", "due", "surfaced"] as const; + +interface ReminderRow { + id: string; + metric: string | null; + noteEncrypted: Uint8Array; + triggerKind: string; + dueAt: Date | null; + contextCue: string | null; + status: string; + source: string; + createdAt: Date; + updatedAt: Date; +} + +function toDto(row: ReminderRow, note: string) { + return { + id: row.id, + note, + metric: row.metric, + triggerKind: row.triggerKind, + dueAt: row.dueAt?.toISOString() ?? null, + contextCue: row.contextCue, + status: row.status, + source: row.source, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +export const GET = apiHandler(async (req: Request) => { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "coach"); + if (!gate.enabled) return gate.response; + + const url = new URL(req.url); + const parsed = coachRemindersListQuerySchema.safeParse({ + status: url.searchParams.get("status") ?? undefined, + }); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + const statusWhere = parsed.data.status + ? { status: { in: parsed.data.status } } + : { status: { in: [...NON_TERMINAL] } }; + + const rows = (await prisma.coachReminder.findMany({ + where: { userId: user.id, deletedAt: null, ...statusWhere }, + orderBy: [{ dueAt: "asc" }, { updatedAt: "desc" }], + select: { + id: true, + metric: true, + noteEncrypted: true, + triggerKind: true, + dueAt: true, + contextCue: true, + status: true, + source: true, + createdAt: true, + updatedAt: true, + }, + })) as ReminderRow[]; + + const reminders: ReturnType[] = []; + for (const row of rows) { + let note: string; + try { + note = decryptFromBytes(row.noteEncrypted); + } catch { + // Fail closed per row — never surface ciphertext, never 500 the list. + continue; + } + reminders.push(toDto(row, note)); + } + + annotate({ + action: { name: "coach.reminders.listed" }, + meta: { count: reminders.length }, + }); + + return apiSuccess({ reminders }); +}); + +export const POST = apiHandler(async (req: Request) => { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "coach"); + if (!gate.enabled) return gate.response; + + const rate = await checkRateLimit( + `coach-reminders:create:${user.id}`, + 30, + 60_000, + ); + if (!rate.allowed) return apiError("Too many requests", 429); + + const { data: body, error: jsonError } = await safeJson(req, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = coachReminderCreateSchema.safeParse(body); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + // Resolve the optional `when` against the closed grammar; an invalid token + // 422s rather than silently dropping the timing the user asked for. + const now = new Date(); + let trigger: ReturnType = null; + if (parsed.data.when) { + trigger = resolveWhenGrammar(parsed.data.when, now); + if (!trigger) return apiError("coach.reminder.invalidWhen", 422); + } + + // Per-user cap on the non-terminal set. + const nonTerminal = await prisma.coachReminder.count({ + where: { + userId: user.id, + deletedAt: null, + status: { in: [...NON_TERMINAL] }, + }, + }); + if (nonTerminal >= MAX_REMINDERS_PER_USER) { + return apiError("coach.reminder.capReached", 409); + } + + // Field-by-field — no mass assignment. Manual reminders are active on create. + const created = await prisma.coachReminder.create({ + data: { + userId: user.id, + noteEncrypted: encryptToBytes(parsed.data.note), + metric: parsed.data.metric ?? null, + triggerKind: trigger?.triggerKind ?? "date", + dueAt: trigger?.dueAt ?? null, + contextCue: trigger?.contextCue ?? null, + status: "active", + source: "manual", + }, + select: { + id: true, + metric: true, + noteEncrypted: true, + triggerKind: true, + dueAt: true, + contextCue: true, + status: true, + source: true, + createdAt: true, + updatedAt: true, + }, + }); + + annotate({ + action: { name: "coach.reminders.created" }, + meta: { triggerKind: created.triggerKind, hasDue: created.dueAt != null }, + }); + + return apiSuccess( + { reminder: toDto(created as ReminderRow, parsed.data.note) }, + 201, + ); +}); + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; diff --git a/src/app/api/coach/suggested-actions/route.ts b/src/app/api/coach/suggested-actions/route.ts new file mode 100644 index 000000000..8302bc33c --- /dev/null +++ b/src/app/api/coach/suggested-actions/route.ts @@ -0,0 +1,188 @@ +/** + * POST /api/coach/suggested-actions — confirm a generalised Coach action card. + * + * v1.22 (F6) — the confirm half of the generalised propose→confirm moat. The + * Coach proposes an action via the closed `---SUGGEST-ACTION---` sentinel → + * `suggestedAction` SSE frame → ``. This endpoint is what + * the card's confirm button calls. It switches on the CLOSED `actionType` + * allowlist and, for each, builds the entity FIELD-BY-FIELD from a + * server-resolved spec: + * + * - `checkup.create` → a preventive-care Vorsorge `MeasurementReminder` + * (free-text label + a closed interval id the SERVER resolves to an RRULE), + * minted through the SAME engine the Vorsorge surface uses. + * - `reminder.note` → a `CoachReminder` (note + optional closed `when` + * grammar + optional metric). + * + * HARD SAFETY: never auto-apply (this endpoint runs only on the explicit + * confirm tap); closed allowlist only (the discriminated-union Zod gate rejects + * any other `actionType`); NEVER a medication / dose / clinical-target change. + * Cookie-auth, coach module-gated, per-user rate-limited, owner-scoped — no + * IDOR, no mass assignment. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { + resolveWhenGrammar, + MAX_REMINDERS_PER_USER, +} from "@/lib/ai/coach/reminders"; +import { CHECKUP_INTERVAL_CATALOG } from "@/lib/ai/coach/suggest-action"; +import { coachSuggestedActionSchema } from "@/lib/validations/coach-reminder"; +import { + computeReminderNextDueAt, + type ReminderScheduleInput, +} from "@/lib/measurement-reminders/scheduling"; +import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; + +const DEFAULT_TIMEZONE = "Europe/Berlin"; +const CHECKUP_NOTIFY_HOUR = 9; + +async function postAction(request: NextRequest): Promise { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "coach"); + if (!gate.enabled) return gate.response; + + const rate = await checkRateLimit( + `coach:suggested-action:${user.id}`, + 30, + 60_000, + ); + if (!rate.allowed) return apiError("coach.action.rateLimited", 429); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = coachSuggestedActionSchema.safeParse(body); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + const action = parsed.data; + + // ── checkup.create → a preventive-care Vorsorge reminder ───── + if (action.actionType === "checkup.create") { + // The model named an interval id; the SERVER owns the RRULE so the model + // can never mint an arbitrary schedule. + const preset = CHECKUP_INTERVAL_CATALOG[action.interval]; + if (!preset) return apiError("coach.action.unknownInterval", 422); + + const row = await prisma.user.findUnique({ + where: { id: user.id }, + select: { timezone: true }, + }); + const timezone = row?.timezone || DEFAULT_TIMEZONE; + const now = new Date(); + const scheduleInput: ReminderScheduleInput = { + intervalDays: null, + rrule: preset.rrule, + anchorDate: null, + notifyHour: CHECKUP_NOTIFY_HOUR, + lastSatisfiedAt: null, + createdAt: now, + endsOn: null, + }; + const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now); + + // Field-by-field — no mass assignment. A free-text checklist Vorsorge + // (measurementType null) so it resolves only on a manual "done". origin + // COACH marks the provenance. + const created = await prisma.measurementReminder.create({ + data: { + userId: user.id, + label: action.label, + measurementType: null, + intervalDays: null, + rrule: preset.rrule, + anchorDate: null, + endsOn: null, + origin: "COACH", + notifyHour: CHECKUP_NOTIFY_HOUR, + location: null, + enabled: true, + nextDueAt, + }, + }); + + await auditLog("measurementReminder.create.coachAction", { + userId: user.id, + ipAddress: getClientIp(request), + details: { reminderId: created.id, interval: action.interval }, + }); + annotate({ + action: { name: "coach.action.applied" }, + meta: { actionType: action.actionType, interval: action.interval }, + }); + + return apiSuccess( + { + ok: true, + actionType: action.actionType, + reminder: toMeasurementReminderDto(created), + }, + 201, + ); + } + + // ── reminder.note → a CoachReminder ────────────────────────── + const now = new Date(); + let trigger: ReturnType = null; + if (action.when) { + trigger = resolveWhenGrammar(action.when, now); + if (!trigger) return apiError("coach.reminder.invalidWhen", 422); + } + + const nonTerminal = await prisma.coachReminder.count({ + where: { + userId: user.id, + deletedAt: null, + status: { in: ["proposed", "active", "due", "surfaced"] }, + }, + }); + if (nonTerminal >= MAX_REMINDERS_PER_USER) { + return apiError("coach.reminder.capReached", 409); + } + + const created = await prisma.coachReminder.create({ + data: { + userId: user.id, + noteEncrypted: encryptToBytes(action.note), + metric: action.metric ?? null, + triggerKind: trigger?.triggerKind ?? "date", + dueAt: trigger?.dueAt ?? null, + contextCue: trigger?.contextCue ?? null, + status: "active", + source: "action", + }, + select: { id: true }, + }); + + annotate({ + action: { name: "coach.action.applied" }, + meta: { + actionType: action.actionType, + hasDue: trigger?.dueAt != null, + }, + }); + + return apiSuccess( + { ok: true, actionType: action.actionType, reminderId: created.id }, + 201, + ); +} + +export const POST = apiHandler(postAction); +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts index b74c987bd..fb03aa650 100644 --- a/src/lib/openapi/routes/coach.ts +++ b/src/lib/openapi/routes/coach.ts @@ -22,6 +22,12 @@ import { coachChatRequestSchema } from "@/lib/ai/coach/types"; import { exportSelectionSchema } from "@/lib/validations/health-record-export"; import { createShareLinkSchema } from "@/lib/validations/clinician-share-link"; import { coachReminderSuggestionActionSchema } from "@/lib/validations/coach-reminder-suggestion"; +import { COACH_REMINDER_STATUSES } from "@/lib/ai/coach/reminders"; +import { + coachReminderCreateSchema, + coachReminderPatchSchema, + coachSuggestedActionSchema, +} from "@/lib/validations/coach-reminder"; import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; // ── Coach cadence suggestions (v1.18.1) ────────────────────────────── @@ -316,6 +322,70 @@ coachPlansListQuerySchema.meta({ "Optional `?status=` filter for the plans list (proposed | active | met | abandoned). Omitted returns the non-terminal set (proposed + active).", }); +// ── Coach episodic reminders (v1.22 B2/B6) ────────────────────────────── +const coachReminderItem = z + .object({ + id: z.string(), + note: z + .string() + .nullable() + .describe( + "Decrypted note (the user's own framing). Null when the row's key id is no longer in the map.", + ), + metric: z.string().nullable(), + triggerKind: z.enum(["date", "context"]), + dueAt: z.iso.datetime({ offset: true }).nullable(), + contextCue: z.string().nullable(), + status: z.enum(COACH_REMINDER_STATUSES), + source: z.enum(["sentinel", "extractor", "manual", "action"]), + createdAt: z.iso.datetime({ offset: true }), + updatedAt: z.iso.datetime({ offset: true }), + }) + .meta({ + id: "CoachReminder", + description: + "One Coach episodic reminder, decrypted server-side. The note reads null only when the encryption key id has rotated out of the map.", + }); + +const coachRemindersListResponse = z.object({ + reminders: z + .array(coachReminderItem) + .describe( + "The caller's reminders, soonest-due first. Undecryptable rows are omitted. No `status` filter returns the non-terminal set (proposed + active + due + surfaced).", + ), +}); + +const coachReminderCreatedResponse = z.object({ + reminder: coachReminderItem, +}); + +const coachReminderUpdatedResponse = z.object({ + reminder: coachReminderItem, +}); + +const coachReminderDeletedResponse = z.object({ + deleted: z + .boolean() + .describe( + "True when a reminder owned by the caller was soft-deleted; false for an unknown / cross-user / already-deleted id (idempotent no-op).", + ), +}); + +const coachSuggestedActionResultResponse = z + .object({ + ok: z.literal(true), + actionType: z.enum(["checkup.create", "reminder.note"]), + reminder: z.unknown().optional(), + reminderId: z.string().optional(), + }) + .meta({ + id: "CoachSuggestedActionResult", + description: + "Outcome of confirming a Coach action card. `checkup.create` returns the created MeasurementReminder DTO; `reminder.note` returns the created CoachReminder id.", + }); +// The request bodies carry their `.meta({ id })` from +// `@/lib/validations/coach-reminder` (single-source with the runtime parse). + // ── Coach conversation history (v1.18.0) ───────────────────────────── // List + detail + delete surface for the Coach's persisted chat // conversations. Bodies are stored encrypted at rest; the detail @@ -1052,6 +1122,198 @@ export const coachPaths: NonNullable = { }, }; +export const coachReminderPaths: NonNullable = { + "/api/coach/reminders": { + get: { + tags: ["Insights"], + summary: "List the caller's Coach reminders", + description: + 'v1.22 (B2/B6) — the durable "remind me about X" memory the Coach captured inline, decrypted on the fly, soonest-due first. Pass `?status=` (one status or a comma set like `due,surfaced` for the in-app tile); omitted returns the non-terminal set (proposed + active + due + surfaced). Coach-gated (`requireModuleEnabled("coach")`). Auth via cookie or Bearer; the owner is narrowed from the session. Undecryptable rows are omitted.', + parameters: [ + { + name: "status", + in: "query", + required: false, + schema: { type: "string" }, + description: + "Filter to one status or a comma-separated set (proposed | active | due | surfaced | done | dismissed). Omit for the non-terminal set.", + }, + ], + responses: { + "200": { + description: "The caller's reminders.", + content: { + "application/json": { + schema: dataEnvelope( + coachRemindersListResponse, + "CoachRemindersList", + ), + }, + }, + }, + "403": { + description: "Coach surface disabled.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + post: { + tags: ["Insights"], + summary: "Create a Coach reminder manually", + description: + "v1.22 (B6) — create a reminder from the ledger. `note` is the only writable content; `when` is the closed grammar (ISO date | +Nd / +Nw | a context cue) resolved into the trigger server-side; `metric` optional. The per-user cap returns 409 when the non-terminal set is full. Coach-gated. Auth via cookie or Bearer; the owner is narrowed from the session.", + requestBody: { + required: true, + content: { + "application/json": { schema: coachReminderCreateSchema }, + }, + }, + responses: { + "201": { + description: "The created reminder.", + content: { + "application/json": { + schema: dataEnvelope( + coachReminderCreatedResponse, + "CoachReminderCreated", + ), + }, + }, + }, + "403": { + description: "Coach surface disabled.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "409": { + description: "The per-user reminder cap is full.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/coach/reminders/{id}": { + patch: { + tags: ["Insights"], + summary: "Confirm or update a Coach reminder's lifecycle", + description: + "v1.22 (B6) — confirm a proposed reminder (→ active), mark it done / dismissed, or re-schedule via the closed `when` grammar (null clears the due moment). The body never carries the note text. A foreign / unknown / already-deleted id maps to 404 (never 403) so the existence channel does not leak. Coach-gated. Auth via cookie or Bearer.", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + description: "Reminder id.", + }, + ], + requestBody: { + required: true, + content: { + "application/json": { schema: coachReminderPatchSchema }, + }, + }, + responses: { + "200": { + description: "The reminder after the lifecycle update.", + content: { + "application/json": { + schema: dataEnvelope( + coachReminderUpdatedResponse, + "CoachReminderUpdated", + ), + }, + }, + }, + "403": { + description: "Coach surface disabled.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "404": { + description: "Reminder not found or not owned by the caller.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Insights"], + summary: "Soft-delete one Coach reminder", + description: + "v1.22 (B6) — soft-deletes a single reminder owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`. Coach-gated. Auth via cookie or Bearer.", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + description: "Reminder id.", + }, + ], + responses: { + "200": { + description: + "The reminder was soft-deleted (`deleted: true`) or the id matched nothing the caller owns (`deleted: false`).", + content: { + "application/json": { + schema: dataEnvelope( + coachReminderDeletedResponse, + "CoachReminderDeleted", + ), + }, + }, + }, + "403": { + description: "Coach surface disabled.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, +}; + +export const coachSuggestedActionPaths: NonNullable = + { + "/api/coach/suggested-actions": { + post: { + tags: ["Insights"], + summary: "Confirm a Coach action card", + description: + 'v1.22 (F6) — the confirm half of the generalised propose→confirm moat. `actionType` is from the CLOSED allowlist (checkup.create | reminder.note) — NEVER a medication or clinical change. `checkup.create` builds a preventive-care Vorsorge `MeasurementReminder` (free-text label + a closed interval id resolved to an RRULE server-side); `reminder.note` builds a `CoachReminder`. Nothing is created without this explicit confirm. Coach-gated (`requireModuleEnabled("coach")`); per-user rate-limited (429). Auth via cookie or Bearer; the owner is narrowed from the session, built field-by-field — no mass assignment, no IDOR.', + requestBody: { + required: true, + content: { + "application/json": { schema: coachSuggestedActionSchema }, + }, + }, + responses: { + "201": { + description: "The action was applied (entity created server-side).", + content: { + "application/json": { + schema: dataEnvelope( + coachSuggestedActionResultResponse, + "CoachSuggestedActionResultCreated", + ), + }, + }, + }, + "403": { + description: "Coach surface disabled.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "409": { + description: "The per-user reminder cap is full (reminder.note).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + }; + export const coachFeedbackPaths: NonNullable = { "/api/insights/chat/messages/{id}/feedback": { post: { diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 8eb903034..b4b5a9c91 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -27,6 +27,8 @@ import { coachFeedbackPaths, coachPaths, coachReminderSuggestionPaths, + coachReminderPaths, + coachSuggestedActionPaths, } from "./coach"; import { consentPaths } from "./consent"; import { cyclePaths } from "./cycle"; @@ -62,6 +64,8 @@ export const openApiPaths: NonNullable = { ...profilePaths, ...coachPaths, ...coachReminderSuggestionPaths, + ...coachReminderPaths, + ...coachSuggestedActionPaths, ...adminInvitePaths, ...coachFeedbackPaths, ...adminDiagnosticPaths, diff --git a/src/lib/validations/coach-reminder.ts b/src/lib/validations/coach-reminder.ts new file mode 100644 index 000000000..9a1eccccb --- /dev/null +++ b/src/lib/validations/coach-reminder.ts @@ -0,0 +1,138 @@ +/** + * v1.22 (B2/B6/F6) — request schemas for the Coach episodic-reminder surface + * (`/api/coach/reminders` + `/[id]`) and the generalised confirm-card endpoint + * (`/api/coach/suggested-actions`). + * + * Lives outside the route files so the OpenAPI registry can import it without + * touching the route modules (route files may only export handlers + config). + * + * No `userId` field anywhere — the owner is always narrowed from the session. + * The note free-text is the only user-writable content; everything timing is + * resolved SERVER-side from a closed grammar / catalog, so a client can never + * mint an arbitrary schedule. + */ +import { z } from "zod/v4"; + +import { + COACH_REMINDER_STATUSES, + REMINDER_NOTE_MAX_CHARS, + REMINDER_METRIC_MAX_CHARS, +} from "@/lib/ai/coach/reminders"; +import { + SUGGESTED_ACTION_TYPES, + CHECKUP_LABEL_MAX_CHARS, + ACTION_NOTE_MAX_CHARS, + ACTION_METRIC_MAX_CHARS, +} from "@/lib/ai/coach/suggest-action"; + +/** A `when` grammar token: ISO date | relative +Nd/+Nw | a context cue. */ +const whenToken = z.string().trim().min(1).max(32); +const metricToken = z + .string() + .trim() + .min(1) + .max(REMINDER_METRIC_MAX_CHARS) + .regex(/^[A-Za-z][A-Za-z0-9_]*$/, "metric must be a metric key"); + +/** + * `?status=` filter on the list endpoint. Accepts one status or a + * comma-separated set (e.g. `due,surfaced` for the in-app tile). Each token is + * validated against the closed status enum; an unknown token 422s. + */ +export const coachRemindersListQuerySchema = z.object({ + status: z + .string() + .optional() + .transform((v) => + v + ? v + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : undefined, + ) + .pipe(z.array(z.enum(COACH_REMINDER_STATUSES)).min(1).optional()), +}); + +/** + * Manual create (the ledger "+ add a reminder" path). The note is the only + * free text; `when` is the closed grammar (resolved server-side into the + * trigger), `metric` optional. Strict: unknown keys 422. + */ +export const coachReminderCreateSchema = z + .object({ + note: z.string().trim().min(1).max(REMINDER_NOTE_MAX_CHARS), + when: whenToken.optional(), + metric: metricToken.optional(), + }) + .strict() + .meta({ + id: "CoachReminderCreate", + description: + "Create a Coach reminder manually. `note` is free text (the only writable content). `when` is an optional closed grammar token — an ISO date (YYYY-MM-DD), a relative offset (+Nd / +Nw), or a context cue (NEXT_BP_LOGGED, NEXT_WEIGHT_LOGGED, NEXT_SLEEP_LOGGED, NEXT_APP_OPEN) — resolved into the trigger server-side. Omit `when` for a recall-only note. Strict: unknown keys 422.", + }); + +/** + * The lifecycle PATCH. `status` confirms a proposed reminder (→ active), marks + * it done / dismissed, or re-activates it. `when` optionally re-schedules + * (snooze) via the same closed grammar. The body never carries the note text, + * so a client can never overwrite a reminder's prose — only its lifecycle. + */ +export const coachReminderPatchSchema = z + .object({ + status: z.enum(COACH_REMINDER_STATUSES).optional(), + when: whenToken.nullable().optional(), + }) + .strict() + .refine( + (v) => v.status !== undefined || v.when !== undefined, + "At least one of status or when must be provided", + ) + .meta({ + id: "CoachReminderPatch", + description: + "Update a Coach reminder's lifecycle. `status` confirms (proposed → active), or marks done / dismissed. `when` re-schedules via the closed grammar (null clears the due moment). Never carries the note text. Strict: unknown keys 422.", + }); + +// ── Suggested-action confirm endpoint (F6) ────────────────────────────── + +const checkupParams = z.object({ + actionType: z.literal("checkup.create"), + label: z.string().trim().min(1).max(CHECKUP_LABEL_MAX_CHARS), + interval: z.enum(["yearly", "halfYearly", "quarterly", "monthly"]), +}); + +const reminderNoteParams = z.object({ + actionType: z.literal("reminder.note"), + note: z.string().trim().min(1).max(ACTION_NOTE_MAX_CHARS), + when: whenToken.optional(), + metric: z + .string() + .trim() + .min(1) + .max(ACTION_METRIC_MAX_CHARS) + .regex(/^[A-Za-z][A-Za-z0-9_]*$/) + .optional(), +}); + +/** + * The confirm-card POST body. `actionType` names a closed-allowlist action; the + * params are a discriminated union, validated field-by-field. The server resolves + * every schedule from a closed catalog and builds the entity field-by-field — + * the client can never mint an arbitrary entity, widen a cadence, or touch a + * medication / clinical target. + */ +export const coachSuggestedActionSchema = z + .discriminatedUnion("actionType", [checkupParams, reminderNoteParams]) + .meta({ + id: "CoachSuggestedAction", + description: + "Confirm a Coach action card. `actionType` is from the closed allowlist (checkup.create, reminder.note); NEVER a medication or clinical change. `checkup.create` builds a preventive-care Vorsorge reminder (free-text label + a closed interval id resolved to an RRULE server-side); `reminder.note` builds a CoachReminder (note + optional closed `when` grammar + optional metric). Nothing is created without this explicit confirm.", + }); + +export type CoachSuggestedActionInput = z.infer< + typeof coachSuggestedActionSchema +>; + +/** Stable list of confirmable action types (mirrors the lib allowlist). */ +export const COACH_SUGGESTED_ACTION_TYPES = SUGGESTED_ACTION_TYPES; From 5331528076e0c3ad3c52b425a1ce77fdab00a120 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 13:38:25 +0200 Subject: [PATCH 4/5] feat(coach): surface reminders in-app and add the action card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Your reminders" panel under Settings → Coach that lists the stored reminders, highlights the due ones, and gives the user view, confirm, done/dismiss, and delete control over their own memory. Light the Coach launcher dot when a reminder is due, alongside the existing nudge signal. Add the generic confirm-card component for the suggested-action frame: a plain-text preview with a localized per-action heading and a single confirm tap that posts the validated params to the build endpoint. Translate the new copy in English and German. --- messages/de.json | 41 ++++ messages/en.json | 41 ++++ .../coach-panel/suggested-action-card.tsx | 133 +++++++++++ src/components/insights/layout-coach-fab.tsx | 23 +- .../settings/coach-reminders-section.tsx | 212 ++++++++++++++++++ src/components/settings/coach-section.tsx | 3 + src/hooks/use-coach-reminders.ts | 78 +++++++ src/lib/query-keys/coach.ts | 10 + 8 files changed, 539 insertions(+), 2 deletions(-) create mode 100644 src/components/insights/coach-panel/suggested-action-card.tsx create mode 100644 src/components/settings/coach-reminders-section.tsx create mode 100644 src/hooks/use-coach-reminders.ts diff --git a/messages/de.json b/messages/de.json index fc469ffe7..df0fae7c5 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4416,6 +4416,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.", @@ -7051,6 +7073,25 @@ "glucoseStructured": "Miss deine Glukose nach einem strukturierten Tagesplan" } }, + "suggestedAction": { + "apply": "Hinzufügen", + "dismiss": "Jetzt nicht", + "applied": "Hinzugefügt", + "dismissed": "Verworfen", + "failed": "Konnte das nicht hinzufügen — bitte erneut versuchen", + "checkup": { + "title": "Vorsorge-Erinnerung hinzufügen?" + }, + "note": { + "title": "Erinnerung speichern?" + }, + "interval": { + "yearly": "Jedes Jahr", + "halfYearly": "Alle sechs Monate", + "quarterly": "Alle drei Monate", + "monthly": "Jeden Monat" + } + }, "settings": { "reminderSuggestionsLabel": "Kadenz-Vorschläge", "reminderSuggestionsDescription": "Lass den Coach gelegentlich eine evidenzbasierte Erinnerung anbieten, eine Metrik regelmäßiger zu messen." diff --git a/messages/en.json b/messages/en.json index c02b890d4..58bc1940e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4416,6 +4416,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.", @@ -7051,6 +7073,25 @@ "glucoseStructured": "Test your glucose on a structured daily schedule" } }, + "suggestedAction": { + "apply": "Add", + "dismiss": "Not now", + "applied": "Added", + "dismissed": "Dismissed", + "failed": "Couldn't add that — please try again", + "checkup": { + "title": "Add a check-up reminder?" + }, + "note": { + "title": "Save a reminder?" + }, + "interval": { + "yearly": "Every year", + "halfYearly": "Every six months", + "quarterly": "Every three months", + "monthly": "Every month" + } + }, "settings": { "reminderSuggestionsLabel": "Cadence suggestions", "reminderSuggestionsDescription": "Let the Coach offer an occasional, evidence-based reminder to measure a metric more regularly." diff --git a/src/components/insights/coach-panel/suggested-action-card.tsx b/src/components/insights/coach-panel/suggested-action-card.tsx new file mode 100644 index 000000000..5ac6f57c9 --- /dev/null +++ b/src/components/insights/coach-panel/suggested-action-card.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Check, Loader2, Sparkles } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { useTranslations } from "@/lib/i18n/context"; +import { apiPost } from "@/lib/api/api-fetch"; +import type { CoachSuggestedAction } from "@/lib/ai/coach/suggest-action"; + +/** + * v1.22 (F6) — the generalised one-tap "confirm → apply" Coach action card. + * + * Rendered under an assistant bubble when the turn carried a `suggestedAction` + * (live from the SSE frame, or persisted on the message provenance). The card + * shows a localized heading per action type (`titleKey`) + the model's plain-text + * summary, and two actions: + * + * - "Add" → POSTs the validated params to `POST /api/coach/suggested-actions`, + * which builds the entity SERVER-side, field-by-field. NEVER auto-applied. + * - "Not now" → dismisses the card locally (no server write). + * + * RENDER dedup mirrors the cadence card: once settled, the card collapses to a + * confirmation so a stale stream + persisted twin can't double-prompt. The + * STATIC copy is i18n; the dynamic summary/label is the user's own content + * (plain text — no markdown, matching the XSS posture). + */ +export function SuggestedActionCard({ + action, +}: { + action: CoachSuggestedAction; +}) { + const { t } = useTranslations(); + const [settled, setSettled] = useState<"applied" | "dismissed" | null>(null); + + const apply = useMutation({ + mutationFn: async () => { + await apiPost("/api/coach/suggested-actions", action.params); + }, + onSuccess: () => setSettled("applied"), + onError: () => toast.error(t("coach.suggestedAction.failed")), + }); + + if (settled) { + return ( +

+

+ ); + } + + const busy = apply.isPending; + const intervalLabel = + action.params.actionType === "checkup.create" + ? t(`coach.suggestedAction.interval.${action.params.interval}`) + : null; + + return ( +
+
+ +
+ + {t(action.titleKey)} + + + {action.summary} + + {intervalLabel && ( + + {intervalLabel} + + )} +
+
+
+ + +
+
+ ); +} diff --git a/src/components/insights/layout-coach-fab.tsx b/src/components/insights/layout-coach-fab.tsx index e59d77802..461734302 100644 --- a/src/components/insights/layout-coach-fab.tsx +++ b/src/components/insights/layout-coach-fab.tsx @@ -115,8 +115,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 @@ -194,7 +213,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} @@ -239,7 +258,7 @@ export function LayoutCoachFab() { )} >