From fdbbd3a84adbbd37c29c120069f07fc39b613554 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:50:18 +0200 Subject: [PATCH] fix(settings): gate the Coach tuning on the record's module state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Coach hid its preference and nudge cards on the raw disableCoach column, which /me publishes for the signed-in person. Inside a managed or shared record that answered for the wrong person: a guardian with the Coach on saw the tuning for a record that has it off, and the other way round. The cards now read the module map the server resolves for the record on screen, the same map the navigation and the settings entry already follow. --- .../__tests__/coach-memory-while-off.test.tsx | 2 + .../coach-reminders-read-only.test.tsx | 2 + .../settings/__tests__/coach-section.test.tsx | 40 ++++++++++++++++++- src/components/settings/coach-section.tsx | 21 ++++++---- 4 files changed, 56 insertions(+), 9 deletions(-) diff --git a/src/components/settings/__tests__/coach-memory-while-off.test.tsx b/src/components/settings/__tests__/coach-memory-while-off.test.tsx index 96eecaca1..ffa3ee4b0 100644 --- a/src/components/settings/__tests__/coach-memory-while-off.test.tsx +++ b/src/components/settings/__tests__/coach-memory-while-off.test.tsx @@ -24,6 +24,8 @@ vi.mock("@/hooks/use-auth", () => ({ role: "USER", timezone: "Europe/Berlin", disableCoach: authState.disableCoach, + // The server's module map mirrors the record's opt-out. + modules: { coach: !authState.disableCoach }, }, isAuthenticated: true, isLoading: false, diff --git a/src/components/settings/__tests__/coach-reminders-read-only.test.tsx b/src/components/settings/__tests__/coach-reminders-read-only.test.tsx index 6a2011886..21af3b076 100644 --- a/src/components/settings/__tests__/coach-reminders-read-only.test.tsx +++ b/src/components/settings/__tests__/coach-reminders-read-only.test.tsx @@ -34,6 +34,8 @@ vi.mock("@/hooks/use-auth", () => ({ role: "USER", timezone: "Europe/Berlin", disableCoach: !coach.available, + // The record's module map, which the section's gate reads. + modules: { coach: coach.available }, }, isAuthenticated: true, isLoading: false, diff --git a/src/components/settings/__tests__/coach-section.test.tsx b/src/components/settings/__tests__/coach-section.test.tsx index f24d59fff..20c9689d2 100644 --- a/src/components/settings/__tests__/coach-section.test.tsx +++ b/src/components/settings/__tests__/coach-section.test.tsx @@ -14,6 +14,13 @@ vi.mock("next/navigation", () => ({ usePathname: () => "/settings/coach", })); +// Past hydration only where a test says so: the gate reads the account once +// mounted, and the pre-hydration pass shows every card. +const mountState = { mounted: false }; +vi.mock("@/hooks/use-mounted", () => ({ + useMounted: () => mountState.mounted, +})); + vi.mock("@tanstack/react-query", () => ({ useQuery: () => ({ data: null, isLoading: false, refetch: vi.fn() }), useInfiniteQuery: () => ({ data: undefined, isLoading: false }), @@ -31,10 +38,18 @@ vi.mock("@/hooks/use-ai-capability", () => ({ }), })); -const authState: { disableCoach: boolean } = { disableCoach: false }; +const authState: { + disableCoach: boolean; + modules: { coach?: boolean } | undefined; +} = { disableCoach: false, modules: undefined }; vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ - user: { id: "u1", role: "USER", disableCoach: authState.disableCoach }, + user: { + id: "u1", + role: "USER", + disableCoach: authState.disableCoach, + modules: authState.modules, + }, isAuthenticated: true, isLoading: false, refetch: vi.fn(), @@ -83,3 +98,24 @@ describe(" — SSR smoke", () => { expect(html).not.toContain("settings.coach.activate."); }); }); + +describe(" — the tuning follows the record, not the account", () => { + it("hides the nudge when the record has the Coach off, whatever the viewer's own opt-out says", () => { + // A guardian with the Coach on, inside a managed record that has it off: + // `/me` publishes the viewer's own `disableCoach` and the RECORD's module + // map. The record's answer decides. + mountState.mounted = true; + authState.disableCoach = false; + authState.modules = { coach: false }; + try { + expect(render()).not.toContain('id="coach-nudge"'); + authState.disableCoach = true; + authState.modules = { coach: true }; + expect(render()).toContain('id="coach-nudge"'); + } finally { + mountState.mounted = false; + authState.disableCoach = false; + authState.modules = undefined; + } + }); +}); diff --git a/src/components/settings/coach-section.tsx b/src/components/settings/coach-section.tsx index e4a0ccc83..631029590 100644 --- a/src/components/settings/coach-section.tsx +++ b/src/components/settings/coach-section.tsx @@ -17,8 +17,12 @@ * 4. Coach memory — durable-fact review + forget controls. * * The nav entry is module-gated on `coach`. The preference + nudge cards - * keep their own content gate on `!user.disableCoach`: hiding the Coach hides - * its tuning. The memory (facts, stored conversations and reminders) is never + * keep their own content gate on the same published module state + * (`user.modules.coach`, resolved by the server for the record on screen + * from that record's opt-out and the operator's switches): hiding the Coach + * hides its tuning. The raw `disableCoach` column is the signed-in person's + * own, so inside a managed or shared record it answered for the wrong + * person. The memory (facts, stored conversations and reminders) is never * gated: it is the person's own record, readable and deletable whatever the * Coach's state. The reminders card turns read-only while the Coach is * unavailable and, with the Coach hidden, shows only when rows exist. @@ -39,6 +43,7 @@ import { CoachNudgeCard } from "@/components/settings/coach-nudge-card"; import { CoachPrefsSection } from "@/components/settings/coach-prefs-section"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; +import { isSurfaceVisible } from "@/lib/modules/surface"; export function CoachSection() { const { isAuthenticated, user } = useAuth(); @@ -47,11 +52,13 @@ export function CoachSection() { // `useMounted()` to keep the hydration render matching the SSR HTML. const mounted = useMounted(); const authed = mounted && isAuthenticated; - // The preference + memory cards ride the same content gate as the rest - // of the Coach surface: when the user has hidden the Coach there is no - // assistant to tune or remember anything. (`mounted`-gated so the SSR + - // hydration renders agree.) - const coachEnabled = !mounted || !user?.disableCoach; + // The preference + nudge cards ride the same content gate as the rest of + // the Coach surface: when the record has the Coach hidden there is no + // assistant to tune. Read from the server's module map for the record, + // never recomputed from the account's own columns. (`mounted`-gated so the + // SSR + hydration renders agree.) + const coachEnabled = + !mounted || isSurfaceVisible("settings:coach", user?.modules); return (