feat(coach): goal / if-then plan memory + locale-keyed dashboard snapshot

Add a CoachPlan model: durable, user-scoped if-then implementation plans
the Coach proposes and the user confirms. The background memory-refresh
pass extracts plans the user agreed to and writes them as "proposed"; only
the user-facing PATCH on /api/coach/plans/[id] activates one. The free-text
cue / action / target are encrypted at rest with the shared Bytes codec;
metric, status and dates stay plain so the injection picker ranks without a
per-row decrypt. Active plans fold into the Coach snapshot's memory block
and survive the prompt-budget drop alongside facts. List, confirm and
soft-delete routes are Coach-gated and narrow the owner from the session.

Key the dashboard-snapshot query cell by locale so a locale switch reads
freshly localised tile copy on its own cell instead of serving the prior
locale's prose until the slot goes stale. The zero-arg key stays
byte-identical, so every prefix-based invalidation keeps reaching the
locale-keyed cells; the offline-banner and preloader match on the prefix.

Migration 0194 (additive CREATE TABLE). Encryption-rotation registry and
script cover the three new Bytes columns.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 01:25:35 +02:00
1 parent ee704cacab
commit dcbac63fdf
20 files changed
+2162 -14

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
/**
* v1.21.3 (B1) — request schemas for the Coach goal / if-then plan surface
* (`/api/coach/plans` + `/api/coach/plans/[id]`).
*
* Lives outside the route files so the OpenAPI registry can import it without
* touching the route modules (route files may only export handlers + config)
* and without dragging the Prisma-backed helper module into the generator
* script.
*
* No `userId` field anywhere — the owner is always narrowed from the session.
* The extractor is the only writer of plan TEXT; this surface only lets the
* user CONFIRM (proposed → active), mark a plan met / abandoned, set a review
* date, or soft-delete. It never accepts the encrypted free-text fields, so a
* client can never inject or overwrite a plan's prose.
*/
import { z } from "zod/v4";
import { COACH_PLAN_STATUSES } from "@/lib/ai/coach/plans";
/** Optional `?status=` filter on the list endpoint. */
export const coachPlansListQuerySchema = z.object({
status: z.enum(COACH_PLAN_STATUSES).optional(),
});
/**
* The user-facing PATCH body. `status` is the only mutable lifecycle field; a
* `proposed` plan moves to `active` (confirm), or a plan moves to `met` /
* `abandoned`. `reviewDate` optionally pins / clears a check-in checkpoint
* (null clears). Strict: unknown keys 422, so a client cannot smuggle a
* userId, a metric, or the encrypted text.
*/
export const coachPlanPatchSchema = z
.object({
status: z.enum(COACH_PLAN_STATUSES).optional(),
reviewDate: z.iso.datetime({ offset: true }).nullable().optional(),
})
.strict()
.refine(
(v) => v.status !== undefined || v.reviewDate !== undefined,
"At least one of status or reviewDate must be provided",
);