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

+194
View File
@@ -13,6 +13,11 @@ import {
INSIGHTS_SECTION_IDS,
} from "@/lib/insights-layout";
import { COACH_FACT_CATEGORIES } from "@/lib/ai/coach/facts";
import { COACH_PLAN_STATUSES } from "@/lib/ai/coach/plans";
import {
coachPlanPatchSchema,
coachPlansListQuerySchema,
} from "@/lib/validations/coach-plan";
import { coachChatRequestSchema } from "@/lib/ai/coach/types";
import { exportSelectionSchema } from "@/lib/validations/health-record-export";
import { createShareLinkSchema } from "@/lib/validations/clinician-share-link";
@@ -234,6 +239,83 @@ const coachFactDeletedResponse = z.object({
),
});
// ── Coach plans (v1.21.3 B1) ─────────────────────────────────────────
// The durable goal / if-then plans the Coach proposes and the user
// confirms. The extractor writes a plan as `proposed`; the PATCH below is
// the only path that activates it. The list/PATCH never accept the metric
// or the encrypted free text — a client can only confirm / change a plan's
// lifecycle, never inject or overwrite its prose.
const coachPlanItem = z
.object({
id: z.string(),
metric: z
.string()
.describe("The metric this plan moves (e.g. WEIGHT, SLEEP)."),
ifCue: z
.string()
.nullable()
.describe(
"Decrypted if-cue (the trigger). Null when the row's key id is no longer in the map.",
),
thenAction: z
.string()
.nullable()
.describe(
"Decrypted then-action. Null when the row's key id is no longer in the map.",
),
target: z
.string()
.nullable()
.describe("Decrypted optional target; null when none / undecryptable."),
status: z
.enum(COACH_PLAN_STATUSES)
.describe("App-side lifecycle: proposed | active | met | abandoned."),
reviewDate: z.iso
.datetime({ offset: true })
.nullable()
.describe("Optional check-in checkpoint; null when none."),
createdAt: z.iso.datetime({ offset: true }),
updatedAt: z.iso.datetime({ offset: true }),
})
.meta({
id: "CoachPlan",
description:
"One Coach goal / if-then plan, decrypted server-side. The free-text fields read null only when the encryption key id has rotated out of the map.",
});
const coachPlansListResponse = z.object({
plans: z
.array(coachPlanItem)
.describe(
"The caller's plans, newest first. Undecryptable rows are omitted from the list endpoint. No `status` filter returns the non-terminal set (proposed + active).",
),
});
const coachPlanUpdatedResponse = z.object({
plan: coachPlanItem.describe("The plan after the lifecycle update."),
});
const coachPlanDeletedResponse = z.object({
deleted: z
.boolean()
.describe(
"True when a plan owned by the caller was soft-deleted; false for an unknown / cross-user / already-deleted id (idempotent no-op).",
),
});
coachPlanPatchSchema.meta({
id: "CoachPlanPatchRequest",
description:
"v1.21.3 — confirm or update a Coach plan's lifecycle. `status` moves a `proposed` plan to `active` (confirm), or to `met` / `abandoned`. `reviewDate` pins (ISO instant) or clears (null) a check-in checkpoint. At least one field is required. Strict: unknown keys 422 — the body can never carry the metric, the encrypted text, or a userId.",
});
coachPlansListQuerySchema.meta({
id: "CoachPlansListQuery",
description:
"Optional `?status=` filter for the plans list (proposed | active | met | abandoned). Omitted returns the non-terminal set (proposed + active).",
});
// ── 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
@@ -856,6 +938,118 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/coach/plans": {
get: {
tags: ["Insights"],
summary: "List the caller's Coach goal / if-then plans",
description:
'v1.21.3 (B1) — returns the durable plans the Coach has proposed for the caller, newest first, each decrypted on the fly. A plan is an "if-then" implementation intention tied to one metric, with an optional target. The Coach extractor writes a plan as `proposed`; only `PATCH /api/coach/plans/{id}` activates it. Pass `?status=` to filter (proposed | active | met | abandoned); omitted returns the non-terminal set (proposed + active). Coach-gated (`requireModuleEnabled("coach")`); a disabled surface 403s. Auth via cookie or Bearer; the owner is always narrowed from the session, never the body. Undecryptable rows are omitted rather than failing the read.',
parameters: [
{
name: "status",
in: "query",
required: false,
schema: { type: "string", enum: [...COACH_PLAN_STATUSES] },
description:
"Filter to a single lifecycle status. Omit for the non-terminal set (proposed + active).",
},
],
responses: {
"200": {
description: "The caller's plans.",
content: {
"application/json": {
schema: dataEnvelope(coachPlansListResponse, "CoachPlansList"),
},
},
},
"403": {
description: "Coach surface disabled.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/api/coach/plans/{id}": {
patch: {
tags: ["Insights"],
summary: "Confirm or update a Coach plan's lifecycle",
description:
"v1.21.3 (B1) — confirm a proposed plan (status proposed → active) or mark it met / abandoned, and optionally set / clear a review date. The body carries ONLY lifecycle fields — never the metric or the encrypted free text — so a client can change a plan's status but never inject or overwrite its prose. A foreign / unknown / already-deleted id maps to 404 (never 403) so the existence channel does not leak across accounts. Coach-gated. Auth via cookie or Bearer; the owner is narrowed from the session.",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string" },
description: "Plan id.",
},
],
requestBody: {
required: true,
content: {
"application/json": { schema: coachPlanPatchSchema },
},
},
responses: {
"200": {
description: "The plan after the lifecycle update.",
content: {
"application/json": {
schema: dataEnvelope(
coachPlanUpdatedResponse,
"CoachPlanUpdated",
),
},
},
},
"403": {
description: "Coach surface disabled.",
content: { "application/json": { schema: errorEnvelope } },
},
"404": {
description: "Plan not found or not owned by the caller.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
delete: {
tags: ["Insights"],
summary: "Soft-delete one Coach plan",
description:
"v1.21.3 (B1) — soft-deletes a single plan owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`, never revealing whether the id exists under another account. Coach-gated. Auth via cookie or Bearer.",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string" },
description: "Plan id.",
},
],
responses: {
"200": {
description:
"The plan was soft-deleted (`deleted: true`) or the id matched nothing the caller owns (`deleted: false`).",
content: {
"application/json": {
schema: dataEnvelope(
coachPlanDeletedResponse,
"CoachPlanDeleted",
),
},
},
},
"403": {
description: "Coach surface disabled.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
};
export const coachFeedbackPaths: NonNullable<ZodOpenApiObject["paths"]> = {