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" && ( <>