From de8eadbf2f1e7256d7d4cd364f20bd446f43b31c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:18:10 +0100 Subject: [PATCH] fix(ai): keep setup invitations away from people who opted out A person who switched both the Coach and AI analysis off under Settings, Modules still got invited to set AI up: the Insights hint, its copy in Settings, AI, the checklist row and the onboarding panel all keyed on `provider.canConfigure`, which only knows the record kind and the operator switch. The flag itself stays as it is, because the AI usage card and the consent request read it too and still serve document reading, lab photos and medication extraction. The three invitation readers now also check the module map through `hasOptedOutOfAi`. --- .../__tests__/ai-setup-hint-target.test.tsx | 41 +++++++++++++++++-- src/components/insights/ai-setup-hint.tsx | 7 +++- .../onboarding/__tests__/done-screen.test.tsx | 10 ++++- .../getting-started-checklist.test.tsx | 22 ++++++++++ src/components/onboarding/done-screen.tsx | 9 ++-- .../onboarding/getting-started-checklist.tsx | 11 +++-- src/lib/ai/capabilities/types.ts | 16 ++++++++ src/lib/onboarding/checklist.ts | 11 ++--- 8 files changed, 108 insertions(+), 19 deletions(-) diff --git a/src/components/insights/__tests__/ai-setup-hint-target.test.tsx b/src/components/insights/__tests__/ai-setup-hint-target.test.tsx index 6e06357f6..c25d84aba 100644 --- a/src/components/insights/__tests__/ai-setup-hint-target.test.tsx +++ b/src/components/insights/__tests__/ai-setup-hint-target.test.tsx @@ -3,12 +3,21 @@ * phone it is the only control on the tile a thumb has to hit, so it carries * the 44 px floor every other mobile action does (`size-11`), stepping down * from `sm` like the rest of the app. + * + * The hint is also an invitation, so it stays away from a person who switched + * both the Coach and AI analysis off. */ import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +const auth = vi.hoisted(() => ({ + modules: {} as Record, +})); vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ user: { id: "u1" }, isAuthenticated: true }), + useAuth: () => ({ + user: { id: "u1", modules: auth.modules }, + isAuthenticated: true, + }), })); vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); vi.mock("@/hooks/use-ai-capability", () => ({ @@ -22,16 +31,21 @@ vi.mock("@/hooks/use-ai-capability", () => ({ import { I18nProvider } from "@/lib/i18n/context"; import { AiSetupHint } from "../ai-setup-hint"; -function dismissButton(): string { +function render(surface: "insights" | "settings" = "insights"): string { vi.stubGlobal("window", { localStorage: { getItem: () => null, setItem: () => undefined }, }); const html = renderToStaticMarkup( - + , ); vi.unstubAllGlobals(); + return html; +} + +function dismissButton(): string { + const html = render(); const match = html.match( /]*data-slot="ai-setup-hint-dismiss"[^>]*>/, ); @@ -46,3 +60,22 @@ describe("the setup hint's dismiss button", () => { expect(cls).toContain("size-11"); }); }); + +describe("the setup hint after an opt-out", () => { + afterEach(() => { + auth.modules = {}; + }); + + it("is gone on both surfaces once the Coach and AI analysis are off", () => { + auth.modules = { coach: false, insights: false }; + expect(render("insights")).not.toContain('data-slot="ai-setup-hint"'); + expect(render("settings")).not.toContain('data-slot="ai-setup-hint"'); + }); + + it("stays while either one is still on", () => { + auth.modules = { coach: false, insights: true }; + expect(render("insights")).toContain('data-slot="ai-setup-hint"'); + auth.modules = { coach: true, insights: false }; + expect(render("settings")).toContain('data-slot="ai-setup-hint"'); + }); +}); diff --git a/src/components/insights/ai-setup-hint.tsx b/src/components/insights/ai-setup-hint.tsx index 89bdb7a9b..c8fa9abdc 100644 --- a/src/components/insights/ai-setup-hint.tsx +++ b/src/components/insights/ai-setup-hint.tsx @@ -13,6 +13,7 @@ import { SettingsCardActions } from "@/components/settings/_card-actions"; import { useAiProviderState } from "@/hooks/use-ai-capability"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; +import { hasOptedOutOfAi } from "@/lib/ai/capabilities/types"; import { useTranslations } from "@/lib/i18n/context"; /** @@ -26,8 +27,9 @@ import { useTranslations } from "@/lib/i18n/context"; * It shows only while no provider is configured AND the person could set one * up here (`provider.canConfigure`: false inside somebody else's record and * while the operator has AI switched off, where the hint would point at a - * setting nobody on this screen can change). Dismissing it is remembered per - * viewer in this browser; the hint stays gone on both surfaces. + * setting nobody on this screen can change) AND they have not switched both + * the Coach and AI analysis off (`hasOptedOutOfAi`). Dismissing it is + * remembered per viewer in this browser; the hint stays gone on both surfaces. */ const AI_SETUP_HINT_STORAGE_PREFIX = "healthlog.ai-setup-hint.dismissed"; @@ -63,6 +65,7 @@ export function AiSetupHint({ const viewerId = user?.id ?? null; if (!mounted || viewerId === null) return null; if (provider.configured || !provider.canConfigure) return null; + if (hasOptedOutOfAi(user?.modules)) return null; if (dismissedNow || readDismissed(viewerId)) return null; const dismiss = () => { diff --git a/src/components/onboarding/__tests__/done-screen.test.tsx b/src/components/onboarding/__tests__/done-screen.test.tsx index 85725cd66..e704d891a 100644 --- a/src/components/onboarding/__tests__/done-screen.test.tsx +++ b/src/components/onboarding/__tests__/done-screen.test.tsx @@ -62,8 +62,9 @@ vi.mock("next/navigation", () => ({ useRouter: () => ({ push: () => {}, replace: () => {} }), })); +const auth = vi.hoisted(() => ({ modules: {} as Record })); vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ user: { id: "u1" } }), + useAuth: () => ({ user: { id: "u1", modules: auth.modules } }), })); /** @@ -154,6 +155,13 @@ describe(" AI panel", () => { providerState.canConfigure = true; }); + it("leaves the panel out once the Coach and AI analysis are switched off", () => { + auth.modules = { coach: false, insights: false }; + aiProviderState.data = undefined; + expect(render()).not.toContain('data-slot="onboarding-ai-panel"'); + auth.modules = {}; + }); + it("keeps every exit so the panel is fully skippable", () => { aiProviderState.data = undefined; const html = render(); diff --git a/src/components/onboarding/__tests__/getting-started-checklist.test.tsx b/src/components/onboarding/__tests__/getting-started-checklist.test.tsx index 13d7733dc..0f9ac9a92 100644 --- a/src/components/onboarding/__tests__/getting-started-checklist.test.tsx +++ b/src/components/onboarding/__tests__/getting-started-checklist.test.tsx @@ -153,6 +153,28 @@ describe(" — rows for an established account", () => }; }); + it("leaves the AI row out once the Coach and AI analysis are switched off", () => { + // Nothing configured, so the row would be an open to-do: the person + // declined AI in Settings, Modules, and the list does not ask again. + providerState = { + configured: false, + managedBy: null, + canConfigure: true, + responseTimeoutMs: 60_000, + }; + currentUser = user({ modules: { coach: false, insights: false } }); + expect(render()).toContain("4 of 5 done"); + // Either one still on keeps the row. + currentUser = user({ modules: { coach: false, insights: true } }); + expect(render()).toContain("4 of 6 done"); + providerState = { + configured: true, + managedBy: "user", + canConfigure: true, + responseTimeoutMs: 60_000, + }; + }); + it("keeps the supporting queries enabled while the card can render", () => { currentUser = user(); render(); diff --git a/src/components/onboarding/done-screen.tsx b/src/components/onboarding/done-screen.tsx index 39a6d2f80..7e86bcc4e 100644 --- a/src/components/onboarding/done-screen.tsx +++ b/src/components/onboarding/done-screen.tsx @@ -28,6 +28,7 @@ import { setTourReferrer } from "@/components/onboarding/tour-launcher"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; import { useGrantAiConsent } from "@/hooks/use-grant-ai-consent"; +import { hasOptedOutOfAi } from "@/lib/ai/capabilities/types"; import { useTranslations } from "@/lib/i18n/context"; import { markChecklistExpanded } from "@/lib/onboarding/checklist-storage"; import type { OnboardingStateDto } from "@/lib/onboarding/needs"; @@ -133,10 +134,12 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) { // The panel is one optional offer, shown only while this person could set // AI up here: `provider.canConfigure` is false while the operator has AI // switched off and inside a record somebody else owns. HealthLog is - // complete without it, so with nothing to offer the screen simply goes - // from "done" to the exits. + // complete without it, so with nothing to offer (or once the person + // switched the Coach and AI analysis off) the screen simply goes from + // "done" to the exits. const providerState = useAiProviderState(); - const showAiPanel = providerState.canConfigure; + const showAiPanel = + providerState.canConfigure && !hasOptedOutOfAi(user?.modules); const { data: aiProvider } = useQuery({ queryKey: queryKeys.userAiProvider(), queryFn: async () => { diff --git a/src/components/onboarding/getting-started-checklist.tsx b/src/components/onboarding/getting-started-checklist.tsx index e8bc4d4d0..ba5fcae95 100644 --- a/src/components/onboarding/getting-started-checklist.tsx +++ b/src/components/onboarding/getting-started-checklist.tsx @@ -21,6 +21,7 @@ import type { LucideIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useAccountOnceMounted } from "@/hooks/use-auth"; +import { hasOptedOutOfAi } from "@/lib/ai/capabilities/types"; import { useTranslations } from "@/lib/i18n/context"; import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; import { queryKeys } from "@/lib/query-keys"; @@ -263,9 +264,10 @@ export function GettingStartedChecklist() { }); // The AI row reads the provider state `/api/auth/me` already carries: - // present only while AI can be set up from here (`canConfigure`), done once - // any provider is configured (a personal key, a local model, an OAuth - // sign-in or the operator's shared key). No request of its own. + // present only while AI can be set up from here (`canConfigure`) and the + // person has not switched the Coach and AI analysis off, done once any + // provider is configured (a personal key, a local model, an OAuth sign-in or + // the operator's shared key). No request of its own. const aiProvider = useAiProviderState(); // v1.5: skip these two fetches once the card can't render at @@ -336,7 +338,8 @@ export function GettingStartedChecklist() { (channel) => channel?.enabled === true, ); const insightsConfigured = aiProvider.configured; - const aiConfigurable = aiProvider.canConfigure; + const aiConfigurable = + aiProvider.canConfigure && !hasOptedOutOfAi(user?.modules); const items = useMemo( () => diff --git a/src/lib/ai/capabilities/types.ts b/src/lib/ai/capabilities/types.ts index bb1397d80..6c3e411e4 100644 --- a/src/lib/ai/capabilities/types.ts +++ b/src/lib/ai/capabilities/types.ts @@ -189,6 +189,22 @@ export const AI_OPT_OUT_MODULES: ReadonlySet = new Set([ "insights", ]); +/** + * Whether the record has every opt-out module switched off, read from the + * module map on `/api/auth/me` (a key the map does not carry counts as on). + * An invitation to set AI up has nothing left to offer then. It does not gate + * the AI settings themselves: document reading, lab photos and medication + * extraction belong to other modules and still need a provider and consent. + */ +export function hasOptedOutOfAi( + modules: Partial> | null | undefined, +): boolean { + for (const key of AI_OPT_OUT_MODULES) { + if (modules?.[key] !== false) return false; + } + return true; +} + const COACH_KINDS: readonly AiConsentKind[] = ["ai_coach", "ai_full"]; const INSIGHTS_KINDS: readonly AiConsentKind[] = [ "ai_insights_only", diff --git a/src/lib/onboarding/checklist.ts b/src/lib/onboarding/checklist.ts index 3b5b24f02..cf1f3b4f1 100644 --- a/src/lib/onboarding/checklist.ts +++ b/src/lib/onboarding/checklist.ts @@ -83,11 +83,12 @@ export interface ChecklistInputs { */ insightsConfigured: boolean; /** - * Whether AI can be set up from here at all (`ai.provider.canConfigure`): - * false while the operator has AI switched off and inside a record - * somebody else owns. The AI row exists only when true. HealthLog is - * complete without AI, so the list never carries a to-do nobody on this - * screen can finish. + * Whether AI can be set up from here at all (`ai.provider.canConfigure`) + * and is still wanted: false while the operator has AI switched off, inside + * a record somebody else owns, and once the person switched the Coach and + * AI analysis off. The AI row exists only when true. HealthLog is complete + * without AI, so the list never carries a to-do nobody on this screen can + * finish or that was declined. */ aiConfigurable: boolean; /** Dismissed item ids (per-item localStorage state). */