Files
HealthLog/src/components/admin/module-availability-section.tsx
T
Marc-André Bombeck 676bb2eff7 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.
2026-09-24 12:11:14 +02:00

160 lines
5.3 KiB
TypeScript

"use client";
import Link from "next/link";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { ToggleLeft } from "lucide-react";
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 {
aiInputDependentKeys,
invalidateKeys,
queryKeys,
} from "@/lib/query-keys";
import {
MODULE_KEYS,
MODULE_REGISTRY,
SWITCH_OWNED_MODULE_KEYS,
isCodeDisabledModule,
} from "@/lib/modules/registry";
import type { ModuleKey } from "@/lib/modules/registry";
import { SettingsToggle } from "./_shared";
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
/**
* v1.18.0 — operator-side panel for server-wide module availability, the
* SECOND layer of the two-layer module model. A module turned off here is
* off for EVERY account regardless of personal preference — mirroring how
* the assistant master flag sits above the per-user coach opt-out.
*
* Core domains (weight, blood pressure, pulse, medications) are NOT module
* keys and never appear here; the gate keeps them always-on structurally.
*
* UX mirrors the assistant-flags matrix: one toggle per module, calm and
* neutral, optimistic flip + server confirm via `useMutation`, with a
* failure toast that reverts by re-invalidating the read query.
*/
type AvailabilityMap = Record<ModuleKey, boolean>;
interface ModuleAvailabilityResponse {
availability: AvailabilityMap;
}
function useModuleAvailability() {
return useQuery({
queryKey: queryKeys.adminModuleAvailability(),
queryFn: async () => {
return apiGet<ModuleAvailabilityResponse>(
"/api/admin/settings/module-availability",
);
},
});
}
function useUpdateModuleAvailability() {
const client = useQueryClient();
const { t } = useTranslations();
return useMutation({
mutationFn: async (
patch: Partial<AvailabilityMap>,
): Promise<ModuleAvailabilityResponse> => {
return apiPatch<ModuleAvailabilityResponse>(
"/api/admin/settings/module-availability",
patch,
);
},
onSuccess: (data) => {
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.
void invalidateKeys(client, aiInputDependentKeys);
toast.success(t("common.saved"));
},
onError: (err) => {
toast.error(
err instanceof Error && err.message
? err.message
: t("admin.modules.saveError"),
);
client.invalidateQueries({
queryKey: queryKeys.adminModuleAvailability(),
});
},
});
}
export function ModuleAvailabilitySection() {
const { t } = useTranslations();
const { data } = useModuleAvailability();
const mutation = useUpdateModuleAvailability();
const availability = data?.availability;
return (
<SettingsCard>
<SettingsCardHeader
icon={ToggleLeft}
title={t("admin.modules.title")}
description={t("admin.modules.description")}
/>
<p className="text-sm">{t("admin.modules.detail")}</p>
<div className="space-y-4">
{MODULE_KEYS
// A module switched off in code (pending a rebuild) is hard-off
// server-wide; drop its row so the operator toggle can't mislead.
.filter((key) => !isCodeDisabledModule(key))
.map((key) => {
const def = MODULE_REGISTRY[key];
// Default-available until the read resolves; an explicit `false`
// from the operator layer is the only thing that turns it off.
const available = availability?.[key] ?? true;
// The Coach has one operator switch, on the assistant panel. The
// row stays so the operator sees its state here, read-only, with
// the way to the switch that decides it.
if (SWITCH_OWNED_MODULE_KEYS.includes(key)) {
return (
<div
key={key}
className="space-y-1"
data-slot="module-availability-switch-owned"
>
<SettingsToggle
label={t(def.labelKey)}
description={t("admin.modules.coachFollowsSwitch")}
checked={available}
onCheckedChange={() => {}}
disabled
/>
<Link
href="/admin/coach"
className="text-primary text-xs underline-offset-4 hover:underline"
>
{t("admin.assistant.title")}
</Link>
</div>
);
}
return (
<SettingsToggle
key={key}
label={t(def.labelKey)}
description={t(def.descriptionKey)}
checked={available}
onCheckedChange={(checked) =>
mutation.mutate({
[key]: checked,
} as Partial<AvailabilityMap>)
}
disabled={mutation.isPending}
/>
);
})}
</div>
</SettingsCard>
);
}