mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Merge wave D — the shared provider is offered only when it is known to work
The done screen's assistant panel stops claiming that the instance's provider already serves the person. A tri-state read over the operator's own provider ledger decides between a one-tap offer that mints the consent receipt, a plain "not answering right now" note, and today's setup link; anything unknown, any read error, a managed profile and an operator who has switched the assistant off all fall to no offer. # Conflicts: # e2e/setup/global-setup.ts
This commit is contained in:
commit
1885b7867d
18 files changed
+1204
-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