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`.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 20:18:10 +01:00
1 parent aae029d94e
commit de8eadbf2f
8 files changed
+108 -19

No files matched your search

@@ -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<string, boolean>,
}));
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(
<I18nProvider initialLocale="en">
<AiSetupHint surface="insights" />
<AiSetupHint surface={surface} />
</I18nProvider>,
);
vi.unstubAllGlobals();
return html;
}
function dismissButton(): string {
const html = render();
const match = html.match(
/<button[^>]*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"');
});
});
+5 -2
View File
@@ -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 = () => {
@@ -62,8 +62,9 @@ vi.mock("next/navigation", () => ({
useRouter: () => ({ push: () => {}, replace: () => {} }),
}));
const auth = vi.hoisted(() => ({ modules: {} as Record<string, boolean> }));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: { id: "u1" } }),
useAuth: () => ({ user: { id: "u1", modules: auth.modules } }),
}));
/**
@@ -154,6 +155,13 @@ describe("<DoneScreen> 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();
@@ -153,6 +153,28 @@ describe("<GettingStartedChecklist> — 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();
+6 -3
View File
@@ -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<AiProviderStatus>({
queryKey: queryKeys.userAiProvider(),
queryFn: async () => {
@@ -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(
() =>