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

@@ -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,
},
},