From 6e45acfbc6b65a569d83a8a40e534e270811efff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 11:57:42 +0200 Subject: [PATCH] feat(coach): keep stored reminders readable and erasable with the Coach unavailable Reminders are the person's own record, like the stored facts, conversations and plans. The list and delete routes no longer ask the Coach module; creating, confirming, resolving or rescheduling one stays Coach use and is refused by POST and PATCH as before. Settings -> Coach now always mounts the reminders card (shown with Hide Coach on only when reminders exist), and Settings -> AI lists them through the stored-memory block while Settings -> Coach is out of reach. With the Coach unavailable the card is read-only: every reminder can be read and deleted, while keep / done / dismiss are not offered. --- docs/api/openapi.yaml | 30 +--- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/ko.json | 3 +- messages/pl.json | 3 +- .../module-route-gate-inventory.test.ts | 5 + src/app/api/coach/reminders/[id]/route.ts | 7 +- .../coach/reminders/__tests__/route.test.ts | 44 ++++- src/app/api/coach/reminders/route.ts | 16 +- .../coach-reminders-read-only.test.tsx | 155 ++++++++++++++++++ .../settings/__tests__/coach-section.test.tsx | 10 ++ .../settings-read-error-family.test.tsx | 10 ++ .../settings/coach-memory-section.tsx | 5 +- .../settings/coach-reminders-section.tsx | 33 +++- src/components/settings/coach-section.tsx | 16 +- src/lib/openapi/routes/coach.ts | 8 +- 19 files changed, 303 insertions(+), 57 deletions(-) create mode 100644 src/components/settings/__tests__/coach-reminders-read-only.test.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 466698744..5f1f7a8ee 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -9593,11 +9593,11 @@ paths: tags: - Insights summary: List the caller's Coach reminders - description: v1.22 (B2/B6) — the durable "remind me about X" memory the Coach captured inline, decrypted on the fly, + description: 'v1.22 (B2/B6) — the durable "remind me about X" memory the Coach captured inline, decrypted on the fly, soonest-due first. Pass `?status=` (one status or a comma set like `due,surfaced` for the in-app tile); omitted - returns the non-terminal set (proposed + active + due + surfaced). Coach-gated - (`requireModuleEnabled("coach")`). Auth via cookie or Bearer; the owner is narrowed from the session. - Undecryptable rows are omitted. + returns the non-terminal set (proposed + active + due + surfaced). Not gated on the Coach: the reminders belong + to the caller and stay readable while the Coach is unavailable (v1.39). Auth via cookie or Bearer; the owner is + narrowed from the session. Undecryptable rows are omitted.' parameters: - name: status in: query @@ -9614,24 +9614,7 @@ paths: schema: $ref: "#/components/schemas/CoachRemindersList" "401": *a1 - "403": - description: >- - Coach surface disabled. - - - Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). - Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections - do not reach this surface, and a read grant on a request that writes — the response carries no reason, so it - is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's - audit trail instead. On the cookie transport the same code answers one further case: a session that has - entered a shared record and sends a request without its record-context assertion, which is a client older - than the fence — leave the record and reload. A request that names no record but the caller's own never - reaches this response, so a client that never switches and never sends the per-request account selector will - not see it. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" + "403": *a9 "422": *a4 "429": *a5 post: @@ -9719,7 +9702,8 @@ paths: - Insights summary: Soft-delete one Coach reminder description: "v1.22 — soft-deletes a single reminder owned by the caller. An unknown / cross-user / already-deleted id - is an idempotent no-op returning `{ deleted: false }`. Coach-gated. Auth via cookie or Bearer." + is an idempotent no-op returning `{ deleted: false }`. Not gated on the Coach: erasing a stored reminder works + while the Coach is unavailable (v1.39). Auth via cookie or Bearer." parameters: - name: id in: path diff --git a/messages/de.json b/messages/de.json index f393249b0..68029e2d1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5794,7 +5794,8 @@ "deleteConfirmTitle": "Diese Erinnerung löschen?", "deleteConfirmBody": "Sie wird nicht mehr ausgelöst. An bereits erfassten Daten ändert sich nichts.", "deleteConfirmAction": "Löschen", - "detail": "Fällige erscheinen hier und beim Coach." + "detail": "Fällige erscheinen hier und beim Coach.", + "readOnlyNote": "Der Coach ist gerade nicht verfügbar, deine Erinnerungen kannst du aber weiter lesen und löschen." }, "aboutMe": { "title": "Über mich", diff --git a/messages/en.json b/messages/en.json index ea852d2bc..e39c7e57d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5794,7 +5794,8 @@ "deleteConfirmTitle": "Delete this reminder?", "deleteConfirmBody": "It stops being raised. Anything you have already recorded is untouched.", "deleteConfirmAction": "Delete", - "detail": "Due ones show up here and on the Coach." + "detail": "Due ones show up here and on the Coach.", + "readOnlyNote": "The Coach is not available right now, but you can still read and delete your reminders." }, "aboutMe": { "title": "About me", diff --git a/messages/es.json b/messages/es.json index fbfff98a9..6e86d01ab 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5794,7 +5794,8 @@ "deleteConfirmTitle": "¿Eliminar este recordatorio?", "deleteConfirmBody": "Deja de aparecer. Lo que ya has registrado no cambia.", "deleteConfirmAction": "Eliminar", - "detail": "Las que vencen aparecen aquí y en el Coach." + "detail": "Las que vencen aparecen aquí y en el Coach.", + "readOnlyNote": "El Coach no está disponible ahora mismo, pero aún puedes leer y eliminar tus recordatorios." }, "aboutMe": { "title": "Sobre mí", diff --git a/messages/fr.json b/messages/fr.json index 80ba6ed56..9fe90dcb7 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5794,7 +5794,8 @@ "deleteConfirmTitle": "Supprimer ce rappel ?", "deleteConfirmBody": "Il ne se déclenchera plus. Ce que tu as déjà enregistré reste intact.", "deleteConfirmAction": "Supprimer", - "detail": "Celles qui arrivent à échéance apparaissent ici et chez le Coach." + "detail": "Celles qui arrivent à échéance apparaissent ici et chez le Coach.", + "readOnlyNote": "Le Coach n'est pas disponible pour le moment, mais tu peux toujours lire et supprimer tes rappels." }, "aboutMe": { "title": "À propos de moi", diff --git a/messages/it.json b/messages/it.json index b84aaf102..f617caf03 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5794,7 +5794,8 @@ "deleteConfirmTitle": "Eliminare questo promemoria?", "deleteConfirmBody": "Non verrà più mostrato. Quanto hai già registrato resta invariato.", "deleteConfirmAction": "Elimina", - "detail": "Quelle in scadenza compaiono qui e nel Coach." + "detail": "Quelle in scadenza compaiono qui e nel Coach.", + "readOnlyNote": "Il Coach non è disponibile al momento, ma puoi ancora leggere ed eliminare i tuoi promemoria." }, "aboutMe": { "title": "Su di me", diff --git a/messages/ko.json b/messages/ko.json index ee1169f2b..b03990b57 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5794,7 +5794,8 @@ "deleteConfirmTitle": "이 알림을 삭제할까요?", "deleteConfirmBody": "더 이상 알려 주지 않아요. 이미 기록한 내용은 그대로예요.", "deleteConfirmAction": "삭제", - "detail": "예정된 알림은 여기와 코치에 나타나요." + "detail": "예정된 알림은 여기와 코치에 나타나요.", + "readOnlyNote": "지금은 코치를 사용할 수 없지만 알림은 계속 읽고 삭제할 수 있어요." }, "aboutMe": { "title": "나에 대해", diff --git a/messages/pl.json b/messages/pl.json index afc4028f0..d041381ff 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5794,7 +5794,8 @@ "deleteConfirmTitle": "Usunąć to przypomnienie?", "deleteConfirmBody": "Przestanie się pojawiać. Zapisane wcześniej dane pozostają bez zmian.", "deleteConfirmAction": "Usuń", - "detail": "Te, których termin nadszedł, pojawiają się tutaj i w Coachu." + "detail": "Te, których termin nadszedł, pojawiają się tutaj i w Coachu.", + "readOnlyNote": "Coach jest teraz niedostępny, ale nadal możesz czytać i usuwać swoje przypomnienia." }, "aboutMe": { "title": "O mnie", 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 ac88bf326..7bd394e83 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -330,6 +330,11 @@ const EXEMPT_ROUTES: ReadonlyArray = [ // 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", + // Not listed, by the same rule: `coach/reminders/route.ts` lists without the + // Coach gate but keeps it on POST (creating one is Coach use), and + // `coach/reminders/[id]/route.ts` erases without it but keeps it on PATCH. + // Both files still carry a gate, so the walk counts them as gated and an + // entry here would fail the "does not hide a gate" check below. "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/reminders/[id]/route.ts b/src/app/api/coach/reminders/[id]/route.ts index 8c702a04b..e8f65765a 100644 --- a/src/app/api/coach/reminders/[id]/route.ts +++ b/src/app/api/coach/reminders/[id]/route.ts @@ -14,6 +14,11 @@ * no-op rather than a P2025 throw — the existence channel never leaks across * accounts. PATCH on a 0-count match returns 404; DELETE returns the idempotent * `{ deleted: false }`. + * + * PATCH is Coach use (it confirms, resolves or reschedules what the Coach + * captured) and is gated by `requireModuleEnabled(userId, "coach")`. DELETE is + * not: erasing one's own stored reminder never depends on the Coach being + * available, the same rule as the stored facts, conversations and plans. */ import type { NextRequest } from "next/server"; @@ -152,8 +157,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/reminders/__tests__/route.test.ts b/src/app/api/coach/reminders/__tests__/route.test.ts index 5f3fd26dd..df26d1d3e 100644 --- a/src/app/api/coach/reminders/__tests__/route.test.ts +++ b/src/app/api/coach/reminders/__tests__/route.test.ts @@ -125,6 +125,14 @@ const callDelete = (id = "r1") => { params: Promise.resolve({ id }) }, ); +/** The Coach module gate refuses: operator switch, module off or Hide Coach. */ +function coachOff() { + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: false, + response: new Response(null, { status: 403 }), + } as never); +} + beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); @@ -162,7 +170,14 @@ describe("GET /api/coach/reminders", () => { ?.where as { userId: string; deletedAt: null }; expect(where.userId).toBe("user-1"); expect(where.deletedAt).toBeNull(); - expect(requireModuleEnabled).toHaveBeenCalledWith("user-1", "coach"); + }); + + it("lists stored reminders with the Coach unavailable (they are the person's record)", async () => { + coachOff(); + vi.mocked(prisma.coachReminder.findMany).mockResolvedValue([] as never); + const res = await callGet(); + expect(res.status).toBe(200); + expect(requireModuleEnabled).not.toHaveBeenCalled(); }); it("skips an undecryptable row rather than 500ing", async () => { @@ -202,6 +217,13 @@ describe("GET /api/coach/reminders", () => { }); describe("POST /api/coach/reminders", () => { + it("stays Coach use: refused with the Coach unavailable", async () => { + coachOff(); + const res = await callPost({ note: "check my sleep" }); + expect(res.status).toBe(403); + expect(prisma.coachReminder.create).not.toHaveBeenCalled(); + }); + it("creates a reminder field-by-field, resolving the when grammar", async () => { vi.mocked(prisma.coachReminder.count).mockResolvedValue(0 as never); vi.mocked(prisma.coachReminder.create).mockResolvedValue({ @@ -248,6 +270,14 @@ describe("POST /api/coach/reminders", () => { }); describe("PATCH /api/coach/reminders/[id]", () => { + it("stays Coach use: refused with the Coach unavailable", async () => { + coachOff(); + const res = await callPatch({ status: "active" }); + expect(res.status).toBe(403); + expect(requireModuleEnabled).toHaveBeenCalledWith("user-1", "coach"); + expect(prisma.coachReminder.updateMany).not.toHaveBeenCalled(); + }); + it("confirms proposed → active field-by-field, owner-scoped", async () => { vi.mocked(prisma.coachReminder.updateMany).mockResolvedValue({ count: 1, @@ -309,4 +339,16 @@ describe("DELETE /api/coach/reminders/[id]", () => { const body = (await res.json()) as { data: { deleted: boolean } }; expect(body.data.deleted).toBe(false); }); + + it("erases a stored reminder with the Coach unavailable", async () => { + coachOff(); + vi.mocked(prisma.coachReminder.updateMany).mockResolvedValue({ + count: 1, + } as never); + const res = await callDelete(); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { deleted: boolean } }; + expect(body.data.deleted).toBe(true); + expect(requireModuleEnabled).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/coach/reminders/route.ts b/src/app/api/coach/reminders/route.ts index 1291292a6..00496bd00 100644 --- a/src/app/api/coach/reminders/route.ts +++ b/src/app/api/coach/reminders/route.ts @@ -9,9 +9,13 @@ * * Ownership: every query is scoped `where: { userId, ... }`, so a caller can * only ever see / create their own reminders. The owner is always narrowed from - * the session, never the body. Coach-gated by the same - * `requireModuleEnabled(userId, "coach")` kill-switch as the rest of the Coach - * management stack. + * the session, never the body. + * + * GET is not gated on the Coach. A reminder is the person's own stored record: + * reading it never depends on the Coach being available, the same rule as the + * stored facts, conversations and plans (a Coach switched off by the operator + * or hidden by the person must not lock them out of what it kept). POST is + * Coach use and stays gated by `requireModuleEnabled(userId, "coach")`. * * `?status=` optionally filters (one status, or a comma set like `due,surfaced` * for the in-app tile). Omitted returns the non-terminal set (proposed / active @@ -77,11 +81,9 @@ export const GET = apiHandler(async (req: Request) => { // the delegate's own ledger while the page shows another person's record, // which is the failure mode this whole mode system exists to prevent. // - // The module gate below resolves against the record, so the surface exists - // for a delegate only where the OWNER switched the Coach on. + // No Coach gate: the ledger is readable whatever the Coach's state, for the + // owner and for a delegate with a read grant alike, like the mood note. const { user } = await requireRecordAuth("read", "record"); - const gate = await requireModuleEnabled(user.id, "coach"); - if (!gate.enabled) return gate.response; const url = new URL(req.url); const parsed = coachRemindersListQuerySchema.safeParse({ diff --git a/src/components/settings/__tests__/coach-reminders-read-only.test.tsx b/src/components/settings/__tests__/coach-reminders-read-only.test.tsx new file mode 100644 index 000000000..6a2011886 --- /dev/null +++ b/src/components/settings/__tests__/coach-reminders-read-only.test.tsx @@ -0,0 +1,155 @@ +/** + * Stored Coach reminders stay readable and erasable with the Coach unavailable. + * + * A "remind me about X" note is the person's own record, like the stored facts + * and conversations. Before v1.39 Settings → Coach mounted the reminders card + * only while the Coach was shown, and Settings → AI never mounted it, so a + * person whose Coach was switched off or hidden could neither read nor erase + * what it had kept. Now: + * + * - the card lists every reminder and offers delete on each, whatever the + * Coach's state; + * - keeping, resolving or dismissing one is Coach use (the PATCH route + * refuses it then), so those controls appear only with the Coach + * available; + * - Settings → Coach shows the card with Hide Coach on, and Settings → AI + * shows it through `` only when reminders exist. + */ +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const coach = vi.hoisted(() => ({ available: false })); +vi.mock("@/hooks/use-ai-capability", () => ({ + useAiCapability: () => ({ + available: coach.available, + reason: coach.available ? null : "user_disabled", + onDeviceAllowed: false, + }), +})); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { + id: "u1", + role: "USER", + timezone: "Europe/Berlin", + disableCoach: !coach.available, + }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); +vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/settings/coach", +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +import { CoachRemindersSection } from "../coach-reminders-section"; +import { CoachSection } from "../coach-section"; +import { StoredCoachMemory } from "../coach-memory-section"; + +const reminder = (id: string, status: string) => ({ + id, + note: `note ${id}`, + metric: null, + triggerKind: "date", + dueAt: null, + contextCue: null, + status, + source: "extractor", + createdAt: "2026-09-01T08:00:00.000Z", + updatedAt: "2026-09-01T08:00:00.000Z", +}); +const STORED = [ + reminder("r-proposed", "proposed"), + reminder("r-active", "active"), + reminder("r-surfaced", "surfaced"), +]; + +function render(node: React.ReactNode, reminders: unknown[]): string { + const client = new QueryClient({ + defaultOptions: { queries: { retry: 0, staleTime: Infinity } }, + }); + client.setQueryData(queryKeys.coachReminders(), reminders); + client.setQueryData(queryKeys.coachFacts(), []); + client.setQueryData(queryKeys.coachConversationHistory(""), { + pages: [{ conversations: [], nextCursor: null }], + pageParams: [null], + }); + return renderToStaticMarkup( + + {node} + , + ); +} + +const count = (html: string, needle: string) => html.split(needle).length - 1; + +beforeEach(() => { + // Everything is seeded; a background refetch must not reach the network. + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("{}", { status: 500 })), + ); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe("", () => { + it("stays readable and erasable with the Coach unavailable", () => { + coach.available = false; + const html = render(, STORED); + expect(count(html, 'data-testid="settings-coach-reminder"')).toBe(3); + expect(html).toContain("note r-proposed"); + // Every reminder can be removed. + expect(count(html, 'data-slot="coach-reminder-delete"')).toBe(3); + // Keeping, resolving or dismissing one is Coach use and is not offered. + expect(html).not.toContain("settings-coach-reminder-confirm"); + expect(html).not.toContain("settings-coach-reminder-done"); + expect(html).not.toContain("settings-coach-reminder-dismiss"); + expect(html).toContain("you can still read and delete your reminders"); + }); + + it("offers the lifecycle controls with the Coach available", () => { + coach.available = true; + const html = render(, STORED); + expect(html).toContain('data-testid="settings-coach-reminder-confirm"'); + expect(html).toContain('data-testid="settings-coach-reminder-done"'); + expect(html).toContain('data-testid="settings-coach-reminder-dismiss"'); + expect(count(html, 'data-slot="coach-reminder-delete"')).toBe(3); + expect(html).not.toContain("you can still read and delete your reminders"); + }); +}); + +describe("where the reminders show while the Coach is unavailable", () => { + it("Settings → Coach shows them with Hide Coach on", () => { + coach.available = false; + const html = render(, STORED); + expect(html).toContain('data-testid="settings-coach-reminders-card"'); + expect(html).toContain("note r-active"); + // The Coach's tuning stays hidden with the Coach. + expect(html).not.toContain('id="coach-nudge"'); + }); + + it("Settings → Coach does not invite new reminders with Hide Coach on", () => { + coach.available = false; + const html = render(, []); + expect(html).not.toContain('data-testid="settings-coach-reminders-card"'); + }); + + it("Settings → AI shows them through when they exist", () => { + coach.available = false; + const html = render(, STORED); + expect(html).toContain('data-testid="settings-coach-reminders-card"'); + expect(count(html, 'data-slot="coach-reminder-delete"')).toBe(3); + }); + + it("Settings → AI shows nothing for somebody with no stored reminders", () => { + coach.available = false; + expect(render(, [])).toBe(""); + }); +}); diff --git a/src/components/settings/__tests__/coach-section.test.tsx b/src/components/settings/__tests__/coach-section.test.tsx index 064709020..f24d59fff 100644 --- a/src/components/settings/__tests__/coach-section.test.tsx +++ b/src/components/settings/__tests__/coach-section.test.tsx @@ -21,6 +21,16 @@ vi.mock("@tanstack/react-query", () => ({ useQueryClient: () => ({ invalidateQueries: vi.fn() }), })); +// The reminders card reads the Coach capability off `/api/auth/me`; the +// react-query mock above carries no context for the real hook to read. +vi.mock("@/hooks/use-ai-capability", () => ({ + useAiCapability: () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), +})); + const authState: { disableCoach: boolean } = { disableCoach: false }; vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ diff --git a/src/components/settings/__tests__/settings-read-error-family.test.tsx b/src/components/settings/__tests__/settings-read-error-family.test.tsx index 9181bf587..18f03becd 100644 --- a/src/components/settings/__tests__/settings-read-error-family.test.tsx +++ b/src/components/settings/__tests__/settings-read-error-family.test.tsx @@ -25,6 +25,16 @@ vi.mock("@tanstack/react-query", () => ({ useQueryClient: () => ({ invalidateQueries: vi.fn(), setQueryData: vi.fn() }), })); +// The reminders card reads the Coach capability off `/api/auth/me`; the +// react-query mock above carries no context for the real hook to read. +vi.mock("@/hooks/use-ai-capability", () => ({ + useAiCapability: () => ({ + available: true, + reason: null, + onDeviceAllowed: true, + }), +})); + vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), usePathname: () => "/settings", diff --git a/src/components/settings/coach-memory-section.tsx b/src/components/settings/coach-memory-section.tsx index 67a4141bf..7625999a6 100644 --- a/src/components/settings/coach-memory-section.tsx +++ b/src/components/settings/coach-memory-section.tsx @@ -19,7 +19,8 @@ * v1.39 — never gated on the Coach. What the Coach stored is the person's * own record: it stays readable and deletable while the Coach is off for any * reason (the operator's switch, Hide Coach, no provider, no consent). The - * facts routes are data routes and ask no AI gate. Settings → Coach always + * facts routes are data routes and ask no AI gate. The same holds for the + * stored reminders (``). Settings → Coach always * shows this card; while that page is not reachable, Settings → AI shows it * through `` below, whenever rows exist. */ @@ -51,6 +52,7 @@ import { queryKeys } from "@/lib/query-keys"; import { toast } from "sonner"; import { apiDelete, apiGet } from "@/lib/api/api-fetch"; import { CoachConversationsMemoryCard } from "@/components/settings/coach-conversations-memory-card"; +import { CoachRemindersSection } from "@/components/settings/coach-reminders-section"; /** Closed enum mirrored from the server `CoachFact.category` column. */ const FACT_CATEGORIES = [ @@ -310,6 +312,7 @@ export function StoredCoachMemory({ isAuthenticated={isAuthenticated} hideWhenEmpty /> + ); } diff --git a/src/components/settings/coach-reminders-section.tsx b/src/components/settings/coach-reminders-section.tsx index c4cde2c14..27b01e0f1 100644 --- a/src/components/settings/coach-reminders-section.tsx +++ b/src/components/settings/coach-reminders-section.tsx @@ -14,8 +14,15 @@ * DELETE /api/coach/reminders/{id} → { data: { deleted } } * * Reads unwrap `(await res.json()).data`; every key routes through - * `queryKeys.coachReminders()` so a mutation invalidates the list. Gated on the - * Coach surface like the rest of the memory controls. + * `queryKeys.coachReminders()` so a mutation invalidates the list. + * + * v1.39 — never gated on the Coach. A reminder is the person's own record, like + * the stored facts and conversations: the list and delete routes ask no Coach + * gate, so the card stays readable and every reminder stays deletable while the + * Coach is unavailable for any reason. Keeping, resolving or dismissing one is + * Coach use (the PATCH route refuses it then), so those controls are not + * offered in that state. Settings → Coach always mounts this card; Settings → AI + * mounts it through `` with `hideWhenEmpty`. */ import { useMemo } from "react"; import { BellRing, Check, Trash2, X } from "lucide-react"; @@ -28,6 +35,7 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { ConfirmButton } from "@/components/ui/confirm-button"; import { useTranslations } from "@/lib/i18n/context"; +import { useAiCapability } from "@/hooks/use-ai-capability"; import { useCoachReminders, useCoachReminderMutations, @@ -38,10 +46,16 @@ const DUE_STATUSES = new Set(["due", "surfaced"]); export function CoachRemindersSection({ isAuthenticated, + hideWhenEmpty = false, }: { isAuthenticated: boolean; + /** Render nothing while there are no stored reminders (the Coach-off mount). */ + hideWhenEmpty?: boolean; }) { const { t } = useTranslations(); + // Unavailable (including the loading frame) means read-only: the lifecycle + // controls appear only once the account says the Coach can be used. + const readOnly = !useAiCapability("coach").available; const query = useCoachReminders({ enabled: isAuthenticated }); const { setStatus, remove } = useCoachReminderMutations(); @@ -87,7 +101,7 @@ export function CoachRemindersSection({ {t(`settings.ai.coachReminders.status.${r.status}`)}
- {isProposed && ( + {!readOnly && isProposed && (
); } diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts index 20f692eb3..86fa35cea 100644 --- a/src/lib/openapi/routes/coach.ts +++ b/src/lib/openapi/routes/coach.ts @@ -1745,7 +1745,7 @@ export const coachReminderPaths: NonNullable = { tags: ["Insights"], summary: "List the caller's Coach reminders", description: - 'v1.22 (B2/B6) — the durable "remind me about X" memory the Coach captured inline, decrypted on the fly, soonest-due first. Pass `?status=` (one status or a comma set like `due,surfaced` for the in-app tile); omitted returns the non-terminal set (proposed + active + due + surfaced). Coach-gated (`requireModuleEnabled("coach")`). Auth via cookie or Bearer; the owner is narrowed from the session. Undecryptable rows are omitted.', + 'v1.22 (B2/B6) — the durable "remind me about X" memory the Coach captured inline, decrypted on the fly, soonest-due first. Pass `?status=` (one status or a comma set like `due,surfaced` for the in-app tile); omitted returns the non-terminal set (proposed + active + due + surfaced). Not gated on the Coach: the reminders belong to the caller and stay readable while the Coach is unavailable (v1.39). Auth via cookie or Bearer; the owner is narrowed from the session. Undecryptable rows are omitted.', parameters: [ { name: "status", @@ -1769,8 +1769,8 @@ export const coachReminderPaths: NonNullable = { }, }, // The read is delegable and the create beside it is not, so only this - // one shares its 403 with the sharing refusal. - ...recordRefusal("Coach surface disabled."), + // one answers the sharing refusal. It asks no Coach gate. + ...recordRefusal(), ...stdResponses, }, }, @@ -1857,7 +1857,7 @@ export const coachReminderPaths: NonNullable = { tags: ["Insights"], summary: "Soft-delete one Coach reminder", description: - "v1.22 — soft-deletes a single reminder owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`. Coach-gated. Auth via cookie or Bearer.", + "v1.22 — soft-deletes a single reminder owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`. Not gated on the Coach: erasing a stored reminder works while the Coach is unavailable (v1.39). Auth via cookie or Bearer.", parameters: [ { name: "id",