From 5e5d33ecf805711144f2535fdbac8ef39b7b83c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 11:48:15 +0200 Subject: [PATCH] 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. --- docs/api/openapi.yaml | 23 ++--- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/pl.json | 1 + .../module-route-gate-inventory.test.ts | 3 + src/app/api/coach/plans/[id]/route.ts | 10 +- .../api/coach/plans/__tests__/route.test.ts | 26 ++++-- src/app/api/coach/plans/route.ts | 13 +-- src/app/coach/conversations/page.tsx | 24 +++-- .../plans/__tests__/page-read-only.test.tsx | 93 +++++++++++++++++++ src/app/coach/plans/page.tsx | 38 ++++---- src/lib/openapi/routes/coach.ts | 12 +-- 16 files changed, 168 insertions(+), 81 deletions(-) create mode 100644 src/app/coach/plans/__tests__/page-read-only.test.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index cd18fe97e..466698744 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/messages/de.json b/messages/de.json index ae1e581a3..f393249b0 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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}", diff --git a/messages/en.json b/messages/en.json index aef4fcb86..ea852d2bc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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}", diff --git a/messages/es.json b/messages/es.json index d0c5cb37e..fbfff98a9 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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}", diff --git a/messages/fr.json b/messages/fr.json index e006f315a..80ba6ed56 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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}", diff --git a/messages/it.json b/messages/it.json index 51dc640a9..b84aaf102 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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}", diff --git a/messages/ko.json b/messages/ko.json index 7fc71b76d..ee1169f2b 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -9281,6 +9281,7 @@ "plans": { "title": "계획", "pageDescription": "코치와의 대화에서 합의한 조건-실행 계획이에요. 제안을 확정하면 코치가 기억해요.", + "readOnlyNote": "지금은 코치를 사용할 수 없지만 계획은 계속 읽고 삭제할 수 있습니다.", "proposalTitle": "계획 제안", "ifThen": "만약 {cue}, 그러면 {action}.", "targetPrefix": "목표: {target}", diff --git a/messages/pl.json b/messages/pl.json index 8fc0ddc6e..afc4028f0 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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}", diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index ba12f419a..ac88bf326 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -327,6 +327,9 @@ const EXEMPT_ROUTES: ReadonlyArray = [ "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 diff --git a/src/app/api/coach/plans/[id]/route.ts b/src/app/api/coach/plans/[id]/route.ts index 02a13b6df..fc368aa71 100644 --- a/src/app/api/coach/plans/[id]/route.ts +++ b/src/app/api/coach/plans/[id]/route.ts @@ -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; diff --git a/src/app/api/coach/plans/__tests__/route.test.ts b/src/app/api/coach/plans/__tests__/route.test.ts index f0b871460..109ff00b3 100644 --- a/src/app/api/coach/plans/__tests__/route.test.ts +++ b/src/app/api/coach/plans/__tests__/route.test.ts @@ -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(); }); }); diff --git a/src/app/api/coach/plans/route.ts b/src/app/api/coach/plans/route.ts index 998ed9fe5..7c656ed40 100644 --- a/src/app/api/coach/plans/route.ts +++ b/src/app/api/coach/plans/route.ts @@ -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, diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx index 1d3048e6d..457bf92e0 100644 --- a/src/app/coach/conversations/page.tsx +++ b/src/app/coach/conversations/page.tsx @@ -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 : ( - - ) + // only from the composer's `+` menu. Plans stay readable with the + // Coach unavailable, so the link does too. + } />
diff --git a/src/app/coach/plans/__tests__/page-read-only.test.tsx b/src/app/coach/plans/__tests__/page-read-only.test.tsx new file mode 100644 index 000000000..246ca36f4 --- /dev/null +++ b/src/app/coach/plans/__tests__/page-read-only.test.tsx @@ -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( + + + , + ); +} + +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"); + }); +}); diff --git a/src/app/coach/plans/page.tsx b/src/app/coach/plans/page.tsx index 04ab0dec6..0a84f528c 100644 --- a/src/app/coach/plans/page.tsx +++ b/src/app/coach/plans/page.tsx @@ -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}
- {group === "proposed" && ( + {!readOnly && group === "proposed" && ( <>