feat(coach): keep stored plans readable and erasable with the Coach unavailable

Plans are the person's own record, like the stored facts and
conversations. The list and delete routes no longer ask the Coach
module, and the plans page turns read-only instead of redirecting to
Insights: every plan can be read and removed, while confirming or moving
one stays Coach use and is refused by the PATCH route as before.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 11:48:15 +02:00
1 parent e0ece41e21
commit 5e5d33ecf8
16 files changed
+168 -81

No files matched your search

+6 -17
View File
@@ -9429,14 +9429,14 @@ paths:
tags:
- Insights
summary: List the caller's Coach goal / if-then plans
description: v1.21.3 — returns the durable plans the Coach has proposed for the caller, newest first, each decrypted on
description: 'v1.21.3 — 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 to one lifecycle status, or `?scope=` for a named group (open = proposed + active + review_due, past =
met + abandoned + reviewed, all = every non-deleted plan) — mutually exclusive. Both 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.
non-terminal set (proposed + active). Not gated on the Coach: the plans belong to the caller and stay readable
while the Coach is unavailable (v1.39). 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
@@ -9471,12 +9471,6 @@ paths:
schema:
$ref: "#/components/schemas/CoachPlansList"
"401": *a1
"403":
description: Coach surface disabled.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
/api/coach/plans/{id}:
@@ -9530,7 +9524,8 @@ paths:
summary: Soft-delete one Coach plan
description: "v1.21.3 — 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."
Not gated on the Coach: erasing one's own plan works while the Coach is unavailable (v1.39). Auth via cookie or
Bearer."
parameters:
- name: id
in: path
@@ -9741,12 +9736,6 @@ paths:
schema:
$ref: "#/components/schemas/CoachReminderDeleted"
"401": *a1
"403":
description: Coach surface disabled.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
/api/coach/suggested-actions:
+1
View File
@@ -9281,6 +9281,7 @@
"plans": {
"title": "Pläne",
"pageDescription": "Wenn-dann-Pläne aus deinen Coach-Gesprächen. Bestätige einen Vorschlag, damit der Coach ihn sich merkt.",
"readOnlyNote": "Der Coach ist gerade nicht verfügbar, deine Pläne kannst du aber weiter lesen und löschen.",
"proposalTitle": "Plan-Vorschlag",
"ifThen": "Wenn {cue}, dann {action}.",
"targetPrefix": "Ziel: {target}",
+1
View File
@@ -9281,6 +9281,7 @@
"plans": {
"title": "Plans",
"pageDescription": "If-then plans agreed in your Coach conversations. Confirm a proposal to make it part of the Coach's memory.",
"readOnlyNote": "The Coach is not available right now, but you can still read and delete your plans.",
"proposalTitle": "Plan proposal",
"ifThen": "If {cue}, then {action}.",
"targetPrefix": "Target: {target}",
+1
View File
@@ -9281,6 +9281,7 @@
"plans": {
"title": "Planes",
"pageDescription": "Planes de tipo «si… entonces» acordados en tus conversaciones con el Coach. Confirma una propuesta para que el Coach la recuerde.",
"readOnlyNote": "El Coach no está disponible ahora mismo, pero aún puedes leer y eliminar tus planes.",
"proposalTitle": "Propuesta de plan",
"ifThen": "Si {cue}, entonces {action}.",
"targetPrefix": "Objetivo: {target}",
+1
View File
@@ -9281,6 +9281,7 @@
"plans": {
"title": "Plans",
"pageDescription": "Plans « si… alors » convenus dans vos conversations avec le Coach. Confirmez une proposition pour que le Coach s'en souvienne.",
"readOnlyNote": "Le Coach n'est pas disponible pour le moment, mais vous pouvez toujours lire et supprimer vos plans.",
"proposalTitle": "Proposition de plan",
"ifThen": "Si {cue}, alors {action}.",
"targetPrefix": "Objectif : {target}",
+1
View File
@@ -9281,6 +9281,7 @@
"plans": {
"title": "Piani",
"pageDescription": "Piani «se… allora» concordati nelle conversazioni con il Coach. Conferma una proposta perché il Coach la tenga a mente.",
"readOnlyNote": "Il Coach non è disponibile al momento, ma puoi ancora leggere ed eliminare i tuoi piani.",
"proposalTitle": "Proposta di piano",
"ifThen": "Se {cue}, allora {action}.",
"targetPrefix": "Obiettivo: {target}",
+1
View File
@@ -9281,6 +9281,7 @@
"plans": {
"title": "계획",
"pageDescription": "코치와의 대화에서 합의한 조건-실행 계획이에요. 제안을 확정하면 코치가 기억해요.",
"readOnlyNote": "지금은 코치를 사용할 수 없지만 계획은 계속 읽고 삭제할 수 있습니다.",
"proposalTitle": "계획 제안",
"ifThen": "만약 {cue}, 그러면 {action}.",
"targetPrefix": "목표: {target}",
+1
View File
@@ -9281,6 +9281,7 @@
"plans": {
"title": "Plany",
"pageDescription": "Plany typu „jeśli… to” uzgodnione w rozmowach z Coachem. Potwierdź propozycję, aby Coach ją zapamiętał.",
"readOnlyNote": "Coach jest teraz niedostępny, ale nadal możesz czytać i usuwać swoje plany.",
"proposalTitle": "Propozycja planu",
"ifThen": "Jeśli {cue}, to {action}.",
"targetPrefix": "Cel: {target}",
@@ -327,6 +327,9 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
"src/app/api/insights/coach/seen/route.ts",
"src/app/api/insights/coach/facts/route.ts",
"src/app/api/insights/coach/facts/[id]/route.ts",
// The stored plans list. Its sibling `[id]` route keeps the Coach gate on
// PATCH (confirming a proposal is Coach use) and erases without it.
"src/app/api/coach/plans/route.ts",
"src/app/api/insights/chat/[id]/route.ts",
"src/app/api/insights/chat/messages/[id]/feedback/route.ts",
// The Today digest and its dismiss: data, with the AI parts masked by the
+5 -5
View File
@@ -16,9 +16,11 @@
* 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.
* PATCH is Coach use (it confirms what the Coach proposed) and is gated by
* `requireModuleEnabled(userId, "coach")`. DELETE is not: erasing one's own
* stored plan never depends on the Coach being available, the same rule as
* the stored facts and conversations. Bodies are Zod-parsed; an invalid body
* returns the multi-issue 422 envelope.
*/
import type { NextRequest } from "next/server";
@@ -207,8 +209,6 @@ export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => {
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;
@@ -276,10 +276,15 @@ describe("GET /api/coach/plans", () => {
expect(body.data.plans[0]?.id).toBe("p2");
});
it("invokes the coach module gate", async () => {
it("lists stored plans with the Coach unavailable (they are the person's record)", async () => {
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: false,
response: new Response(null, { status: 403 }),
} as never);
vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([] as never);
await callGet();
expect(requireModuleEnabled).toHaveBeenCalledWith("user-1", "coach");
const res = await callGet();
expect(res.status).toBe(200);
expect(requireModuleEnabled).not.toHaveBeenCalled();
});
});
@@ -438,11 +443,16 @@ describe("DELETE /api/coach/plans/[id]", () => {
expect(body.data.deleted).toBe(false);
});
it("invokes the coach module gate", async () => {
vi.mocked(prisma.coachPlan.updateMany).mockResolvedValue({
count: 0,
it("erases a stored plan with the Coach unavailable", async () => {
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: false,
response: new Response(null, { status: 403 }),
} as never);
await callDeleteOne();
expect(requireModuleEnabled).toHaveBeenCalledWith("user-1", "coach");
vi.mocked(prisma.coachPlan.updateMany).mockResolvedValue({
count: 1,
} as never);
const res = await callDeleteOne();
expect(res.status).toBe(200);
expect(requireModuleEnabled).not.toHaveBeenCalled();
});
});
+5 -8
View File
@@ -12,8 +12,11 @@
* 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).
* Not gated on the Coach. A plan is the person's own stored record: reading
* it never depends on the Coach being available, the same rule as the stored
* facts and conversations (a Coach switched off by the operator or hidden by
* the person must not lock them out of what it kept). Activating a proposed
* plan is Coach use and stays gated on the PATCH route.
* The owner is always narrowed from the session, never the body.
*
* `?status=proposed|active|met|abandoned` optionally filters the list (e.g.
@@ -26,7 +29,6 @@ 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";
@@ -38,11 +40,6 @@ const SCOPE_STATUSES: Record<"open" | "past", string[]> = {
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,
+11 -13
View File
@@ -188,19 +188,17 @@ function CoachConversationsBody({ readOnly }: { readOnly: boolean }) {
}
actions={
// Surface the sibling Plans ledger — otherwise it is reachable
// only from the composer's `+` menu. Plans need the Coach, so the
// link stays away while it is unavailable.
readOnly ? undefined : (
<Button asChild variant="outline" size="sm">
<Link
href="/coach/plans"
data-slot="coach-conversations-plans-link"
>
<Target className="size-4" aria-hidden="true" />
{t("coach.plans.title")}
</Link>
</Button>
)
// only from the composer's `+` menu. Plans stay readable with the
// Coach unavailable, so the link does too.
<Button asChild variant="outline" size="sm">
<Link
href="/coach/plans"
data-slot="coach-conversations-plans-link"
>
<Target className="size-4" aria-hidden="true" />
{t("coach.plans.title")}
</Link>
</Button>
}
/>
<div className="relative">
@@ -0,0 +1,93 @@
/**
* `/coach/plans` with the Coach unavailable.
*
* Plans are the person's own record. Before v1.39 the page redirected to
* `/insights` as soon as the Coach was unavailable, so a person whose Coach
* the operator switched off could neither read nor erase what it had kept.
* Now the page stays and turns read-only: every plan is listed and can be
* removed, while confirming or moving one (Coach use, refused by the PATCH
* route) is not offered.
*/
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
const capability = vi.hoisted(() => ({
answer: null as { available: boolean } | null,
}));
vi.mock("@/hooks/use-ai-capability", () => ({
useAiCapabilityAnswer: () => capability.answer,
}));
const plan = (id: string, status: string) => ({
id,
metric: "WEIGHT",
ifCue: `cue ${id}`,
thenAction: `action ${id}`,
target: null,
status,
reviewDate: null,
sourceConversationId: null,
createdAt: "2026-09-01T08:00:00.000Z",
updatedAt: "2026-09-01T08:00:00.000Z",
});
vi.mock("@/hooks/use-coach-plans", () => ({
useCoachPlans: () => ({
data: [
plan("p-proposed", "proposed"),
plan("p-active", "active"),
plan("p-met", "met"),
],
isError: false,
isLoading: false,
refetch: vi.fn(),
}),
useCoachPlanMutations: () => ({
setStatus: { mutate: vi.fn(), isPending: false, variables: undefined },
remove: { mutate: vi.fn(), isPending: false, variables: undefined },
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import CoachPlansPage from "../page";
function render(): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<CoachPlansPage />
</I18nProvider>,
);
}
const count = (html: string, slot: string) =>
html.split(`data-slot="${slot}"`).length - 1;
describe("/coach/plans", () => {
it("renders nothing until the account answered", () => {
capability.answer = null;
expect(render()).toBe("");
});
it("stays readable and erasable with the Coach unavailable", () => {
capability.answer = { available: false };
const html = render();
expect(html).toContain('data-slot="coach-plans-page"');
expect(count(html, "coach-plan-row")).toBe(3);
// Every plan can be removed, whatever group it is in.
expect(count(html, "coach-plan-delete")).toBe(3);
// Confirming or moving a plan is Coach use and is not offered.
expect(html).not.toContain('data-slot="coach-plan-accept"');
expect(html).not.toContain('data-slot="coach-plan-met"');
expect(html).not.toContain('data-slot="coach-plan-abandon"');
expect(html).toContain("you can still read and delete your plans");
});
it("offers the lifecycle controls with the Coach available", () => {
capability.answer = { available: true };
const html = render();
expect(html).toContain('data-slot="coach-plan-accept"');
expect(html).toContain('data-slot="coach-plan-met"');
expect(count(html, "coach-plan-delete")).toBe(1);
expect(html).not.toContain("you can still read and delete your plans");
});
});
+18 -20
View File
@@ -1,8 +1,7 @@
"use client";
import { useEffect, useMemo } from "react";
import { useMemo } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
Check,
Loader2,
@@ -45,8 +44,10 @@ import { cn } from "@/lib/utils";
* is injected into the Coach's snapshot memory (top-6, newest first), so
* follow-up conversations recall it; a proposed or declined plan never is.
*
* Gating mirrors `/coach`: the operator master flag OR a per-user opt-out
* redirects back to `/insights` rather than painting a dead shell.
* With the Coach unavailable (for any reason) the page stays reachable and
* turns read-only, like `/coach/conversations`: the plans are the person's
* own record, so they can still be read and erased. Confirming or moving a
* plan is Coach use and is not offered then (the PATCH route refuses it).
*/
type GroupId = "proposed" | "standing" | "past";
@@ -63,7 +64,7 @@ function groupOf(status: string): GroupId {
return "past";
}
function CoachPlansBody() {
function CoachPlansBody({ readOnly }: { readOnly: boolean }) {
const { t } = useTranslations();
const query = useCoachPlans({ filter: { scope: "all" } });
const { setStatus, remove } = useCoachPlanMutations();
@@ -121,7 +122,7 @@ function CoachPlansBody() {
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
{group === "proposed" && (
{!readOnly && group === "proposed" && (
<>
<Button
type="button"
@@ -158,7 +159,7 @@ function CoachPlansBody() {
</Button>
</>
)}
{group === "standing" && (
{!readOnly && group === "standing" && (
<>
<Button
type="button"
@@ -188,7 +189,7 @@ function CoachPlansBody() {
</Button>
</>
)}
{group === "past" && (
{(readOnly || group === "past") && (
<ConfirmButton
slot="coach-plan-delete"
variant="ghost"
@@ -215,7 +216,11 @@ function CoachPlansBody() {
title={
<span data-slot="coach-plans-heading">{t("coach.plans.title")}</span>
}
description={t("coach.plans.pageDescription")}
description={
readOnly
? t("coach.plans.readOnlyNote")
: t("coach.plans.pageDescription")
}
actions={
<Button asChild variant="outline" size="sm">
<Link
@@ -277,18 +282,11 @@ function CoachPlansBody() {
}
export default function CoachPlansPage() {
const router = useRouter();
// Same gate as `/coach`: the `coach` capability, acted on only once
// `/me` has answered.
// The `coach` capability, read only once `/me` has answered. Unavailable
// means read-only, never a redirect.
const coach = useAiCapabilityAnswer("coach");
useEffect(() => {
if (coach !== null && !coach.available) {
router.replace("/insights");
}
}, [coach, router]);
if (coach === null || !coach.available) return null;
if (coach === null) return null;
return (
<div
@@ -298,7 +296,7 @@ export default function CoachPlansPage() {
// mobile-only BottomNav band).
className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]"
>
<CoachPlansBody />
<CoachPlansBody readOnly={!coach.available} />
</div>
);
}
+2 -10
View File
@@ -1626,7 +1626,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Insights"],
summary: "List the caller's Coach goal / if-then plans",
description:
'v1.21.3 — 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 to one lifecycle status, or `?scope=` for a named group (open = proposed + active + review_due, past = met + abandoned + reviewed, all = every non-deleted plan) — mutually exclusive. Both 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.',
'v1.21.3 — 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 to one lifecycle status, or `?scope=` for a named group (open = proposed + active + review_due, past = met + abandoned + reviewed, all = every non-deleted plan) — mutually exclusive. Both omitted returns the non-terminal set (proposed + active). Not gated on the Coach: the plans belong to the caller and stay readable while the Coach is unavailable (v1.39). 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",
@@ -1654,10 +1654,6 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"403": {
description: "Coach surface disabled.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
@@ -1710,7 +1706,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Insights"],
summary: "Soft-delete one Coach plan",
description:
"v1.21.3 — 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.",
"v1.21.3 — 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. Not gated on the Coach: erasing one's own plan works while the Coach is unavailable (v1.39). Auth via cookie or Bearer.",
parameters: [
{
name: "id",
@@ -1884,10 +1880,6 @@ export const coachReminderPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"403": {
description: "Coach surface disabled.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},