mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
feat(onboarding): the done screen offers the provider only when it works
The card read one field and made two claims from it: that insights work, and that no setup is needed. The first was a presence read - it knew a key existed, never that it answered - and the second skipped the consent receipt the egress actually needs, so the person who believed the note met a consent.ai.required refusal on their first briefing. Four branches now, one button. The offer appears only when the server says the shared provider is known to work and nothing forbids it, and the tap posts the affirmative web intent. An unhealthy or unknown verdict paints 'not answering' with no button. When the offer is refused for a reason that is not health, the panel stays neutral rather than blaming the provider. The note survives for the one state it was ever true of: consent on file. The sample, the ladder, the 'fully useful without' line and the setup link are identical in every branch. Copy in all seven locales.
This commit is contained in:
1 parent
0d2bad69e3
commit
74ae672373
9 files changed
+371
-23
No files matched your search
@@ -12,15 +12,52 @@ import { I18nProvider } from "@/lib/i18n/context";
|
||||
*/
|
||||
|
||||
const aiProviderState = vi.hoisted(() => ({
|
||||
data: undefined as { managedBy?: string | null } | undefined,
|
||||
data: undefined as
|
||||
| {
|
||||
managedBy?: string | null;
|
||||
serverProviderHealth?: string;
|
||||
serverProviderOffer?: boolean;
|
||||
serverProviderConsent?: boolean;
|
||||
}
|
||||
| undefined,
|
||||
}));
|
||||
|
||||
/**
|
||||
* The consent grant is a mutation, and this suite renders to static markup
|
||||
* (the house convention — there is no jsdom in this repo), so the click
|
||||
* itself cannot be dispatched. What IS worth proving is the request the
|
||||
* button would send, so every `useMutation` registration is captured here
|
||||
* and its `mutationFn` invoked directly.
|
||||
*/
|
||||
const mutations = vi.hoisted(
|
||||
() =>
|
||||
[] as Array<{
|
||||
mutationKey?: readonly unknown[];
|
||||
mutationFn?: () => Promise<unknown>;
|
||||
}>,
|
||||
);
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: aiProviderState.data }),
|
||||
useMutation: () => ({ mutate: () => {}, isPending: false }),
|
||||
useMutation: (options?: {
|
||||
mutationKey?: readonly unknown[];
|
||||
mutationFn?: () => Promise<unknown>;
|
||||
}) => {
|
||||
if (options) mutations.push(options);
|
||||
return { mutate: () => {}, mutateAsync: async () => {}, isPending: false };
|
||||
},
|
||||
useQueryClient: () => ({ invalidateQueries: async () => {} }),
|
||||
}));
|
||||
|
||||
const apiFetchRaw = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
apiFetchRaw,
|
||||
}));
|
||||
|
||||
const toastError = vi.hoisted(() => vi.fn());
|
||||
vi.mock("sonner", () => ({ toast: { error: toastError } }));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: () => {}, replace: () => {} }),
|
||||
}));
|
||||
@@ -110,11 +147,133 @@ describe("<DoneScreen> AI panel", () => {
|
||||
expect(render("both")).toContain('href="/settings/access"');
|
||||
expect(render("me")).not.toContain('href="/settings/access"');
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the shared-key note only when the operator key serves the user", () => {
|
||||
aiProviderState.data = { managedBy: "user" };
|
||||
expect(render()).not.toContain('data-slot="onboarding-ai-shared-key"');
|
||||
aiProviderState.data = { managedBy: "server" };
|
||||
expect(render()).toContain('data-slot="onboarding-ai-shared-key"');
|
||||
/**
|
||||
* v1.38.19 (wave D) — the shared provider, offered only when it works.
|
||||
*
|
||||
* The card used to read `managedBy === "server"` and promise that insights
|
||||
* "work for you right now". That is a presence read: it knew a key was
|
||||
* configured, never that it worked, and it said nothing about the consent
|
||||
* receipt the egress actually needs — so a fresh account followed the
|
||||
* promise straight into a `consent.ai.required` refusal. On 2026-09-11 the
|
||||
* operator's own instance was answering HTTP 500 while the screen kept
|
||||
* making that promise.
|
||||
*
|
||||
* Four variants now, and only one of them has a button.
|
||||
*/
|
||||
function grantMutation() {
|
||||
return mutations.find((m) =>
|
||||
(m.mutationKey ?? []).some(
|
||||
(part) => typeof part === "string" && part.includes("consent"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe("<DoneScreen> shared-provider offer", () => {
|
||||
it("offers one tap when the server says the provider is known to work", () => {
|
||||
aiProviderState.data = {
|
||||
managedBy: "server",
|
||||
serverProviderHealth: "healthy",
|
||||
serverProviderOffer: true,
|
||||
serverProviderConsent: false,
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toContain('data-slot="onboarding-ai-offer"');
|
||||
expect(html).toContain('data-slot="onboarding-ai-offer-grant"');
|
||||
// The unproven promise is gone from this branch.
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-shared-key"');
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-unavailable"');
|
||||
});
|
||||
|
||||
it.each(["unhealthy", "unknown"] as const)(
|
||||
"says the provider is not answering on %s, and offers no button",
|
||||
(health) => {
|
||||
aiProviderState.data = {
|
||||
managedBy: "server",
|
||||
serverProviderHealth: health,
|
||||
serverProviderOffer: false,
|
||||
serverProviderConsent: false,
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toContain('data-slot="onboarding-ai-unavailable"');
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-offer-grant"');
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-shared-key"');
|
||||
// The rest of the panel is untouched in every branch.
|
||||
expect(html).toContain('data-slot="onboarding-ai-keyless"');
|
||||
expect(html).toContain('href="/settings/ai"');
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the shared-key note for the one state it was ever true of", () => {
|
||||
aiProviderState.data = {
|
||||
managedBy: "server",
|
||||
serverProviderHealth: "healthy",
|
||||
serverProviderOffer: false,
|
||||
serverProviderConsent: true,
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toContain('data-slot="onboarding-ai-shared-key"');
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-offer-grant"');
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-unavailable"');
|
||||
});
|
||||
|
||||
it("leaves a user with their own provider the setup link and nothing else", () => {
|
||||
aiProviderState.data = {
|
||||
managedBy: "user",
|
||||
serverProviderHealth: "healthy",
|
||||
serverProviderOffer: false,
|
||||
serverProviderConsent: false,
|
||||
};
|
||||
const html = render();
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-offer"');
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-unavailable"');
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-shared-key"');
|
||||
expect(html).toContain('href="/settings/ai"');
|
||||
});
|
||||
|
||||
it("says nothing at all while the status is still in flight", () => {
|
||||
aiProviderState.data = undefined;
|
||||
const html = render();
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-offer"');
|
||||
expect(html).not.toContain('data-slot="onboarding-ai-unavailable"');
|
||||
});
|
||||
|
||||
it("grants through the affirmative web intent — the only path that may lift a revocation", async () => {
|
||||
mutations.length = 0;
|
||||
apiFetchRaw.mockReset();
|
||||
apiFetchRaw.mockResolvedValue({ ok: true });
|
||||
aiProviderState.data = {
|
||||
managedBy: "server",
|
||||
serverProviderHealth: "healthy",
|
||||
serverProviderOffer: true,
|
||||
serverProviderConsent: false,
|
||||
};
|
||||
render();
|
||||
const grant = grantMutation();
|
||||
expect(grant).toBeDefined();
|
||||
await grant!.mutationFn!();
|
||||
expect(apiFetchRaw).toHaveBeenCalledWith(
|
||||
"/api/consent/ai/web",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ intent: "affirmative" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("a refused grant throws rather than reporting a consent nobody recorded", async () => {
|
||||
mutations.length = 0;
|
||||
apiFetchRaw.mockReset();
|
||||
apiFetchRaw.mockResolvedValue({ ok: false, status: 429 });
|
||||
aiProviderState.data = {
|
||||
managedBy: "server",
|
||||
serverProviderHealth: "healthy",
|
||||
serverProviderOffer: true,
|
||||
serverProviderConsent: false,
|
||||
};
|
||||
render();
|
||||
const grant = grantMutation();
|
||||
await expect(grant!.mutationFn!()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,8 @@
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Compass,
|
||||
@@ -24,7 +25,7 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { useAccountSwitch } from "@/hooks/use-account-switch";
|
||||
import { setTourReferrer } from "@/components/onboarding/tour-launcher";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { markChecklistExpanded } from "@/lib/onboarding/checklist-storage";
|
||||
import type { OnboardingStateDto } from "@/lib/onboarding/needs";
|
||||
@@ -33,6 +34,15 @@ import { accountLabel } from "@/lib/sharing/account-access-view";
|
||||
interface AiProviderStatus {
|
||||
/** Origin of the provider that would serve this user, if any. */
|
||||
managedBy?: "user" | "local" | "server" | null;
|
||||
/**
|
||||
* v1.38.19 (wave D) — the instance-wide tri-state. Fail closed: `unknown`
|
||||
* is treated exactly like `unhealthy`, here and everywhere.
|
||||
*/
|
||||
serverProviderHealth?: "healthy" | "unhealthy" | "unknown";
|
||||
/** Whether the shared provider may honestly be offered in one tap. */
|
||||
serverProviderOffer?: boolean;
|
||||
/** Whether this user already holds an active `ai_full` / `ai_coach` receipt. */
|
||||
serverProviderConsent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,10 +111,21 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
|
||||
router.push("/");
|
||||
}
|
||||
|
||||
// The shared-key note is the ONLY honest divergence in the panel: on a
|
||||
// deployment that ships an operator key, insights already work for the
|
||||
// user, so we say so plainly instead of pushing a BYOK/local setup they
|
||||
// don't need. Presence-only read; no key material is exposed.
|
||||
// ── v1.38.19 (wave D) — the shared provider, offered only when it works ──
|
||||
//
|
||||
// This block used to be one line: `managedBy === "server"` painted a note
|
||||
// reading "insights work for you right now — no setup needed". Both halves
|
||||
// were wrong. `managedBy` is a PRESENCE read — it knows the operator
|
||||
// configured a key, never that the key answers — and "no setup needed"
|
||||
// skipped the consent receipt `consent-guard.ts` demands before any health
|
||||
// data reaches `admin-openai` / `admin-codex`, so the person who believed
|
||||
// the note walked into a `consent.ai.required` refusal on their first
|
||||
// briefing. On 2026-09-11 the operator's own instance was answering HTTP
|
||||
// 500 from its OAuth proxy while this note kept promising otherwise.
|
||||
//
|
||||
// The server decides now, and the four branches below only READ it. The
|
||||
// rest of the panel — sample, ladder, the "fully useful without AI" line,
|
||||
// the setup link — is identical in every branch; nothing here is a gate.
|
||||
const { data: aiProvider } = useQuery<AiProviderStatus>({
|
||||
queryKey: queryKeys.userAiProvider(),
|
||||
queryFn: async () => {
|
||||
@@ -112,7 +133,58 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
const sharedKeyServes = aiProvider?.managedBy === "server";
|
||||
|
||||
// The tap's own outcome, held locally so the line changes the moment the
|
||||
// receipt is minted rather than a refetch later.
|
||||
const [justGranted, setJustGranted] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const grantConsent = useMutation({
|
||||
mutationKey: queryKeys.aiConsentReceipt("ai_full"),
|
||||
mutationFn: async () => {
|
||||
// `intent: "affirmative"` marks this as the user's own consent act —
|
||||
// the only web path allowed to supersede an earlier withdrawal. The
|
||||
// silent mount heal posts no body and deliberately cannot.
|
||||
const res = await apiFetchRaw("/api/consent/ai/web", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ intent: "affirmative" }),
|
||||
});
|
||||
if (!res.ok) throw new Error("consent grant failed");
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setJustGranted(true);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.userAiProvider(),
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.aiConsentReceipt("ai_full"),
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
// The button stays. A failed grant is a failed grant; the screen must
|
||||
// not read as though consent were on file.
|
||||
toast.error(t("onboarding.ai.offer.error"));
|
||||
},
|
||||
});
|
||||
|
||||
const operatorProvides = aiProvider?.managedBy === "server";
|
||||
const consentOnFile =
|
||||
justGranted || aiProvider?.serverProviderConsent === true;
|
||||
const offerShared =
|
||||
operatorProvides &&
|
||||
!consentOnFile &&
|
||||
aiProvider?.serverProviderOffer === true;
|
||||
// Only a health verdict paints the "not answering" card. When the offer is
|
||||
// refused for a reason that is not health — the operator switched the
|
||||
// assistant surfaces off, or this is a managed profile whose provider is
|
||||
// the operator's by definition — the panel falls back to its neutral form
|
||||
// and claims nothing either way.
|
||||
const sharedUnavailable =
|
||||
operatorProvides &&
|
||||
!consentOnFile &&
|
||||
!offerShared &&
|
||||
(aiProvider?.serverProviderHealth === "unhealthy" ||
|
||||
aiProvider?.serverProviderHealth === "unknown");
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -185,15 +257,55 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{sharedKeyServes ? (
|
||||
{consentOnFile && operatorProvides ? (
|
||||
<p
|
||||
data-slot="onboarding-ai-shared-key"
|
||||
className="text-foreground bg-primary/5 border-primary/20 rounded-lg border px-3 py-2 text-sm leading-relaxed"
|
||||
>
|
||||
{t("onboarding.ai.sharedKeyNote")}
|
||||
{justGranted
|
||||
? t("onboarding.ai.offer.granted")
|
||||
: t("onboarding.ai.sharedKeyNote")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{offerShared ? (
|
||||
<div
|
||||
data-slot="onboarding-ai-offer"
|
||||
className="border-primary/20 bg-primary/5 space-y-2.5 rounded-lg border px-3 py-3"
|
||||
>
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("onboarding.ai.offer.title")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t("onboarding.ai.offer.body")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-9 w-full"
|
||||
data-slot="onboarding-ai-offer-grant"
|
||||
disabled={grantConsent.isPending}
|
||||
onClick={() => grantConsent.mutate()}
|
||||
>
|
||||
{t("onboarding.ai.offer.cta")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{sharedUnavailable ? (
|
||||
<div
|
||||
data-slot="onboarding-ai-unavailable"
|
||||
className="border-border bg-muted/40 space-y-1 rounded-lg border px-3 py-3"
|
||||
>
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("onboarding.ai.unavailable.title")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t("onboarding.ai.unavailable.body")}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("onboarding.ai.ladderTitle")}
|
||||
|
||||
Reference in new issue
Block a user