mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(ai): refresh the account's AI answer after every change to its inputs
Every AI surface reads its capability from the ai block on /api/auth/me, but setting up or removing a provider, signing in to or out of Codex, saving the fallback chain, the central-Codex opt-in, the local-OCR switch, a minted consent receipt, the admin's server key and central Codex, and a guardian's save inside a managed record evicted only their own reads. The surfaces stayed on the old answer until a reload. All of these writes, and the ones that already refreshed the payload (consent, modules, Hide Coach, the operator's switches), now evict one bundle, aiInputDependentKeys. A structural test holds every client write to those endpoints to it.
This commit is contained in:
1 parent
03ecb5bd52
commit
676bb2eff7
23 files changed
+260
-62
No files matched your search
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Structural guard: every client write that changes an input of the `ai`
|
||||
* block on `GET /api/auth/me` evicts it through `aiInputDependentKeys`.
|
||||
*
|
||||
* Every AI surface on the web reads its capability from that block. Before
|
||||
* v1.39 shipped, the provider forms, the Codex sign-in, the fallback chain and
|
||||
* the central-Codex switch evicted only the provider and insights reads, so a
|
||||
* freshly configured provider left every AI surface on "not set up" until a
|
||||
* reload. The fix routes all of them through one bundle; this guard keeps a
|
||||
* new write site from re-deciding the keys by hand.
|
||||
*
|
||||
* The matcher finds write calls (`apiPost/apiPut/apiPatch/apiDelete`, and
|
||||
* `apiFetch`/`apiFetchRaw` with a write method) whose literal path names one
|
||||
* of the endpoints below, and asserts the file calls
|
||||
* `invalidateKeys(<client>, aiInputDependentKeys)`. A tripwire, not a proof:
|
||||
* it holds the file, not each mutation in it (one mutation can branch over
|
||||
* two endpoints and evict once), and a path built at runtime would slip the
|
||||
* matcher, which is why the matched total carries a floor and an empty sweep
|
||||
* fails.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { aiInputDependentKeys, queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
/**
|
||||
* The endpoints whose writes change an input of the capability answer.
|
||||
* Starting a device sign-in changes nothing until the poll reports success,
|
||||
* so the `device-start` legs are not listed.
|
||||
*/
|
||||
const AI_INPUT_ENDPOINTS = [
|
||||
"/api/user/ai-provider",
|
||||
"/api/insights/provider-chain",
|
||||
"/api/auth/codex/device-poll",
|
||||
"/api/auth/codex/disconnect",
|
||||
"/api/auth/me/use-central-codex",
|
||||
"/api/auth/me/labs-local-ocr",
|
||||
"/api/consent/ai",
|
||||
"/api/auth/me/modules",
|
||||
"/api/auth/me/disable-coach",
|
||||
"/api/admin/ai-settings",
|
||||
"/api/admin/settings/assistant-flags",
|
||||
"/api/admin/settings/module-availability",
|
||||
"/api/admin/central-codex/device-poll",
|
||||
"/api/admin/central-codex",
|
||||
// A managed record's modules / coach / profile families, written by the
|
||||
// guardian from inside that record.
|
||||
"/api/record-settings",
|
||||
] as const;
|
||||
|
||||
const HELPER_WRITE =
|
||||
/\bapi(?:Post|Put|Patch|Delete)\s*(?:<[^()]*?>)?\s*\(\s*["'`](\/api\/[^"'`?]+)/g;
|
||||
const RAW_WRITE =
|
||||
/\bapiFetch(?:Raw)?\s*(?:<[^()]*?>)?\s*\(\s*["'`](\/api\/[^"'`?]+)[^"'`]*["'`]\s*,\s*\{[^}]*?\bmethod:\s*["'](?:POST|PUT|PATCH|DELETE)["']/g;
|
||||
const BUNDLE_CALL =
|
||||
/\binvalidateKeys\(\s*[A-Za-z_]\w*\s*,\s*aiInputDependentKeys\b/g;
|
||||
|
||||
function isAiInputEndpoint(path: string): boolean {
|
||||
if (path.endsWith("/device-start")) return false;
|
||||
return AI_INPUT_ENDPOINTS.some((e) => path === e || path.startsWith(`${e}/`));
|
||||
}
|
||||
|
||||
function inScope(rel: string): boolean {
|
||||
if (rel.includes("__tests__") || /\.test\.tsx?$/.test(rel)) return false;
|
||||
if (rel.startsWith("app/api/") || rel.startsWith("generated/")) return false;
|
||||
return (
|
||||
rel.startsWith("components/") ||
|
||||
rel.startsWith("hooks/") ||
|
||||
rel.startsWith("app/")
|
||||
);
|
||||
}
|
||||
|
||||
function writeSites(source: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const re of [HELPER_WRITE, RAW_WRITE]) {
|
||||
for (const m of source.matchAll(re)) {
|
||||
if (isAiInputEndpoint(m[1]!)) out.push(m[1]!);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const files = walkSourceFiles(SRC, { floor: 3000 }).filter(inScope);
|
||||
const sites = files
|
||||
.map((rel) => {
|
||||
const source = readFileSync(join(SRC, rel), "utf8");
|
||||
return {
|
||||
rel,
|
||||
writes: writeSites(source),
|
||||
bundleCalls: [...source.matchAll(BUNDLE_CALL)].length,
|
||||
};
|
||||
})
|
||||
.filter((s) => s.writes.length > 0);
|
||||
|
||||
describe("AI-input writes evict the capability answer", () => {
|
||||
it("the bundle carries the account payload every AI surface reads", () => {
|
||||
expect(aiInputDependentKeys).toContainEqual(queryKeys.authMe());
|
||||
});
|
||||
|
||||
it("finds the write sites it is meant to hold (an empty sweep is a broken matcher)", () => {
|
||||
const total = sites.reduce((n, s) => n + s.writes.length, 0);
|
||||
expect(sites.length).toBeGreaterThanOrEqual(21);
|
||||
expect(total).toBeGreaterThanOrEqual(22);
|
||||
});
|
||||
|
||||
it.each(sites.map((s) => [s.rel, s] as const))(
|
||||
"%s invalidates aiInputDependentKeys",
|
||||
(_rel, site) => {
|
||||
expect(
|
||||
site.bundleCalls,
|
||||
`writes to ${site.writes.join(", ")}`,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -14,7 +14,11 @@ import { Label } from "@/components/ui/label";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
aiInputDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
import { PasswordInput } from "./_shared";
|
||||
|
||||
@@ -74,6 +78,8 @@ export function AiServerKeySection() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.adminAiServerKey(),
|
||||
});
|
||||
// The server key is a provider input of the admin's own AI answer.
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
toast.success(t("admin.aiServerKey.saved"));
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
|
||||
@@ -7,7 +7,11 @@ import { toast } from "sonner";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
aiInputDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { SettingsToggle } from "./_shared";
|
||||
import { apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
|
||||
@@ -74,7 +78,7 @@ function useUpdateAssistantFlags() {
|
||||
// Every record's resolved AI capabilities ride the account payload,
|
||||
// which is the only thing the web reads them from; bust it so the
|
||||
// operator sees the change within the session.
|
||||
client.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
void invalidateKeys(client, aiInputDependentKeys);
|
||||
toast.success(t("common.saved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
|
||||
@@ -30,7 +30,11 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
aiInputDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
|
||||
interface CentralCodexStatus {
|
||||
status: string;
|
||||
@@ -100,6 +104,7 @@ export function CentralCodexSection() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.adminCentralCodex(),
|
||||
});
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
return;
|
||||
}
|
||||
if (!cancelled) setTimeout(tick, intervalMs);
|
||||
@@ -135,6 +140,7 @@ export function CentralCodexSection() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.adminCentralCodex(),
|
||||
});
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("admin.centralCodex.error"),
|
||||
|
||||
@@ -8,7 +8,11 @@ import { toast } from "sonner";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
aiInputDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import {
|
||||
MODULE_KEYS,
|
||||
MODULE_REGISTRY,
|
||||
@@ -66,7 +70,7 @@ function useUpdateModuleAvailability() {
|
||||
client.setQueryData(queryKeys.adminModuleAvailability(), data);
|
||||
// The resolved `/api/auth/me` module map depends on this operator
|
||||
// layer; bust it so the operator sees the change within the session.
|
||||
client.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
void invalidateKeys(client, aiInputDependentKeys);
|
||||
toast.success(t("common.saved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
|
||||
@@ -16,7 +16,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { ocrImageToText } from "@/lib/labs/local-ocr";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
aiInputDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import type {
|
||||
OcrCapabilityDto,
|
||||
OcrCommitResponseDto,
|
||||
@@ -149,6 +153,8 @@ export function useUpdateLabsLocalOcr() {
|
||||
queryClient.setQueryData(queryKeys.labsLocalOcr(), data);
|
||||
// The toggle changes whether text-mode scanning is available.
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.ocrCapability() });
|
||||
// It is also a provider input of the `labsOcr` capability on `/me`.
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,7 +25,11 @@ import { useOnboardingAnswer } from "@/components/onboarding/use-onboarding-flow
|
||||
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 {
|
||||
aiInputDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { markChecklistExpanded } from "@/lib/onboarding/checklist-storage";
|
||||
@@ -162,9 +166,7 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setJustGranted(true);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.userAiProvider(),
|
||||
});
|
||||
await invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.aiConsentReceipt("ai_full"),
|
||||
});
|
||||
|
||||
@@ -39,7 +39,11 @@ import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
aiInputDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
|
||||
type ConsentReceiptWire = {
|
||||
id: string;
|
||||
@@ -86,10 +90,7 @@ export function AiConsentCard({
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.aiConsentReceipt("ai_full"),
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.insightsProviderChain(),
|
||||
});
|
||||
await invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -110,10 +111,7 @@ export function AiConsentCard({
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.aiConsentReceipt("ai_full"),
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.insightsProviderChain(),
|
||||
});
|
||||
await invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -292,7 +290,7 @@ function DocumentReadingConsentRow({
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.aiConsentReceipt("ai_extraction"),
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
await invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -28,7 +28,11 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
aiInputDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
|
||||
import { AdminOpenAIProviderForm } from "./admin-openai-provider-form";
|
||||
import { AiConsentCard } from "./ai-consent-card";
|
||||
@@ -127,10 +131,20 @@ export function AiInsightsCard({
|
||||
// settings UI. Runs once per mount of the AI-settings surface.
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
void apiFetchRaw("/api/consent/ai/web", { method: "POST" }).catch(() => {
|
||||
/* best-effort heal — the explicit grant path stays available */
|
||||
});
|
||||
}, [isAuthenticated]);
|
||||
// A heal that minted a receipt changed an input of the account's AI
|
||||
// capability answer, so `/me` re-resolves; a no-op heal leaves it alone.
|
||||
void apiFetchRaw("/api/consent/ai/web", { method: "POST" })
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return;
|
||||
const body = (await res.json()) as { data?: { minted?: boolean } };
|
||||
if (body.data?.minted) {
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* best-effort heal — the explicit grant path stays available */
|
||||
});
|
||||
}, [isAuthenticated, queryClient]);
|
||||
|
||||
// The Select is URL-driven so the SSR test can pick the branch and
|
||||
// a deep link works. Default = `?provider=…` query param when the
|
||||
|
||||
@@ -15,7 +15,7 @@ import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
|
||||
|
||||
import {
|
||||
ANTHROPIC_MODEL_PRESETS,
|
||||
@@ -79,8 +79,7 @@ export function AnthropicProviderForm({
|
||||
setOk(true);
|
||||
setMsg(t("settings.ai.saved"));
|
||||
setApiKey("");
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
},
|
||||
onError: (e) => {
|
||||
setOk(false);
|
||||
|
||||
@@ -23,7 +23,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
|
||||
|
||||
import type { InsightsSettings } from "./shared";
|
||||
|
||||
@@ -51,11 +51,9 @@ export function CentralCodexSwitch({
|
||||
}),
|
||||
onSuccess: () => {
|
||||
// The opt-in changes which providers can serve this user — refresh the
|
||||
// settings summary (which carries the flag) and every insight read.
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.insightsSettings(),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
|
||||
// settings summary (which carries the flag), the account's AI
|
||||
// capability answer, and every insight read.
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
setPendingEnable(false);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
|
||||
|
||||
import type { InsightsSettings } from "./shared";
|
||||
|
||||
@@ -55,7 +55,7 @@ export function CodexProviderForm({
|
||||
url.searchParams.delete("codex_error");
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
if (oauthOutcome.kind === "connected") {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
}
|
||||
}, [oauthOutcome, queryClient]);
|
||||
|
||||
@@ -112,7 +112,7 @@ export function CodexProviderForm({
|
||||
setDevicePolling(false);
|
||||
setMsg(t("settings.codexConnected"));
|
||||
setMsgType("success");
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
return;
|
||||
}
|
||||
if (!cancelled) setTimeout(tick, intervalMs);
|
||||
@@ -151,7 +151,7 @@ export function CodexProviderForm({
|
||||
}
|
||||
setMsg(t("settings.codexDisconnected"));
|
||||
setMsgType("success");
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
} catch (err) {
|
||||
setMsg(err instanceof Error ? err.message : t("settings.savingError"));
|
||||
setMsgType("error");
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
|
||||
|
||||
import { uiToLegacyProviderEnum, type UserAIProvider } from "./shared";
|
||||
|
||||
@@ -63,8 +63,7 @@ export function CompatProviderForm({
|
||||
setOk(true);
|
||||
setMsg(t("settings.ai.saved"));
|
||||
setApiKey("");
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
},
|
||||
onError: (e) => {
|
||||
setOk(false);
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* a pure UI inversion over the same column.
|
||||
*
|
||||
* Persists via `PATCH /api/auth/me/disable-coach`; the response invalidates
|
||||
* `queryKeys.authMe()` so every Coach mount point on the client
|
||||
* `aiInputDependentKeys` (the account payload first) so every Coach mount point on the client
|
||||
* (`<LayoutCoachFab>`, `<LayoutCoachMount>`, the inline `<CoachLaunchButton>`
|
||||
* pill, the `/targets` page CTAs) re-renders with the new `user.disableCoach`
|
||||
* value on the next React Query refetch tick — no full reload required.
|
||||
@@ -39,7 +39,7 @@ import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
|
||||
|
||||
export function DisableCoachCard({
|
||||
isAuthenticated,
|
||||
@@ -115,7 +115,7 @@ export function DisableCoachCard({
|
||||
// Surface the new value to every Coach gate via the shared
|
||||
// `useAuth()` query — the gates re-render once React Query
|
||||
// re-fetches /api/auth/me with the updated `disableCoach` field.
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
setOptimistic(null);
|
||||
scheduleClear();
|
||||
},
|
||||
|
||||
@@ -33,7 +33,7 @@ import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { apiPut } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
|
||||
|
||||
import {
|
||||
DEFAULT_CHAIN,
|
||||
@@ -99,7 +99,7 @@ export function FallbackChainCard({
|
||||
onSuccess: () => {
|
||||
setOk(true);
|
||||
setMsg(t("settings.ai.providerChain.saved"));
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
},
|
||||
onError: (e) => {
|
||||
setOk(false);
|
||||
|
||||
@@ -15,7 +15,7 @@ import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
|
||||
|
||||
import {
|
||||
CUSTOM_MODEL_SENTINEL,
|
||||
@@ -79,8 +79,7 @@ export function LocalProviderForm({
|
||||
setOk(true);
|
||||
setMsg(t("settings.ai.saved"));
|
||||
setApiKey("");
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
},
|
||||
onError: (e) => {
|
||||
setOk(false);
|
||||
|
||||
@@ -18,7 +18,7 @@ import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
|
||||
|
||||
import {
|
||||
CUSTOM_MODEL_SENTINEL,
|
||||
@@ -86,8 +86,7 @@ export function OpenAIProviderForm({
|
||||
setOk(true);
|
||||
setMsg(t("settings.ai.saved"));
|
||||
setApiKey("");
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
},
|
||||
onError: (e) => {
|
||||
setOk(false);
|
||||
|
||||
@@ -20,7 +20,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
|
||||
|
||||
import type { UserAIProvider } from "./shared";
|
||||
|
||||
@@ -69,7 +69,7 @@ export function ResponseTimeoutCard({
|
||||
onSuccess: () => {
|
||||
setOk(true);
|
||||
setMsg(t("settings.ai.saved"));
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.userAiProvider() });
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
},
|
||||
onError: (e) => {
|
||||
setOk(false);
|
||||
|
||||
@@ -12,7 +12,11 @@ import {
|
||||
type InsightsSectionId,
|
||||
} from "@/lib/insights-layout";
|
||||
import { MODULE_REGISTRY } from "@/lib/modules/registry";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
aiInputDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import {
|
||||
assertRecordSettingsResponseForRecord,
|
||||
MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS,
|
||||
@@ -1074,6 +1078,10 @@ export function ManagedRecordSettingsSection({
|
||||
queryKeys.recordSettingsFamily(recordId, family),
|
||||
response,
|
||||
);
|
||||
// The record's module map, Hide Coach and profile (gender gates the
|
||||
// cycle module) are inputs of the `/me` answer for this record, its AI
|
||||
// capabilities included.
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -44,7 +44,11 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
aiInputDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet, apiPatch } from "@/lib/api/api-fetch";
|
||||
|
||||
interface McpTokenInfo {
|
||||
@@ -211,7 +215,7 @@ function McpEnableCard() {
|
||||
mutationFn: async (next: boolean) =>
|
||||
apiPatch("/api/auth/me/modules", { mcp: next }),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
toast.success(t("settings.sections.modules.saved"));
|
||||
},
|
||||
onError: () => toast.error(t("settings.sections.modules.error")),
|
||||
|
||||
@@ -71,7 +71,11 @@ import {
|
||||
withBaseToken,
|
||||
isConflict,
|
||||
} from "@/lib/api/optimistic-token";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
aiInputDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import {
|
||||
MODULE_REGISTRY,
|
||||
isCodeDisabledModule,
|
||||
@@ -170,7 +174,8 @@ export function ModulesSection() {
|
||||
// Insights pills, dashboard tiles, quick-add, search. The two delegated
|
||||
// keys also feed dedicated settings surfaces, so evict their reads too
|
||||
// (mirrors the canonical Coach / cycle cards' own invalidation).
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
// The module map is also an input of the AI capability answer.
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
if (moduleDelegatesTo(vars.key) === "cycle") {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.cyclePrefs(),
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
aiInputDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* Grant the consent for reading documents (`ai_extraction`) from the place
|
||||
@@ -38,7 +42,7 @@ export function useGrantDocumentReadingConsent() {
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.aiConsentReceipt("ai_extraction"),
|
||||
});
|
||||
|
||||
@@ -302,6 +302,29 @@ export const vaccinationDependentKeys = [
|
||||
queryKeys.measurementReminders(),
|
||||
];
|
||||
|
||||
/**
|
||||
* Keys invalidated when an input of the `ai` block on `GET /api/auth/me`
|
||||
* changes: the account's own provider (key, local model, compatible endpoint,
|
||||
* Codex sign-in), the fallback chain, the central-Codex opt-in, a consent
|
||||
* receipt granted or withdrawn, a module switch, Hide Coach, and, for the
|
||||
* admin's own session, the operator's assistant switches, server key, central
|
||||
* Codex and module availability.
|
||||
*
|
||||
* Every AI surface reads its capability from that block, so a write that
|
||||
* changes an input without evicting `authMe()` leaves the surface on the old
|
||||
* answer: a freshly configured provider stays "not set up" until a reload, a
|
||||
* removed one keeps offering AI work the server then refuses. The provider and
|
||||
* insights roots ride along because they render the same state in detail.
|
||||
*
|
||||
* `ai-input-invalidation-guard.test.ts` holds every client write to one of
|
||||
* those endpoints to this bundle.
|
||||
*/
|
||||
export const aiInputDependentKeys = [
|
||||
queryKeys.authMe(),
|
||||
queryKeys.userAiProvider(),
|
||||
queryKeys.insightsRoot(),
|
||||
];
|
||||
|
||||
/**
|
||||
* Invalidate every key in the bundle in parallel. Use this from mutation
|
||||
* `onSuccess` handlers so the call site stays a one-liner instead of repeating
|
||||
|
||||
Reference in new issue
Block a user