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

+265
View File
@@ -4653,6 +4653,136 @@ paths:
"401": *a1
"422": *a3
"429": *a4
/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:
- proposed
- active
- met
- abandoned
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:
$ref: "#/components/schemas/CoachPlansList"
"401": *a1
"403":
description: Coach surface disabled.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
/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:
type: object
properties:
status:
type: string
enum:
- proposed
- active
- met
- abandoned
reviewDate:
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"
additionalProperties: false
responses:
"200":
description: The plan after the lifecycle update.
content:
application/json:
schema:
$ref: "#/components/schemas/CoachPlanUpdated"
"401": *a1
"403":
description: Coach surface disabled.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"404":
description: Plan 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 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:
$ref: "#/components/schemas/CoachPlanDeleted"
"401": *a1
"403":
description: Coach surface disabled.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
/api/coach/reminder-suggestions:
post:
tags:
@@ -16736,6 +16866,141 @@ components:
- data
- error
additionalProperties: false
CoachPlansList:
type: object
properties:
data:
type: object
properties:
plans:
type: array
items:
$ref: "#/components/schemas/CoachPlan"
description: The caller's plans, newest first. Undecryptable rows are omitted from the list endpoint. No `status` filter
returns the non-terminal set (proposed + active).
required:
- plans
additionalProperties: false
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
CoachPlan:
type: object
properties:
id:
type: string
metric:
type: string
description: The metric this plan moves (e.g. WEIGHT, SLEEP).
ifCue:
anyOf:
- type: string
- type: "null"
description: Decrypted if-cue (the trigger). Null when the row's key id is no longer in the map.
thenAction:
anyOf:
- type: string
- type: "null"
description: Decrypted then-action. Null when the row's key id is no longer in the map.
target:
anyOf:
- type: string
- type: "null"
description: Decrypted optional target; null when none / undecryptable.
status:
type: string
enum:
- proposed
- active
- met
- abandoned
description: "App-side lifecycle: proposed | active | met | abandoned."
reviewDate:
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"
description: Optional check-in checkpoint; null when none.
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
- metric
- ifCue
- thenAction
- target
- status
- reviewDate
- createdAt
- updatedAt
additionalProperties: false
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.
CoachPlanUpdated:
type: object
properties:
data:
type: object
properties:
plan:
description: The plan after the lifecycle update.
$ref: "#/components/schemas/CoachPlan"
required:
- plan
additionalProperties: false
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
CoachPlanDeleted:
type: object
properties:
data:
type: object
properties:
deleted:
type: boolean
description: True when a plan 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
CoachReminderSuggestionResultOk:
type: object
properties:
@@ -0,0 +1,34 @@
-- v1.21.3 (B1) — Coach goal / if-then implementation-plan memory.
--
-- Durable, user-scoped plans the Coach PROPOSES and the user CONFIRMS. The
-- extractor writes a row as status 'proposed'; only the user-facing PATCH
-- flips it to 'active'. Free-text fields (the if-cue, then-action, optional
-- target) are AES-256-GCM ciphertext stored as `bytea` via the shared Bytes
-- codec — the same shape as coach_facts.fact_encrypted. metric / status /
-- dates / source_conversation_id stay plain so the injection picker can rank
-- and filter without a per-row decrypt. status is an app-side closed enum
-- (proposed | active | met | abandoned), deliberately NOT a DB enum so this
-- migration stays purely additive. Soft-delete via deleted_at.
CREATE TABLE "coach_plans" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"metric" TEXT NOT NULL,
"if_cue_encrypted" BYTEA NOT NULL,
"then_action_encrypted" BYTEA NOT NULL,
"target_encrypted" BYTEA,
"status" TEXT NOT NULL DEFAULT 'proposed',
"review_date" TIMESTAMP(3),
"source_conversation_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"deleted_at" TIMESTAMP(3),
CONSTRAINT "coach_plans_pkey" PRIMARY KEY ("id")
);
-- Injection picker scans a user's active plans newest-first; the status +
-- deleted_at columns are part of the predicate, so the composite index lets
-- the planner serve the read index-backed.
CREATE INDEX "coach_plans_user_id_status_deleted_at_idx" ON "coach_plans"("user_id", "status", "deleted_at");
ALTER TABLE "coach_plans" ADD CONSTRAINT "coach_plans_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+43
View File
@@ -570,6 +570,7 @@ model User {
integrationStatuses IntegrationStatus[]
coachConversations CoachConversation[]
coachFacts CoachFact[]
coachPlans CoachPlan[]
coachUsage CoachUsage[]
// v1.4.25 W8d — workout passthrough (Apple HKWorkout / Withings
// getworkouts / future Strava). Schema lands now so the v1.5 iOS
@@ -3891,6 +3892,48 @@ model CoachFact {
@@map("coach_facts")
}
// v1.21.3 (B1) — durable, user-scoped goal / if-then implementation plans the
// Coach proposes and the user CONFIRMS. The Coach extractor writes a plan as
// status "proposed"; only the user-facing PATCH activates it (status "active")
// — there is no silent self-edit of the user's plan. Free-text fields (the
// if-cue, the then-action, and the optional target) are encrypted at rest
// (AES-256-GCM, the same Bytes codec as CoachFact). `metric`, `status`, the
// dates and the source-conversation id stay plain so the injection picker can
// rank / filter without paying a per-row decrypt. Soft-delete via `deletedAt`
// so an abandoned plan is hidden from injection but kept for audit.
//
// `status` is an APP-SIDE closed enum (NOT a DB enum, to keep the migration
// purely additive): proposed | active | met | abandoned.
model CoachPlan {
id String @id @default(cuid())
userId String @map("user_id")
// The metric this plan moves (e.g. WEIGHT, SLEEP, BLOOD_PRESSURE). Plain so
// the picker can filter / group without a decrypt.
metric String
// Free-text "if <cue>" and "then <action>" — implementation-intention prose,
// encrypted at rest. Never a number / measurement (those live in the data).
ifCueEncrypted Bytes @map("if_cue_encrypted")
thenActionEncrypted Bytes @map("then_action_encrypted")
// Optional free-text target ("70 kg by August", "lights out by 23:00"). Free
// prose like the cue/action, so encrypted at rest as well.
targetEncrypted Bytes? @map("target_encrypted")
// App-side closed enum: proposed | active | met | abandoned.
status String @default("proposed")
// Optional review checkpoint the Coach can recall ("how did this go by …").
reviewDate DateTime? @map("review_date")
// Provenance — nullable so a future manual-entry surface can leave it null.
sourceConversationId String? @map("source_conversation_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Soft-delete: hides the plan from injection but keeps the row for audit.
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, status, deletedAt])
@@map("coach_plans")
}
// v1.15.20 — user-authored "about me" self-description the AI surfaces
// (Coach system prompt + daily-briefing user prompt) inject as a clearly
// delimited, user-provided context block. 1:1 with User and lazily
+10
View File
@@ -318,6 +318,16 @@ async function main() {
await rotateBytesColumn("CoachFact", "factEncrypted", prisma.coachFact),
);
// ───── CoachPlan (Bytes columns) ─────
// "ifCueEncrypted" "thenActionEncrypted" "targetEncrypted"
for (const field of [
"ifCueEncrypted",
"thenActionEncrypted",
"targetEncrypted",
]) {
results.push(await rotateBytesColumn("CoachPlan", field, prisma.coachPlan));
}
// ───── UserHealthProfile (Bytes columns) ─────
// "aboutMeEncrypted" "conditionsEncrypted" "allergiesEncrypted"
// "coachFocusEncrypted" "pendingQuestionsEncrypted"
+186
View File
@@ -0,0 +1,186 @@
/**
* PATCH /api/coach/plans/[id] — confirm / update a Coach plan's lifecycle.
* DELETE /api/coach/plans/[id] — soft-delete one Coach plan.
*
* v1.21.3 (B1) — the user-confirm + management surface for the durable Coach
* plans. Coach-proposes-then-user-confirms: the extractor writes a plan as
* `status: "proposed"`; this PATCH is the ONLY path that activates it
* (proposed → active), or marks it met / abandoned, or sets a review date.
* The body never carries the plan's metric or its encrypted free text, so a
* client can never inject or overwrite a plan's prose — only its lifecycle.
*
* 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 plan 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 on a 0-count match
* returns the idempotent `{ deleted: false }`.
*
* Coach-gated by the same `requireModuleEnabled(userId, "coach")` kill-switch
* as the list route (mirrors the about-me routes). Bodies are Zod-parsed; an
* invalid body returns the multi-issue 422 envelope.
*/
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 { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { coachPlanPatchSchema } from "@/lib/validations/coach-plan";
interface RouteCtx {
params: Promise<{ id: string }>;
}
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 { id } = await ctx.params;
const { data: body, error: jsonError } = await safeJson(req, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = coachPlanPatchSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
// Field-by-field data assembly (no mass assignment): only the lifecycle
// fields the body carried are written; the metric + encrypted text are never
// touched by this route.
const data: { status?: string; reviewDate?: Date | null } = {};
if (parsed.data.status !== undefined) data.status = parsed.data.status;
if (parsed.data.reviewDate !== undefined) {
data.reviewDate = parsed.data.reviewDate
? new Date(parsed.data.reviewDate)
: null;
}
// `updateMany` (not `update`) so an unknown / cross-user / already-deleted id
// is a 0-count no-op rather than a P2025 throw — the existence channel never
// leaks across accounts.
const { count } = await prisma.coachPlan.updateMany({
where: { id, userId: user.id, deletedAt: null },
data,
});
if (count === 0) {
// Indistinguishable from "owned by someone else" — never reveal which.
return apiError("Plan not found", 404);
}
const row = await prisma.coachPlan.findFirst({
where: { id, userId: user.id },
select: {
id: true,
metric: true,
ifCueEncrypted: true,
thenActionEncrypted: true,
targetEncrypted: true,
status: true,
reviewDate: true,
createdAt: true,
updatedAt: true,
},
});
if (!row) {
// Should not happen (we just updated it), but fail safe.
return apiError("Plan not found", 404);
}
let ifCue: string;
let thenAction: string;
try {
ifCue = decryptFromBytes(row.ifCueEncrypted);
thenAction = decryptFromBytes(row.thenActionEncrypted);
} catch {
// The row updated fine but its key id is no longer in the map — surface the
// lifecycle change without the (unreadable) prose rather than 500ing.
annotate({
action: { name: "coach.plans.updated" },
meta: { status: row.status },
});
return apiSuccess({
plan: {
id: row.id,
metric: row.metric,
ifCue: null,
thenAction: null,
target: null,
status: row.status,
reviewDate: row.reviewDate?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
},
});
}
let target: string | null = null;
if (row.targetEncrypted) {
try {
target = decryptFromBytes(row.targetEncrypted);
} catch {
target = null;
}
}
annotate({
action: { name: "coach.plans.updated" },
meta: { status: row.status },
});
return apiSuccess({
plan: {
id: row.id,
metric: row.metric,
ifCue,
thenAction,
target,
status: row.status,
reviewDate: row.reviewDate?.toISOString() ?? null,
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 { id } = await ctx.params;
// `updateMany` scoped by BOTH id and userId, active-only: an unknown /
// cross-user / already-deleted id is a 0-count no-op.
const { count } = await prisma.coachPlan.updateMany({
where: { id, userId: user.id, deletedAt: null },
data: { deletedAt: new Date() },
});
const deleted = count > 0;
annotate({
action: { name: "coach.plans.deleted" },
meta: { deleted },
});
return apiSuccess({ deleted });
},
);
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
@@ -0,0 +1,316 @@
/**
* v1.21.3 (B1) — `/api/coach/plans` (+ `/[id]`) routes.
*
* The management + confirm surface for the durable Coach goal / if-then plans.
* Covers:
* - GET lists the caller's plans decrypted, scoped to the user, newest first;
* the default (no `?status=`) returns the non-terminal set.
* - GET skips an undecryptable row rather than 500ing the whole list.
* - PATCH confirms proposed → active field-by-field; a 0-count match 404s;
* the body never carries the metric or encrypted text.
* - [id] DELETE soft-deletes the caller's plan; a cross-user / unknown id is
* a 0-count no-op returning `{ deleted: false }`.
* - the `requireModuleEnabled(userId, "coach")` gate runs on every verb.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
coachPlan: {
findMany: vi.fn(),
updateMany: vi.fn(),
findFirst: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({
getSession: vi.fn(),
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
}));
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
decryptFromBytes: vi.fn((buf: Uint8Array) => {
const tag = Buffer.from(buf).toString("utf8");
if (tag === "__undecryptable__") throw new Error("unknown key id");
return `decrypted:${tag}`;
}),
}));
vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: vi.fn(),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
return { ...actual, annotate: vi.fn() };
});
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { PATCH, DELETE as DELETE_ONE } from "../[id]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "tester",
role: "USER" as const,
displayName: null,
},
};
function bytes(tag: string): Uint8Array {
return new Uint8Array(Buffer.from(tag, "utf8"));
}
const callGet = (url = "http://localhost/api/coach/plans") =>
(GET as unknown as (req: Request) => Promise<Response>)(new Request(url));
function patchReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/coach/plans/x", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
const callPatch = (body: unknown, id = "p1") =>
(
PATCH as unknown as (
req: NextRequest,
ctx: { params: Promise<{ id: string }> },
) => Promise<Response>
)(patchReq(body), { params: Promise.resolve({ id }) });
const callDeleteOne = (id = "p1") =>
(
DELETE_ONE as unknown as (
req: NextRequest,
ctx: { params: Promise<{ id: string }> },
) => Promise<Response>
)(
new NextRequest("http://localhost/api/coach/plans/x", { method: "DELETE" }),
{ params: Promise.resolve({ id }) },
);
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
});
describe("GET /api/coach/plans", () => {
it("lists the caller's plans, decrypted, scoped to the user", async () => {
vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([
{
id: "p1",
metric: "WEIGHT",
ifCueEncrypted: bytes("every morning"),
thenActionEncrypted: bytes("weigh in"),
targetEncrypted: bytes("70 kg"),
status: "active",
reviewDate: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-02T00:00:00Z"),
},
] as never);
const res = await callGet();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { plans: Array<Record<string, unknown>> };
};
expect(body.data.plans).toHaveLength(1);
expect(body.data.plans[0]).toMatchObject({
id: "p1",
metric: "WEIGHT",
ifCue: "decrypted:every morning",
thenAction: "decrypted:weigh in",
target: "decrypted:70 kg",
status: "active",
});
const where = vi.mocked(prisma.coachPlan.findMany).mock.calls[0]?.[0]
?.where as { userId: string; deletedAt: null; status: unknown };
expect(where.userId).toBe("user-1");
expect(where.deletedAt).toBeNull();
// Default = the non-terminal set.
expect(where.status).toEqual({ in: ["proposed", "active"] });
});
it("filters by an explicit ?status=", async () => {
vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([] as never);
await callGet("http://localhost/api/coach/plans?status=proposed");
const where = vi.mocked(prisma.coachPlan.findMany).mock.calls[0]?.[0]
?.where as { status: unknown };
expect(where.status).toBe("proposed");
});
it("422s on an invalid ?status=", async () => {
const res = await callGet("http://localhost/api/coach/plans?status=bogus");
expect(res.status).toBe(422);
});
it("skips an undecryptable row rather than 500ing", async () => {
vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([
{
id: "p1",
metric: "SLEEP",
ifCueEncrypted: bytes("__undecryptable__"),
thenActionEncrypted: bytes("lights out"),
targetEncrypted: null,
status: "active",
reviewDate: null,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "p2",
metric: "STEPS",
ifCueEncrypted: bytes("after lunch"),
thenActionEncrypted: bytes("walk"),
targetEncrypted: null,
status: "active",
reviewDate: null,
createdAt: new Date(),
updatedAt: new Date(),
},
] as never);
const res = await callGet();
const body = (await res.json()) as {
data: { plans: Array<{ id: string }> };
};
expect(body.data.plans).toHaveLength(1);
expect(body.data.plans[0]?.id).toBe("p2");
});
it("invokes the coach module gate", async () => {
vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([] as never);
await callGet();
expect(requireModuleEnabled).toHaveBeenCalledWith("user-1", "coach");
});
});
describe("PATCH /api/coach/plans/[id]", () => {
it("confirms proposed → active, scoped to the caller, field-by-field", async () => {
vi.mocked(prisma.coachPlan.updateMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(prisma.coachPlan.findFirst).mockResolvedValue({
id: "p1",
metric: "WEIGHT",
ifCueEncrypted: bytes("every morning"),
thenActionEncrypted: bytes("weigh in"),
targetEncrypted: null,
status: "active",
reviewDate: null,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-03T00:00:00Z"),
} as never);
const res = await callPatch({ status: "active" });
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { plan: { status: string; ifCue: string } };
};
expect(body.data.plan.status).toBe("active");
expect(body.data.plan.ifCue).toBe("decrypted:every morning");
const arg = vi.mocked(prisma.coachPlan.updateMany).mock.calls[0]?.[0] as {
where: { id: string; userId: string; deletedAt: null };
data: Record<string, unknown>;
};
expect(arg.where.id).toBe("p1");
expect(arg.where.userId).toBe("user-1");
expect(arg.where.deletedAt).toBeNull();
// Only the lifecycle field is written — never metric / encrypted text.
expect(Object.keys(arg.data)).toEqual(["status"]);
});
it("404s on a 0-count (unknown / cross-user) match", async () => {
vi.mocked(prisma.coachPlan.updateMany).mockResolvedValue({
count: 0,
} as never);
const res = await callPatch({ status: "met" }, "someone-elses");
expect(res.status).toBe(404);
expect(prisma.coachPlan.findFirst).not.toHaveBeenCalled();
});
it("422s on an empty body (no mutable field)", async () => {
const res = await callPatch({});
expect(res.status).toBe(422);
});
it("422s on an unknown key (strict)", async () => {
const res = await callPatch({ status: "active", metric: "WEIGHT" });
expect(res.status).toBe(422);
});
it("invokes the coach module gate", async () => {
vi.mocked(prisma.coachPlan.updateMany).mockResolvedValue({
count: 0,
} as never);
await callPatch({ status: "met" });
expect(requireModuleEnabled).toHaveBeenCalledWith("user-1", "coach");
});
});
describe("DELETE /api/coach/plans/[id]", () => {
it("soft-deletes the caller's own plan and returns deleted:true", async () => {
vi.mocked(prisma.coachPlan.updateMany).mockResolvedValue({
count: 1,
} as never);
const res = await callDeleteOne();
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { deleted: boolean } };
expect(body.data.deleted).toBe(true);
const arg = vi.mocked(prisma.coachPlan.updateMany).mock.calls[0]?.[0] as {
where: { id: string; userId: string; deletedAt: null };
data: { deletedAt: Date };
};
expect(arg.where.id).toBe("p1");
expect(arg.where.userId).toBe("user-1");
expect(arg.where.deletedAt).toBeNull();
expect(arg.data.deletedAt).toBeInstanceOf(Date);
});
it("returns deleted:false for an unknown / cross-user id (0-count no-op)", async () => {
vi.mocked(prisma.coachPlan.updateMany).mockResolvedValue({
count: 0,
} as never);
const res = await callDeleteOne("someone-elses");
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { deleted: boolean } };
expect(body.data.deleted).toBe(false);
});
it("invokes the coach module gate", async () => {
vi.mocked(prisma.coachPlan.updateMany).mockResolvedValue({
count: 0,
} as never);
await callDeleteOne();
expect(requireModuleEnabled).toHaveBeenCalledWith("user-1", "coach");
});
});
+121
View File
@@ -0,0 +1,121 @@
/**
* GET /api/coach/plans — list the caller's Coach goal / if-then plans.
*
* v1.21.3 (B1) — the management + confirm surface for the durable plans the
* Coach proposes. A plan is an "if-then" implementation intention tied to one
* metric, with an optional target. The Coach extractor writes a plan as
* `status: "proposed"`; only the user-facing PATCH (`/api/coach/plans/[id]`)
* activates it. There is no silent self-edit of the user's plan set.
*
* Ownership: every query is scoped `where: { userId, ... }`, so a caller can
* only ever see their own plans. The free-text fields (if-cue, then-action,
* target) are decrypted on the fly; an undecryptable row (a key rotated out of
* the map) is skipped rather than 500ing the whole list.
*
* Coach-gated by the same `requireModuleEnabled(userId, "coach")` kill-switch
* the rest of the Coach management stack uses (mirrors the about-me routes).
* The owner is always narrowed from the session, never the body.
*
* `?status=proposed|active|met|abandoned` optionally filters the list (e.g.
* the composer fetches `proposed` to surface confirm cards). Omitted returns
* the non-terminal set (proposed + active), newest first.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { coachPlansListQuerySchema } from "@/lib/validations/coach-plan";
export const GET = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
// Coach module gate (operator availability + disableCoach), mirroring the
// about-me management routes.
const gate = await requireModuleEnabled(user.id, "coach");
if (!gate.enabled) return gate.response;
const url = new URL(req.url);
const parsed = coachPlansListQuerySchema.safeParse({
status: url.searchParams.get("status") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
// No status filter → the non-terminal set (the actionable plans). An
// explicit status filters to exactly that status.
const statusWhere = parsed.data.status
? { status: parsed.data.status }
: { status: { in: ["proposed", "active"] } };
const rows = await prisma.coachPlan.findMany({
where: { userId: user.id, deletedAt: null, ...statusWhere },
orderBy: [{ updatedAt: "desc" }],
select: {
id: true,
metric: true,
ifCueEncrypted: true,
thenActionEncrypted: true,
targetEncrypted: true,
status: true,
reviewDate: true,
createdAt: true,
updatedAt: true,
},
});
const plans: Array<{
id: string;
metric: string;
ifCue: string;
thenAction: string;
target: string | null;
status: string;
reviewDate: string | null;
createdAt: string;
updatedAt: string;
}> = [];
for (const row of rows) {
let ifCue: string;
let thenAction: string;
try {
ifCue = decryptFromBytes(row.ifCueEncrypted);
thenAction = decryptFromBytes(row.thenActionEncrypted);
} catch {
// Fail closed per row — never surface ciphertext, never 500 the whole
// list because one row's key id is no longer in the map.
continue;
}
let target: string | null = null;
if (row.targetEncrypted) {
try {
target = decryptFromBytes(row.targetEncrypted);
} catch {
target = null;
}
}
plans.push({
id: row.id,
metric: row.metric,
ifCue,
thenAction,
target,
status: row.status,
reviewDate: row.reviewDate?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
});
}
annotate({
action: { name: "coach.plans.listed" },
meta: { count: plans.length },
});
return apiSuccess({ plans });
});
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
+7 -2
View File
@@ -68,8 +68,13 @@ export function OfflineBanner() {
// v1.18.6 — when an offline install is showing last-synced data (the SW's
// allowlisted read cache + the IndexedDB query persister hydrated the
// dashboard snapshot), say so honestly rather than implying a blank screen.
const hasCachedData =
queryClient.getQueryData(queryKeys.dashboardSnapshot()) != null;
// v1.21.3 (b) — the snapshot cell is now locale-keyed, so match on the
// `["dashboard","snapshot"]` PREFIX (`getQueriesData`) rather than the
// exact zero-arg slot — any hydrated locale's snapshot proves cached data
// is present.
const hasCachedData = queryClient
.getQueriesData({ queryKey: queryKeys.dashboardSnapshot() })
.some(([, data]) => data != null);
return (
<div
+33
View File
@@ -32,6 +32,39 @@ describe("queryKeys factory", () => {
expect(queryKeys.dashboardSnapshot()).toEqual(["dashboard", "snapshot"]);
});
// v1.21.3 (b) — the snapshot key takes an optional `locale` segment so a
// locale switch reads freshly-localised prose on its own cell. The zero-arg
// call MUST stay byte-identical to the legacy literal so every prefix-based
// invalidation bundle + zero-arg reader keeps prefix-matching the new cells.
it("threads an optional locale segment through dashboardSnapshot", () => {
expect(queryKeys.dashboardSnapshot("en")).toEqual([
"dashboard",
"snapshot",
"en",
]);
expect(queryKeys.dashboardSnapshot("de")).toEqual([
"dashboard",
"snapshot",
"de",
]);
});
it("keeps the zero-arg dashboardSnapshot call byte-identical to the legacy literal", () => {
expect(queryKeys.dashboardSnapshot()).toEqual(["dashboard", "snapshot"]);
expect(queryKeys.dashboardSnapshot(undefined)).toEqual([
"dashboard",
"snapshot",
]);
});
it("makes a zero-arg dashboardSnapshot key a prefix of a locale-keyed cell", () => {
// TanStack `invalidateQueries({ queryKey })` matches by prefix, so a
// zero-arg invalidate must reach every locale-keyed snapshot cell.
const zeroArg = queryKeys.dashboardSnapshot();
const localeKeyed = queryKeys.dashboardSnapshot("en");
expect(localeKeyed.slice(0, zeroArg.length)).toEqual(zeroArg);
});
it("threads the slim slice through queryKeys.analytics", () => {
expect(queryKeys.analytics("summaries")).toEqual([
"analytics",
+330
View File
@@ -0,0 +1,330 @@
/**
* v1.21.3 (B1) — extractAndStorePlanProposals + buildCoachPlansBlock tests.
*
* Covers:
* - extraction parses the model JSON, drops malformed items via the Zod gate,
* de-dups against the existing set, enforces the per-user cap, and persists
* survivors field-by-field as `status: "proposed"` (never active).
* - a no-provider / timeout result skips; an unparseable JSON annotates
* parse_failed and returns none; an empty array returns none.
* - the injection block returns ONLY active plans, newest first, capped, and
* skips an undecryptable row rather than throwing.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
// Codec mock: a tagged Uint8Array round-trips through the string; a sentinel
// triggers a throw to exercise the fail-closed skip.
vi.mock("../bytes-codec", () => ({
encryptToBytes: vi.fn((s: string) => new Uint8Array(Buffer.from(s, "utf8"))),
decryptFromBytes: vi.fn((buf: Uint8Array) => {
const tag = Buffer.from(buf).toString("utf8");
if (tag === "__undecryptable__") throw new Error("unknown key id");
return tag;
}),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
import {
extractAndStorePlanProposals,
buildCoachPlansBlock,
MAX_PLANS_PER_USER,
} from "../plans";
import { annotate } from "@/lib/logging/context";
function bytes(tag: string): Uint8Array {
return new Uint8Array(Buffer.from(tag, "utf8"));
}
function makePrisma(overrides?: {
existing?: Array<Record<string, unknown>>;
turns?: Array<{ role: string; encryptedContent: Uint8Array }>;
active?: Array<Record<string, unknown>>;
}) {
const create = vi.fn().mockResolvedValue({});
const findMany = vi.fn(async (args: { where: { status?: unknown } }) => {
// The injection block filters status: "active"; the extractor filters
// status: { in: [...] }. Route the right fixture to each.
const status = args.where.status;
if (status === "active") return overrides?.active ?? [];
return overrides?.existing ?? [];
});
const findFirst = vi.fn(async () => ({
messages: overrides?.turns ?? [],
}));
return {
coachPlan: { findMany, create },
coachConversation: { findFirst },
_create: create,
_findMany: findMany,
} as never;
}
const okResult = (content: string) => ({ kind: "ok", content }) as never;
beforeEach(() => {
vi.clearAllMocks();
});
describe("extractAndStorePlanProposals", () => {
it("persists a clean plan field-by-field as proposed", async () => {
const db = makePrisma({
turns: [{ role: "user", encryptedContent: bytes("I'll weigh daily") }],
});
const runCompletion = vi.fn().mockResolvedValue(
okResult(
JSON.stringify([
{
metric: "WEIGHT",
ifCue: "every morning",
thenAction: "step on the scale",
target: "70 kg by August",
},
]),
),
);
const out = await extractAndStorePlanProposals("conv-1", "user-1", {
prisma: db,
runCompletion,
});
expect(out).toEqual({ status: "stored", count: 1 });
const createArg = (db as never as { _create: ReturnType<typeof vi.fn> })
._create.mock.calls[0][0] as { data: Record<string, unknown> };
expect(createArg.data.userId).toBe("user-1");
expect(createArg.data.metric).toBe("WEIGHT");
expect(createArg.data.status).toBe("proposed");
expect(createArg.data.sourceConversationId).toBe("conv-1");
// Field-by-field — no stray keys spread in.
expect(Object.keys(createArg.data).sort()).toEqual(
[
"ifCueEncrypted",
"metric",
"sourceConversationId",
"status",
"targetEncrypted",
"thenActionEncrypted",
"userId",
].sort(),
);
});
it("never writes status active (only the PATCH activates)", async () => {
const db = makePrisma({
turns: [{ role: "user", encryptedContent: bytes("plan") }],
});
const runCompletion = vi
.fn()
.mockResolvedValue(
okResult(
JSON.stringify([
{ metric: "SLEEP", ifCue: "after 22:30", thenAction: "lights out" },
]),
),
);
await extractAndStorePlanProposals("c", "u", { prisma: db, runCompletion });
const createArg = (db as never as { _create: ReturnType<typeof vi.fn> })
._create.mock.calls[0][0] as { data: { status: string } };
expect(createArg.data.status).toBe("proposed");
});
it("drops a malformed item but keeps the valid one", async () => {
const db = makePrisma({
turns: [{ role: "user", encryptedContent: bytes("plan") }],
});
const runCompletion = vi.fn().mockResolvedValue(
okResult(
JSON.stringify([
{ metric: "WEIGHT" }, // missing ifCue/thenAction → dropped
{ metric: "STEPS", ifCue: "after lunch", thenAction: "walk 10 min" },
]),
),
);
const out = await extractAndStorePlanProposals("c", "u", {
prisma: db,
runCompletion,
});
expect(out).toEqual({ status: "stored", count: 1 });
});
it("de-dups against an existing active/proposed plan", async () => {
const db = makePrisma({
turns: [{ role: "user", encryptedContent: bytes("plan") }],
existing: [
{
id: "p1",
ifCueEncrypted: bytes("every morning"),
thenActionEncrypted: bytes("step on the scale"),
status: "active",
},
],
});
const runCompletion = vi.fn().mockResolvedValue(
okResult(
JSON.stringify([
{
metric: "WEIGHT",
ifCue: "every morning",
thenAction: "step on the scale",
},
]),
),
);
const out = await extractAndStorePlanProposals("c", "u", {
prisma: db,
runCompletion,
});
expect(out).toEqual({ status: "skipped", count: 0 });
expect(
(db as never as { _create: ReturnType<typeof vi.fn> })._create,
).not.toHaveBeenCalled();
});
it("skips when the user is already at the plan cap", async () => {
const existing = Array.from({ length: MAX_PLANS_PER_USER }, (_, i) => ({
id: `p${i}`,
ifCueEncrypted: bytes(`cue ${i}`),
thenActionEncrypted: bytes(`act ${i}`),
status: "active",
}));
const db = makePrisma({
turns: [{ role: "user", encryptedContent: bytes("plan") }],
existing,
});
const runCompletion = vi
.fn()
.mockResolvedValue(
okResult(
JSON.stringify([
{ metric: "MOOD", ifCue: "fresh cue", thenAction: "fresh act" },
]),
),
);
const out = await extractAndStorePlanProposals("c", "u", {
prisma: db,
runCompletion,
});
expect(out).toEqual({ status: "skipped", count: 0 });
});
it("skips when no turns exist", async () => {
const db = makePrisma({ turns: [] });
const runCompletion = vi.fn();
const out = await extractAndStorePlanProposals("c", "u", {
prisma: db,
runCompletion,
});
expect(out).toEqual({ status: "skipped", count: 0 });
expect(runCompletion).not.toHaveBeenCalled();
});
it("skips on a no-provider / timeout completion", async () => {
const db = makePrisma({
turns: [{ role: "user", encryptedContent: bytes("plan") }],
});
const runCompletion = vi.fn().mockResolvedValue({ kind: "none" } as never);
const out = await extractAndStorePlanProposals("c", "u", {
prisma: db,
runCompletion,
});
expect(out).toEqual({ status: "skipped", count: 0 });
});
it("annotates parse_failed and returns none on unparseable JSON", async () => {
const db = makePrisma({
turns: [{ role: "user", encryptedContent: bytes("plan") }],
});
const runCompletion = vi.fn().mockResolvedValue(okResult("not json"));
const out = await extractAndStorePlanProposals("c", "u", {
prisma: db,
runCompletion,
});
expect(out).toEqual({ status: "none", count: 0 });
const call = vi
.mocked(annotate)
.mock.calls.find(
(c) =>
(c[0] as { action?: { name?: string } })?.action?.name ===
"coach.plans.parse_failed",
);
expect(call).toBeTruthy();
});
it("returns none on an empty array", async () => {
const db = makePrisma({
turns: [{ role: "user", encryptedContent: bytes("plan") }],
});
const runCompletion = vi.fn().mockResolvedValue(okResult("[]"));
const out = await extractAndStorePlanProposals("c", "u", {
prisma: db,
runCompletion,
});
expect(out).toEqual({ status: "none", count: 0 });
});
});
describe("buildCoachPlansBlock", () => {
it("returns only active plans, newest first, decrypted", async () => {
const db = makePrisma({
active: [
{
metric: "WEIGHT",
ifCueEncrypted: bytes("every morning"),
thenActionEncrypted: bytes("weigh in"),
targetEncrypted: bytes("70 kg"),
status: "active",
updatedAt: new Date("2026-06-02T00:00:00Z"),
},
],
});
const block = await buildCoachPlansBlock("u", { prisma: db });
expect(block).not.toBeNull();
expect(block?.plans).toEqual([
{
metric: "WEIGHT",
ifCue: "every morning",
thenAction: "weigh in",
target: "70 kg",
},
]);
// Only the active-status query is used.
const where = (db as never as { _findMany: ReturnType<typeof vi.fn> })
._findMany.mock.calls[0][0].where as { status: string };
expect(where.status).toBe("active");
});
it("skips an undecryptable row rather than throwing", async () => {
const db = makePrisma({
active: [
{
metric: "SLEEP",
ifCueEncrypted: bytes("__undecryptable__"),
thenActionEncrypted: bytes("lights out"),
targetEncrypted: null,
status: "active",
updatedAt: new Date(),
},
{
metric: "STEPS",
ifCueEncrypted: bytes("after lunch"),
thenActionEncrypted: bytes("walk"),
targetEncrypted: null,
status: "active",
updatedAt: new Date(),
},
],
});
const block = await buildCoachPlansBlock("u", { prisma: db });
expect(block?.plans).toHaveLength(1);
expect(block?.plans[0]?.metric).toBe("STEPS");
});
it("returns null when the user has no active plans", async () => {
const db = makePrisma({ active: [] });
const block = await buildCoachPlansBlock("u", { prisma: db });
expect(block).toBeNull();
});
});
@@ -12,6 +12,7 @@ import { annotate } from "@/lib/logging/context";
import type { CoachMemoryRefreshPayload } from "./coach-memory-shared";
import { extractAndStoreFacts } from "./facts";
import { extractAndStorePlanProposals } from "./plans";
import { refreshConversationSummary } from "./conversation-summary";
export async function runCoachMemoryRefresh(
@@ -48,8 +49,27 @@ export async function runCoachMemoryRefresh(
});
}
// v1.21.3 (B1) — durable goal / if-then plan proposals. Same worker pass
// (no separate pg-boss queue), fault-isolated like the facts step: a failure
// or no-provider here never sinks the summary / facts work or the job. Plans
// are written as `proposed`; the user confirms them through the PATCH route.
let plansStatus = "error";
let plansCount = 0;
try {
const result = await extractAndStorePlanProposals(conversationId, userId, {
locale,
});
plansStatus = result.status;
plansCount = result.count;
} catch (err) {
annotate({
action: { name: "coach.memory.refresh.plans_failed" },
meta: { error: err instanceof Error ? err.message : String(err) },
});
}
annotate({
action: { name: "coach.memory.refresh.done" },
meta: { summaryStatus, factsStatus, factsCount },
meta: { summaryStatus, factsStatus, factsCount, plansStatus, plansCount },
});
}
+26 -1
View File
@@ -39,6 +39,7 @@ import {
} from "@/lib/insights/narrative/period-narrative";
import type { BaselineProfile } from "@/lib/insights/derived";
import { buildCoachFactsBlock } from "./facts";
import { buildCoachPlansBlock, type CoachPlanInjectEntry } from "./plans";
/** The period the rolling profile recalls — month is the high-signal beat. */
const MEMORY_PERIOD: NarrativePeriod = "month";
@@ -78,6 +79,12 @@ export interface CoachMemoryBlock {
* confidence then recency). Descriptive, never diagnostic. Absent when none.
*/
facts?: Array<{ category: string; text: string }>;
/**
* v1.21.3 (B1) — the user's ACTIVE goal / if-then plans (top-N, newest
* first). Only confirmed (`active`) plans appear here — a `proposed` plan is
* still awaiting the user's confirmation. Absent when none.
*/
plans?: CoachPlanInjectEntry[];
}
/** Pull the headline + driver recall off the latest period narrative. */
@@ -166,12 +173,30 @@ export async function buildCoachMemoryBlock(
facts = undefined;
}
if (!priorNarrative && Object.keys(trendMemory).length === 0 && !facts) {
// Sub-source 4 (v1.21.3 B1): the user's confirmed goal / if-then plans.
// Fault-isolated like the facts block; only `active` plans are injected.
let plans: CoachPlanInjectEntry[] | undefined;
try {
const plansBlock = await buildCoachPlansBlock(userId);
if (plansBlock && plansBlock.plans.length > 0) {
plans = plansBlock.plans;
}
} catch {
plans = undefined;
}
if (
!priorNarrative &&
Object.keys(trendMemory).length === 0 &&
!facts &&
!plans
) {
return null;
}
const block: CoachMemoryBlock = { trendMemory };
if (priorNarrative) block.priorNarrative = priorNarrative;
if (facts) block.facts = facts;
if (plans) block.plans = plans;
return block;
}
+466
View File
@@ -0,0 +1,466 @@
/**
* v1.21.3 (B1) — durable goal / if-then implementation-plan memory.
*
* The Coach learns the concrete PLANS a user agrees to in a conversation —
* an implementation intention of the form "IF <cue> THEN <action>" attached
* to a metric, with an optional target — and persists them encrypted at rest
* so future turns can recall and review them instead of re-negotiating the
* same plan cold. Plans are the user's OWN committed intentions, never a
* prescription the Coach invents.
*
* Coach-proposes-then-user-confirms is the spine of the feature: the
* extractor here only ever writes a plan as `status: "proposed"`. ONLY the
* user-facing PATCH (`/api/coach/plans/[id]`) flips a plan to `active`. There
* is no silent self-edit of the user's plan set — the extractor surfaces a
* candidate; the user confirms it.
*
* Two halves, mirroring `facts.ts`:
* - `extractAndStorePlanProposals` — the background compute: load active +
* proposed plans and the recent turns, run one bounded
* `runStatusCompletion`, defensively parse the JSON array, drop anything
* the Zod gate rejects, de-dup against the existing set, enforce a per-user
* cap, and persist survivors field-by-field (no mass-assignment spread) as
* `proposed`.
* - `buildCoachPlansBlock` — the injection read: the active plans (newest
* first), decrypted fault-isolated (an undecryptable row is skipped, never
* thrown), for the snapshot memory block.
*
* The cue / action / target TEXT is encrypted via `bytes-codec.ts` (the same
* AES-256-GCM codec as `CoachFact`). `metric`, `status` and the dates stay
* plain so the picker can rank / filter without paying a per-row decrypt.
* Annotations carry counts + ids only — plan text is NEVER logged.
*
* Server-only — reads `@/lib/db`.
*/
import { z } from "zod";
import { prisma } from "@/lib/db";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { annotate } from "@/lib/logging/context";
import { decryptFromBytes, encryptToBytes } from "./bytes-codec";
/** App-side closed status enum (NOT a DB enum — matches the schema column). */
export const COACH_PLAN_STATUSES = [
"proposed",
"active",
"met",
"abandoned",
] as const;
export type CoachPlanStatus = (typeof COACH_PLAN_STATUSES)[number];
/** Hard cap on non-terminal (proposed + active) plans per user. */
export const MAX_PLANS_PER_USER = 25;
/** Per-field text length cap (mirrors the Zod gate + the prompt instruction). */
export const PLAN_FIELD_MAX_CHARS = 160;
/** How many active plans the injection block carries into the snapshot. */
export const PLANS_INJECT_TOP_N = 6;
/** Cap on recent turns fed into the extraction prompt (bounds prompt size). */
const RECENT_TURNS_CAP = 10;
type RunCompletionFn = typeof runStatusCompletion;
type PrismaLike = Pick<typeof prisma, "coachPlan" | "coachConversation">;
interface ExtractOpts {
runCompletion?: RunCompletionFn;
prisma?: PrismaLike;
locale?: string;
}
interface BuildBlockOpts {
prisma?: PrismaLike;
}
// ---------------------------------------------------------------------------
// Extraction prompt (EN + DE mirror)
// ---------------------------------------------------------------------------
const EXTRACTION_PROMPT_EN = `You extract concrete implementation PLANS the user has AGREED to in a coaching conversation, for the assistant's long-term memory. A plan is an "if-then" intention tied to ONE metric. Return a JSON array (no prose, no fences). Each item: { "metric": "<short metric key, e.g. WEIGHT, SLEEP, BLOOD_PRESSURE, STEPS>", "ifCue": "<the trigger, one short clause>", "thenAction": "<the action, one short clause>", "target": "<optional plain target, or omit>" }.
ONLY extract a plan the user has clearly COMMITTED to in their own words ("I'll weigh myself every morning", "if I skip the gym I'll walk after dinner"). Record it descriptively in the user's framing — NEVER prescribe a plan the user did not agree to, never invent a clinical target, never record a raw measurement as a plan. EXCLUDE: vague wishes ("I should sleep more"), one-off intentions, and anything the user asked you to drop. If no concrete plan was agreed, return []. Prefer FEW clear plans over many speculative ones.`;
const EXTRACTION_PROMPT_DE = `Du extrahierst konkrete UMSETZUNGS-PLÄNE, denen die Nutzerin oder der Nutzer in einem Coaching-Gespräch ZUGESTIMMT hat, für das Langzeitgedächtnis des Assistenten. Ein Plan ist eine "Wenn-dann"-Absicht, die an EINE Metrik gebunden ist. Gib ein JSON-Array zurück (kein Fließtext, keine Code-Zäune). Jedes Element: { "metric": "<kurzer Metrik-Schlüssel, z. B. WEIGHT, SLEEP, BLOOD_PRESSURE, STEPS>", "ifCue": "<der Auslöser, ein kurzer Satzteil>", "thenAction": "<die Handlung, ein kurzer Satzteil>", "target": "<optionales einfaches Ziel, oder weglassen>" }.
Extrahiere NUR einen Plan, zu dem sich die Person klar in eigenen Worten VERPFLICHTET hat ("ich wiege mich jeden Morgen", "wenn ich das Training auslasse, gehe ich nach dem Essen spazieren"). Halte ihn beschreibend in der Formulierung der Person fest — VERORDNE NIEMALS einen Plan, dem die Person nicht zugestimmt hat, erfinde kein klinisches Ziel und erfasse keine reine Messung als Plan. SCHLIESSE AUS: vage Wünsche ("ich sollte mehr schlafen"), einmalige Absichten und alles, worum die Person gebeten hat, es fallen zu lassen. Wenn kein konkreter Plan vereinbart wurde, gib [] zurück. Bevorzuge WENIGE klare Pläne gegenüber vielen spekulativen.`;
function extractionSystemPrompt(locale: string | undefined): string {
return locale?.toLowerCase().startsWith("de")
? EXTRACTION_PROMPT_DE
: EXTRACTION_PROMPT_EN;
}
// ---------------------------------------------------------------------------
// Zod gate for the model's JSON array
// ---------------------------------------------------------------------------
const rawPlanSchema = z.object({
metric: z.string().trim().min(1).max(60),
ifCue: z.string().trim().min(1).max(PLAN_FIELD_MAX_CHARS),
thenAction: z.string().trim().min(1).max(PLAN_FIELD_MAX_CHARS),
target: z.string().trim().min(1).max(PLAN_FIELD_MAX_CHARS).optional(),
});
const rawPlanArraySchema = z.array(z.unknown());
interface ParsedPlan {
metric: string;
ifCue: string;
thenAction: string;
target?: string;
}
/**
* Parse the model output into validated plans. Returns `null` only when the
* top-level JSON is unparseable (the caller then annotates `parse_failed`);
* individual malformed items are dropped silently, not fatal.
*/
function parsePlans(
content: string,
): { plans: ParsedPlan[]; dropped: number } | null {
let json: unknown;
try {
json = JSON.parse(content.trim());
} catch {
return null;
}
const arr = rawPlanArraySchema.safeParse(json);
if (!arr.success) return null;
const plans: ParsedPlan[] = [];
let dropped = 0;
for (const item of arr.data) {
const parsed = rawPlanSchema.safeParse(item);
if (parsed.success) {
const plan: ParsedPlan = {
metric: parsed.data.metric,
ifCue: parsed.data.ifCue,
thenAction: parsed.data.thenAction,
};
if (parsed.data.target) plan.target = parsed.data.target;
plans.push(plan);
} else {
dropped += 1;
}
}
return { plans, dropped };
}
// ---------------------------------------------------------------------------
// De-dup — lowercase-normalise + token-overlap against the existing set
// ---------------------------------------------------------------------------
const TOKEN_SPLIT = /[^a-z0-9äöüß]+/i;
function tokenSet(text: string): Set<string> {
return new Set(
text
.toLowerCase()
.split(TOKEN_SPLIT)
.filter((t) => t.length > 1),
);
}
/** Jaccard-style overlap of the two token sets, 0..1. */
function tokenOverlap(a: Set<string>, b: Set<string>): number {
if (a.size === 0 || b.size === 0) return 0;
let inter = 0;
for (const t of a) if (b.has(t)) inter += 1;
const union = a.size + b.size - inter;
return union === 0 ? 0 : inter / union;
}
const DEDUP_OVERLAP_THRESHOLD = 0.6;
/** A plan's de-dup signature is its if-then prose (metric-agnostic). */
function planSignature(plan: ParsedPlan): string {
return `${plan.ifCue} ${plan.thenAction}`;
}
function isNearDuplicate(candidate: string, existing: string[]): boolean {
const cTokens = tokenSet(candidate);
const cNorm = candidate.trim().toLowerCase();
for (const ex of existing) {
if (ex.trim().toLowerCase() === cNorm) return true;
if (tokenOverlap(cTokens, tokenSet(ex)) >= DEDUP_OVERLAP_THRESHOLD)
return true;
}
return false;
}
// ---------------------------------------------------------------------------
// Loading
// ---------------------------------------------------------------------------
function decryptOrNull(buf: Uint8Array | null): string | null {
if (!buf) return null;
try {
return decryptFromBytes(buf);
} catch {
return null;
}
}
interface ExistingPlanRow {
id: string;
ifCueEncrypted: Uint8Array;
thenActionEncrypted: Uint8Array;
status: string;
}
/**
* Load the non-terminal (proposed + active) plans and decrypt their if-then
* signature fault-isolated (undecryptable rows skipped). Used both for the
* cap count and for de-dup against the existing set.
*/
async function loadExistingPlans(
db: PrismaLike,
userId: string,
): Promise<Array<{ id: string; signature: string; status: string }>> {
const rows = (await db.coachPlan.findMany({
where: { userId, deletedAt: null, status: { in: ["proposed", "active"] } },
select: {
id: true,
ifCueEncrypted: true,
thenActionEncrypted: true,
status: true,
},
})) as ExistingPlanRow[];
const out: Array<{ id: string; signature: string; status: string }> = [];
for (const r of rows) {
const ifCue = decryptOrNull(r.ifCueEncrypted);
const thenAction = decryptOrNull(r.thenActionEncrypted);
if (ifCue === null || thenAction === null) continue;
out.push({
id: r.id,
signature: `${ifCue} ${thenAction}`,
status: r.status,
});
}
return out;
}
interface ConversationTurnRow {
role: string;
encryptedContent: Uint8Array;
}
/** Load the last `RECENT_TURNS_CAP` turns of a conversation, decrypted. */
async function loadRecentTurns(
db: PrismaLike,
conversationId: string,
userId: string,
): Promise<Array<{ role: string; content: string }>> {
const row = (await db.coachConversation.findFirst({
where: { id: conversationId, userId },
select: {
messages: {
orderBy: { createdAt: "desc" },
take: RECENT_TURNS_CAP,
select: { role: true, encryptedContent: true },
},
},
})) as { messages: ConversationTurnRow[] } | null;
if (!row) return [];
// Loaded newest-first; reverse to chronological for the prompt.
const turns: Array<{ role: string; content: string }> = [];
for (const m of [...row.messages].reverse()) {
const content = decryptOrNull(m.encryptedContent);
if (content === null) continue;
turns.push({ role: m.role, content });
}
return turns;
}
function buildUserPrompt(
turns: Array<{ role: string; content: string }>,
existingSignatures: string[],
): string {
const transcript = turns.map((t) => `${t.role}: ${t.content}`).join("\n");
const existing =
existingSignatures.length > 0
? `\n\nEXISTING PLANS (do not re-emit these):\n${existingSignatures
.map((p) => `- ${p}`)
.join("\n")}`
: "\n\nEXISTING PLANS: none";
return `${transcript}${existing}`;
}
// ---------------------------------------------------------------------------
// Extraction entry point
// ---------------------------------------------------------------------------
/**
* Extract concrete plans the user agreed to and persist the survivors as
* `status: "proposed"` — the user confirms them through the PATCH route.
*
* Returns:
* - `{status:"stored", count}` — at least one new proposal persisted.
* - `{status:"none", count:0}` — the model produced nothing concrete (or the
* JSON was unparseable, in which case `coach.plans.parse_failed` is
* annotated).
* - `{status:"skipped", count:0}` — no usable provider / timeout / error, or
* every candidate was a dup / dropped / displaced by the cap.
*/
export async function extractAndStorePlanProposals(
conversationId: string,
userId: string,
opts?: ExtractOpts,
): Promise<{ status: "stored" | "none" | "skipped"; count: number }> {
const db = opts?.prisma ?? prisma;
const runCompletion = opts?.runCompletion ?? runStatusCompletion;
const [existing, turns] = await Promise.all([
loadExistingPlans(db, userId),
loadRecentTurns(db, conversationId, userId),
]);
if (turns.length === 0) {
return { status: "skipped", count: 0 };
}
const existingSignatures = existing.map((p) => p.signature);
const result = await runCompletion({
userId,
cacheAction: "coach.plans",
consentSurface: "coach",
systemPrompt: extractionSystemPrompt(opts?.locale),
userPrompt: buildUserPrompt(turns, existingSignatures),
temperature: 0.2,
maxTokens: 400,
});
if (result.kind !== "ok") {
return { status: "skipped", count: 0 };
}
const parsed = parsePlans(result.content);
if (parsed === null) {
annotate({
action: { name: "coach.plans.parse_failed" },
meta: { conversationId },
});
return { status: "none", count: 0 };
}
if (parsed.plans.length === 0) {
return { status: "none", count: 0 };
}
// De-dup against the existing set; grow the seen-set as we accept candidates
// so two near-identical candidates in one batch can't both land.
const seen = [...existingSignatures];
const accepted: ParsedPlan[] = [];
for (const cand of parsed.plans) {
const sig = planSignature(cand);
if (isNearDuplicate(sig, seen)) continue;
accepted.push(cand);
seen.push(sig);
}
if (accepted.length === 0) {
return { status: "skipped", count: 0 };
}
// Cap enforcement: never let proposals push the non-terminal set past the
// per-user cap. A user with a full plan set gets no new proposals until they
// act on (confirm / abandon) the ones they have.
const remainingCapacity = MAX_PLANS_PER_USER - existing.length;
if (remainingCapacity <= 0) {
return { status: "skipped", count: 0 };
}
const toStore = accepted.slice(0, remainingCapacity);
for (const p of toStore) {
// Field-by-field, no spread (mass-assignment rule, CLAUDE.md). Always
// written as "proposed" — only the user-facing PATCH activates a plan.
await db.coachPlan.create({
data: {
userId,
metric: p.metric,
ifCueEncrypted: encryptToBytes(p.ifCue),
thenActionEncrypted: encryptToBytes(p.thenAction),
targetEncrypted: p.target ? encryptToBytes(p.target) : null,
status: "proposed",
sourceConversationId: conversationId,
},
});
}
annotate({
action: { name: "coach.plans.extracted" },
meta: { count: toStore.length, conversationId },
});
return { status: "stored", count: toStore.length };
}
// ---------------------------------------------------------------------------
// Injection block
// ---------------------------------------------------------------------------
interface InjectPlanRow {
metric: string;
ifCueEncrypted: Uint8Array;
thenActionEncrypted: Uint8Array;
targetEncrypted: Uint8Array | null;
status: string;
updatedAt: Date;
}
/** One active plan as the snapshot memory block carries it. */
export interface CoachPlanInjectEntry {
metric: string;
ifCue: string;
thenAction: string;
target?: string;
}
/**
* Build the top-N ACTIVE plans for the snapshot memory block, newest first.
* Only `active` plans are injected — a `proposed` plan is still awaiting the
* user's confirmation and must not be recalled as a committed plan. Decrypt is
* fault-isolated — an undecryptable row is skipped, never thrown into the
* caller. Returns `null` when the user has no usable active plans.
*/
export async function buildCoachPlansBlock(
userId: string,
opts?: BuildBlockOpts,
): Promise<{ plans: CoachPlanInjectEntry[] } | null> {
const db = opts?.prisma ?? prisma;
const rows = (await db.coachPlan.findMany({
where: { userId, deletedAt: null, status: "active" },
orderBy: [{ updatedAt: "desc" }],
select: {
metric: true,
ifCueEncrypted: true,
thenActionEncrypted: true,
targetEncrypted: true,
status: true,
updatedAt: true,
},
})) as InjectPlanRow[];
const plans: CoachPlanInjectEntry[] = [];
for (const r of rows) {
if (plans.length >= PLANS_INJECT_TOP_N) break;
const ifCue = decryptOrNull(r.ifCueEncrypted);
const thenAction = decryptOrNull(r.thenActionEncrypted);
if (ifCue === null || thenAction === null) continue;
const entry: CoachPlanInjectEntry = {
metric: r.metric,
ifCue,
thenAction,
};
const target = decryptOrNull(r.targetEncrypted);
if (target !== null) entry.target = target;
plans.push(entry);
}
if (plans.length === 0) return null;
return { plans };
}
+14 -3
View File
@@ -2550,9 +2550,20 @@ function degradeToBudget(
block && Array.isArray(block.facts) && block.facts.length > 0
? block.facts
: null;
snapshot[key] = facts
? { facts, omitted: "trimmed for prompt budget" }
: { omitted: "trimmed for prompt budget" };
// v1.21.3 (B1) — the durable plans survive the drop alongside facts: they
// are the user's confirmed if-then commitments, tiny by construction
// (top-6, ≤160 chars each), and shedding them would make the Coach forget
// a plan exactly on the data-heavy accounts that hit the char cap.
const plans =
block && Array.isArray(block.plans) && block.plans.length > 0
? block.plans
: null;
const survivors: Record<string, unknown> = {
omitted: "trimmed for prompt budget",
};
if (facts) survivors.facts = facts;
if (plans) survivors.plans = plans;
snapshot[key] = survivors;
return true;
};
+12
View File
@@ -381,6 +381,11 @@ ISO-week means.
context, DESCRIPTIVE not diagnostic — never restate a "condition" fact
as a medical finding, and never invent a fact the block does not
carry. If a fact seems outdated, gently check it rather than assume.
- The "memory" block MAY also carry a "plans" list — the user's CONFIRMED
goal / if-then plans ({ metric, ifCue, thenAction, target? }). These are
commitments the user agreed to, not prescriptions you invented. Recall
them to check in on progress and reinforce the plan; never restate one as
a medical instruction, and never invent a plan the block does not carry.
- The SNAPSHOT MAY carry an "illness" block: { restMode, active[],
recentResolved[] }. When "restMode" is true the user has one or more
ACTIVE conditions right now (each with a label, type, lifecycle, and
@@ -883,6 +888,13 @@ ISO-Wochenmittel zusammen.
nie als medizinischen Befund um und erfinde nie einen Fakt, den der
Block nicht enthält. Wirkt ein Fakt veraltet, frage behutsam nach,
statt es anzunehmen.
- Der "memory"-Block KANN außerdem eine "plans"-Liste tragen — die vom
Nutzer BESTÄTIGTEN Ziel- / Wenn-dann-Pläne ({ metric, ifCue, thenAction,
target? }). Das sind Selbstverpflichtungen, denen der Nutzer zugestimmt
hat, keine von dir erdachten Verordnungen. Greife sie auf, um den
Fortschritt zu prüfen und den Plan zu bestärken; formuliere keinen davon
als medizinische Anweisung um und erfinde keinen Plan, den der Block
nicht enthält.
- Der SNAPSHOT KANN einen "illness"-Block tragen: { restMode, active[],
recentResolved[] }. Ist "restMode" true, hat der Nutzer gerade eine oder
mehrere AKTIVE Erkrankungen (je mit Label, Typ, Lebenszyklus, Beginn) —
+4
View File
@@ -108,6 +108,10 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
{ model: "CoachMessage", field: "encryptedContent", kind: "bytes" },
{ model: "CoachConversation", field: "summaryEncrypted", kind: "bytes" },
{ model: "CoachFact", field: "factEncrypted", kind: "bytes" },
// v1.21.3 (B1) — Coach goal / if-then plan free-text columns.
{ model: "CoachPlan", field: "ifCueEncrypted", kind: "bytes" },
{ model: "CoachPlan", field: "thenActionEncrypted", kind: "bytes" },
{ model: "CoachPlan", field: "targetEncrypted", kind: "bytes" },
// ───── User health profile (Bytes columns) ─────
{ model: "UserHealthProfile", field: "aboutMeEncrypted", kind: "bytes" },
+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"]> = {
+26 -6
View File
@@ -43,6 +43,7 @@ import {
type QueryClient,
} from "@tanstack/react-query";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval";
import { retryOnceOnTransientError } from "@/lib/queries/retry-transient";
@@ -106,11 +107,20 @@ export function prefetchDashboardSnapshot(queryClient: QueryClient) {
// Cache already warm (return-to-dashboard within staleTime) — the
// mounted cell serves it without ever calling `queryFn`; fetching here
// would be a wasted request parked in the slot.
const state = queryClient.getQueryState(queryKeys.dashboardSnapshot());
if (
state?.data !== undefined &&
Date.now() - state.dataUpdatedAt < PRELOAD_MAX_AGE_MS
) {
// v1.21.3 (b) — the live cell is locale-keyed (`["dashboard","snapshot",
// locale]`), but the preloader runs before the locale context is in scope.
// Probe ALL `["dashboard","snapshot"]` cells (prefix) and treat the freshest
// as the warm-slot signal: if any locale's cell is fresh, skip the preload;
// otherwise warm the request through the handoff and let the mounted cell
// commit it under whichever locale the user lands on.
const states = queryClient
.getQueriesData({ queryKey: queryKeys.dashboardSnapshot() })
.map(([key]) => queryClient.getQueryState(key));
const freshest = states.reduce<number>(
(max, s) => (s ? Math.max(max, s.dataUpdatedAt) : max),
0,
);
if (freshest > 0 && Date.now() - freshest < PRELOAD_MAX_AGE_MS) {
return;
}
const promise = fetchDashboardSnapshot();
@@ -165,8 +175,18 @@ function makeSnapshotQueryFn(queryClient: QueryClient) {
export function useDashboardSnapshot(enabled = true) {
const queryClient = useQueryClient();
// v1.21.3 (b) — key the live snapshot cell by the active locale so a locale
// switch reads the freshly-localised prose on its own cache cell rather than
// serving the prior locale's tile copy / narrative until the 60 s staleTime
// elapses. The module-level preloader stays locale-agnostic: it warms the
// request through the promise handoff (NOT the query cache), and the mounted
// cell commits it under the locale key here, so the preload still rides in
// parallel with the page chunk and warms exactly the locale the user lands
// on. Every zero-arg `dashboardSnapshot()` invalidation still prefix-matches
// this locale-keyed cell, so cache eviction is unchanged.
const { locale } = useTranslations();
return useQuery({
queryKey: queryKeys.dashboardSnapshot(),
queryKey: queryKeys.dashboardSnapshot(locale),
queryFn: makeSnapshotQueryFn(queryClient),
enabled,
staleTime: 60_000,
+13 -1
View File
@@ -34,8 +34,20 @@ export const dashboardKeys = {
* `src/lib/cache/invalidate.ts`; the client read carries the same
* 60 s `staleTime` as `DASHBOARD_QUERY_OPTS` so a warm return-to-
* dashboard is a free cache hit.
*
* v1.21.3 (b) — optional `locale` segment so a locale switch lands the
* server-rendered prose (the snapshot carries localised tile copy /
* narrative text) on its own cache cell instead of serving the prior
* locale's prose until the slot goes stale. Mirrors the `analytics(slice?)`
* optional-segment precedent above: a ZERO-ARG call stays byte-identical to
* the legacy `["dashboard","snapshot"]` literal, so every prefix-based
* invalidation bundle (`measurementDependentKeys`, `medicationDependentKeys`)
* and zero-arg reader keeps prefix-matching the locale-keyed cells.
*/
dashboardSnapshot: () => ["dashboard", "snapshot"] as const,
dashboardSnapshot: (locale?: string) =>
locale
? (["dashboard", "snapshot", locale] as const)
: (["dashboard", "snapshot"] as const),
/**
* v1.4.22 W5 reconcile (Code-LOW-5) — `["user", "dashboardWidgets"]`
+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",
);