mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(modules): direct on/off switches for the delegated Coach + Cycle rows
The Modules hub listed Coach and Cycle as read-only "manage in X" deep-links because their enabled-state is owned outside `modulePreferencesJson` (Coach in Coach settings, Cycle in the Account card). Give both a real switch like every other module, driving the SAME canonical state instead of a second copy: - Coach → `PATCH /api/auth/me/disable-coach` (`User.disableCoach`, inverted); the switch reads `!disableCoach`. - Cycle → `PATCH /api/auth/me/cycle-prefs` (`cycleTrackingEnabled`); the switch reads the resolved per-user gate. Precedence stays honest. A module the operator turned off server-wide can never be re-enabled by a per-user toggle, so the switch renders disabled with a "disabled server-wide" hint. For Coach the operator layer is BOTH the module-availability blob AND the assistant master flag (`flags.coach`); for Cycle it is the module-availability blob. Each delegated row keeps a small "manage" deep-link beside its switch, since the canonical surface carries more than on/off (Coach cadence/memory; cycle goal, predictions, lengths). Flipping either switch invalidates `authMe()` so every gated surface re-gates live; the cycle flip also evicts its own `cyclePrefs`/`cycle` reads, matching the canonical cycle card. Reuses the existing endpoints — no new route, no OpenAPI change.
This commit is contained in:
1 parent
421c407811
commit
8bc2f0f2ef
6 files changed
+337
-120
No files matched your search
@@ -5,7 +5,10 @@
|
||||
* to the resolved module map (`useAuth().user.modules`), plus a read-only
|
||||
* "always on" group for the four core domains. Flipping a toggle PATCHes
|
||||
* `/api/auth/me/modules` (a DISABLED allowlist) and invalidates the
|
||||
* `authMe()` query so nav / pills / tiles re-gate live.
|
||||
* `authMe()` query so nav / pills / tiles re-gate live. The two delegated
|
||||
* modules (coach, cycle) render a live switch too, but their flip drives the
|
||||
* canonical column (`disableCoach` / `cycleTrackingEnabled`) via the existing
|
||||
* per-column endpoints — never the module-allowlist the gate ignores for them.
|
||||
*
|
||||
* Test strategy mirrors `advanced-research-mode.test.tsx`: mock
|
||||
* `@tanstack/react-query` so the rendered tree executes under SSR
|
||||
@@ -20,18 +23,14 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { AuthUser } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
MODULE_KEYS,
|
||||
isCodeDisabledModule,
|
||||
moduleDelegatesTo,
|
||||
} from "@/lib/modules/registry";
|
||||
import { MODULE_KEYS, isCodeDisabledModule } from "@/lib/modules/registry";
|
||||
|
||||
// Capture the latest useMutation config + a shared mutate spy so we can
|
||||
// invoke `mutationFn` / `onSuccess` by hand (the SSR pass can't fire a
|
||||
// real DOM click).
|
||||
type MutationConfig = {
|
||||
mutationFn?: (vars: unknown) => Promise<unknown>;
|
||||
onSuccess?: (data?: unknown) => void;
|
||||
onSuccess?: (data?: unknown, variables?: unknown) => void;
|
||||
onError?: (err?: unknown) => void;
|
||||
};
|
||||
let lastMutation: MutationConfig | null = null;
|
||||
@@ -57,7 +56,28 @@ vi.mock("@/hooks/use-auth", async () => {
|
||||
return { ...actual, useAuth: () => authSpy() };
|
||||
});
|
||||
|
||||
function buildUser(modules: AuthUser["modules"]): AuthUser {
|
||||
// The hub now renders a live coach switch, so it reads the operator assistant
|
||||
// flag matrix (`flags.coach`) to decide whether the operator killed Coach
|
||||
// server-wide. Mock the hook directly so the SSR pass doesn't need a
|
||||
// `<QueryClientProvider>`; default all-on.
|
||||
import type { AssistantFlagSet } from "@/hooks/use-feature-flags";
|
||||
const ALL_ON_FLAGS: AssistantFlagSet = {
|
||||
enabled: true,
|
||||
coach: true,
|
||||
briefing: true,
|
||||
insightStatus: true,
|
||||
correlations: true,
|
||||
healthScoreExplainer: true,
|
||||
};
|
||||
const flagsSpy = vi.fn<() => AssistantFlagSet>(() => ALL_ON_FLAGS);
|
||||
vi.mock("@/hooks/use-feature-flags", () => ({
|
||||
useFeatureFlags: () => flagsSpy(),
|
||||
}));
|
||||
|
||||
function buildUser(
|
||||
modules: AuthUser["modules"],
|
||||
overrides: Partial<AuthUser> = {},
|
||||
): AuthUser {
|
||||
return {
|
||||
id: "user-1",
|
||||
username: "testuser",
|
||||
@@ -82,6 +102,8 @@ function buildUser(modules: AuthUser["modules"]): AuthUser {
|
||||
insuranceNumber: null,
|
||||
cycleTrackingEnabled: false,
|
||||
modules,
|
||||
moduleAvailability: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,6 +125,7 @@ beforeEach(() => {
|
||||
user: buildUser({}),
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
flagsSpy.mockImplementation(() => ALL_ON_FLAGS);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -110,7 +133,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("<ModulesSection>", () => {
|
||||
it("renders a Switch for every non-delegated toggleable module and a managed deep-link for delegated ones", () => {
|
||||
it("renders a live Switch for every toggleable module, including the delegated ones", () => {
|
||||
// v1.18.6 (W9) — the "Always on" core-domains card was removed (a domain
|
||||
// that can't be turned off doesn't need listing), so this section now
|
||||
// only renders the toggleable modules.
|
||||
@@ -122,15 +145,9 @@ describe("<ModulesSection>", () => {
|
||||
expect(html, `code-disabled has no switch ${key}`).not.toContain(
|
||||
`id="module-toggle-${key}"`,
|
||||
);
|
||||
} else if (moduleDelegatesTo(key) !== undefined) {
|
||||
// Delegated modules (cycle/coach) are owned by their real control
|
||||
// elsewhere — they render as a read-only "manage in X" deep-link,
|
||||
// never a live Switch that would write an inert disabled-allowlist
|
||||
// entry the gate ignores.
|
||||
expect(html, `delegated has no switch ${key}`).not.toContain(
|
||||
`id="module-toggle-${key}"`,
|
||||
);
|
||||
} else {
|
||||
// Delegated modules (coach, cycle) now render a real Switch too — it
|
||||
// drives their canonical column — alongside a "manage" deep-link.
|
||||
expect(html, `toggleable switch ${key}`).toContain(
|
||||
`id="module-toggle-${key}"`,
|
||||
);
|
||||
@@ -138,6 +155,13 @@ describe("<ModulesSection>", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a manage deep-link beside the delegated coach + cycle switches", () => {
|
||||
const html = render();
|
||||
// Coach → Coach settings; cycle → the Account cycle-tracking card.
|
||||
expect(html).toContain('href="/settings/coach"');
|
||||
expect(html).toContain('href="/settings/account#cycle-tracking"');
|
||||
});
|
||||
|
||||
it("no longer renders the 'Always on' core-domains card", () => {
|
||||
// v1.18.6 (W9) — the read-only core card (weight / BP / pulse) was
|
||||
// removed; the core domains are always active regardless.
|
||||
@@ -199,10 +223,141 @@ describe("<ModulesSection>", () => {
|
||||
render();
|
||||
expect(lastMutation?.onSuccess).toBeTypeOf("function");
|
||||
|
||||
lastMutation!.onSuccess!();
|
||||
// onSuccess reads `variables.key` to decide the extra evictions; a
|
||||
// non-delegated key only touches authMe.
|
||||
lastMutation!.onSuccess!(undefined, { key: "glucose", enabled: false });
|
||||
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: queryKeys.authMe(),
|
||||
});
|
||||
});
|
||||
|
||||
describe("delegated coach row", () => {
|
||||
it("reflects `!disableCoach`: checked when Coach is active, unchecked when hidden", () => {
|
||||
authSpy.mockImplementation(() => ({
|
||||
user: buildUser({}, { disableCoach: false }),
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
let coachTag = render().match(
|
||||
/<button[^>]*id="module-toggle-coach"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(coachTag).toMatch(/data-state="checked"/);
|
||||
|
||||
authSpy.mockImplementation(() => ({
|
||||
user: buildUser({}, { disableCoach: true }),
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
coachTag = render().match(
|
||||
/<button[^>]*id="module-toggle-coach"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(coachTag).toMatch(/data-state="unchecked"/);
|
||||
});
|
||||
|
||||
it("writes the inverted `disableCoach` to the canonical endpoint", async () => {
|
||||
render();
|
||||
const fetchSpy = vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ data: { disableCoach: true } }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
// Turning the hub switch OFF (enabled:false) writes disableCoach:true.
|
||||
await lastMutation!.mutationFn!({ key: "coach", enabled: false });
|
||||
|
||||
const [url, init] = fetchSpy.mock.calls[0] as unknown as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(url).toContain("/api/auth/me/disable-coach");
|
||||
expect(init.method).toBe("PATCH");
|
||||
expect(JSON.parse(init.body as string)).toEqual({ disableCoach: true });
|
||||
});
|
||||
|
||||
it("disables the switch + shows a hint when the operator killed Coach", () => {
|
||||
// Operator assistant master flag off → coach is unavailable server-wide.
|
||||
flagsSpy.mockImplementation(() => ({ ...ALL_ON_FLAGS, coach: false }));
|
||||
const html = render();
|
||||
const coachTag = html.match(
|
||||
/<button[^>]*id="module-toggle-coach"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(coachTag).toMatch(/disabled/);
|
||||
expect(html).toContain("Disabled server-wide");
|
||||
});
|
||||
|
||||
it("also disables the switch when the module-availability blob kills Coach", () => {
|
||||
authSpy.mockImplementation(() => ({
|
||||
user: buildUser({}, { moduleAvailability: { coach: false } }),
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
const coachTag = render().match(
|
||||
/<button[^>]*id="module-toggle-coach"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(coachTag).toMatch(/disabled/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("delegated cycle row", () => {
|
||||
it("reflects the resolved `cycleTrackingEnabled`", () => {
|
||||
authSpy.mockImplementation(() => ({
|
||||
user: buildUser({}, { cycleTrackingEnabled: true }),
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
const cycleTag = render().match(
|
||||
/<button[^>]*id="module-toggle-cycle"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(cycleTag).toMatch(/data-state="checked"/);
|
||||
});
|
||||
|
||||
it("writes `{ enabled }` to the cycle-prefs endpoint", async () => {
|
||||
render();
|
||||
const fetchSpy = vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ data: { enabled: true } }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
await lastMutation!.mutationFn!({ key: "cycle", enabled: true });
|
||||
|
||||
const [url, init] = fetchSpy.mock.calls[0] as unknown as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(url).toContain("/api/auth/me/cycle-prefs");
|
||||
expect(init.method).toBe("PATCH");
|
||||
expect(JSON.parse(init.body as string)).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it("evicts cyclePrefs + cycle + authMe on a successful cycle toggle", () => {
|
||||
render();
|
||||
lastMutation!.onSuccess!(undefined, { key: "cycle", enabled: true });
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: queryKeys.authMe(),
|
||||
});
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: queryKeys.cyclePrefs(),
|
||||
});
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: queryKeys.cycle(),
|
||||
});
|
||||
});
|
||||
|
||||
it("disables the switch + shows a hint when the operator killed cycle", () => {
|
||||
authSpy.mockImplementation(() => ({
|
||||
user: buildUser({}, { moduleAvailability: { cycle: false } }),
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
const html = render();
|
||||
const cycleTag = html.match(
|
||||
/<button[^>]*id="module-toggle-cycle"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(cycleTag).toMatch(/disabled/);
|
||||
expect(html).toContain("Disabled server-wide");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,18 +9,22 @@
|
||||
* row is presentation-only — the parent `<ModulesSection>` owns the mutation
|
||||
* and cache invalidation, so this component stays a pure, easily-tested leaf.
|
||||
*
|
||||
* Four modes:
|
||||
* Every module — including the two delegated ones (coach, cycle) — now
|
||||
* renders a real Switch, so the hub reads uniformly. Four shapes ride on top
|
||||
* of that Switch:
|
||||
* - toggleable: the Switch is live; flipping it calls `onToggle(next)`.
|
||||
* - delegated (coach, cycle): the Switch is live and drives the canonical
|
||||
* state (coach → `User.disableCoach`; cycle → `cycleTrackingEnabled`),
|
||||
* plus a small `manageLink` deep-link beneath the description points at
|
||||
* the fuller settings surface (Coach cadence, cycle goal/predictions).
|
||||
* - locked (core domains): the Switch is checked + disabled with a short
|
||||
* "always on" note, so the always-on measurement engine + meds read as
|
||||
* "always on" note, so the always-on measurement engine reads as
|
||||
* deliberately fixed rather than broken.
|
||||
* - managed elsewhere (delegated modules cycle/coach): no Switch at all —
|
||||
* the real on/off lives in another section, so the row renders a
|
||||
* read-only deep-link ("Manage in X") rather than a dead toggle that
|
||||
* no-ops and snaps back behind a misleading "saved" toast.
|
||||
* - operator-disabled: the operator turned the module off server-wide, so
|
||||
* the Switch is replaced by a read-only "disabled server-wide" note —
|
||||
* a per-user toggle could not re-enable it anyway.
|
||||
* - disabled-with-reason: the operator turned the module off server-wide
|
||||
* (or the module is otherwise not applicable). The Switch is disabled and
|
||||
* a `disabledReason` hint renders beneath the description — an honest
|
||||
* "you can't flip this and here's why", never a toggle that no-ops and
|
||||
* snaps back behind a misleading "saved" toast.
|
||||
*
|
||||
* Palette stays neutral throughout — no alarming green-when-on /
|
||||
* red-when-off tint.
|
||||
@@ -49,20 +53,19 @@ export interface ModuleToggleRowProps {
|
||||
/** One-line "core / always on" note, only shown when `locked`. */
|
||||
lockedNote?: string;
|
||||
/**
|
||||
* Delegated modules (cycle/coach): the real control lives elsewhere. When
|
||||
* set, the row renders a read-only deep-link instead of a Switch.
|
||||
* `label` is the destination name; `href` the in-app link.
|
||||
* Delegated modules (coach, cycle) keep a small deep-link to the fuller
|
||||
* settings surface beside their live Switch — Coach cadence/memory, the
|
||||
* cycle goal/prediction/length fields. `label` is the link text; `href`
|
||||
* the in-app destination.
|
||||
*/
|
||||
managedAt?: { href: string; label: string };
|
||||
/** Localised "Manage in {section}" link text (managed-elsewhere rows). */
|
||||
manageLinkLabel?: string;
|
||||
manageLink?: { href: string; label: string };
|
||||
/**
|
||||
* Operator turned this module off server-wide. Replaces the Switch with a
|
||||
* read-only note; a per-user toggle cannot re-enable it. `operatorNote`
|
||||
* is the localised "disabled server-wide" copy.
|
||||
* When set the Switch is disabled and this line renders beneath the
|
||||
* description as an honest "why you can't flip this" hint. Used when the
|
||||
* operator turned the module off server-wide (a per-user toggle cannot
|
||||
* re-enable it) or the module is otherwise not applicable to the account.
|
||||
*/
|
||||
operatorDisabled?: boolean;
|
||||
operatorNote?: string;
|
||||
disabledReason?: string;
|
||||
/** Disable the Switch while a mutation is in flight (toggleable rows). */
|
||||
pending?: boolean;
|
||||
/** Fired with the next desired state on a user toggle (toggleable rows). */
|
||||
@@ -77,18 +80,16 @@ export function ModuleToggleRow({
|
||||
enabled,
|
||||
locked = false,
|
||||
lockedNote,
|
||||
managedAt,
|
||||
manageLinkLabel,
|
||||
operatorDisabled = false,
|
||||
operatorNote,
|
||||
manageLink,
|
||||
disabledReason,
|
||||
pending = false,
|
||||
onToggle,
|
||||
}: ModuleToggleRowProps) {
|
||||
const inputId = `module-toggle-${moduleKey}`;
|
||||
// A delegated module whose real control lives elsewhere, or an
|
||||
// operator-disabled module: either way no live Switch — the per-user
|
||||
// toggle would be a dead no-op.
|
||||
const managed = managedAt != null;
|
||||
// The Switch is inert when the domain is core (locked), when the operator
|
||||
// turned it off / it is inapplicable (disabledReason), or while a mutation
|
||||
// is in flight.
|
||||
const interactive = !locked && disabledReason == null;
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 py-3">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
@@ -97,44 +98,35 @@ export function ModuleToggleRow({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
{managed ? (
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
) : (
|
||||
<Label htmlFor={inputId} className="text-sm font-medium">
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
<Label htmlFor={inputId} className="text-sm font-medium">
|
||||
{label}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">{description}</p>
|
||||
{locked && lockedNote ? (
|
||||
<p className="text-muted-foreground text-xs">{lockedNote}</p>
|
||||
) : null}
|
||||
{disabledReason ? (
|
||||
<p className="text-muted-foreground text-xs">{disabledReason}</p>
|
||||
) : null}
|
||||
{manageLink ? (
|
||||
<Link
|
||||
href={manageLink.href}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
|
||||
>
|
||||
{manageLink.label}
|
||||
<ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{operatorDisabled ? (
|
||||
// Operator kill-switch: read-only note, no toggle.
|
||||
<span className="text-muted-foreground mt-0.5 shrink-0 text-xs">
|
||||
{operatorNote}
|
||||
</span>
|
||||
) : managed ? (
|
||||
// Delegated module: deep-link to the real control instead of a
|
||||
// dead toggle.
|
||||
<Link
|
||||
href={managedAt.href}
|
||||
className="text-muted-foreground hover:text-foreground mt-0.5 inline-flex shrink-0 items-center gap-1 text-xs underline-offset-2 hover:underline"
|
||||
>
|
||||
{manageLinkLabel}
|
||||
<ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
) : (
|
||||
<Switch
|
||||
id={inputId}
|
||||
checked={locked ? true : enabled}
|
||||
disabled={locked || pending}
|
||||
onCheckedChange={locked ? undefined : (v) => onToggle?.(v)}
|
||||
className="mt-0.5 shrink-0"
|
||||
aria-label={label}
|
||||
/>
|
||||
)}
|
||||
<Switch
|
||||
id={inputId}
|
||||
checked={locked ? true : enabled}
|
||||
disabled={!interactive || pending}
|
||||
onCheckedChange={interactive ? (v) => onToggle?.(v) : undefined}
|
||||
className="mt-0.5 shrink-0"
|
||||
aria-label={label}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,10 +6,21 @@
|
||||
* The one place a user decides which secondary domains HealthLog surfaces.
|
||||
* Each toggleable module (mood, sleep, glucose, workouts, recovery, labs,
|
||||
* achievements, coach, insights, doctor report, cycle) gets a row with an
|
||||
* icon, label, one-line description, and a `<Switch>`. Flipping a switch
|
||||
* PATCHes `/api/auth/me/modules` (a DISABLED allowlist; the endpoint refuses
|
||||
* core keys) and then invalidates the `authMe()` query so the nav, Insights
|
||||
* pills, and dashboard tiles re-gate live off `useAuth().user.modules`.
|
||||
* icon, label, one-line description, and a live `<Switch>`. For most modules
|
||||
* the switch PATCHes `/api/auth/me/modules` (a DISABLED allowlist; the
|
||||
* endpoint refuses core keys). The two delegated modules drive their
|
||||
* canonical column instead — coach → `PATCH /api/auth/me/disable-coach`
|
||||
* (`User.disableCoach`, inverted), cycle → `PATCH /api/auth/me/cycle-prefs`
|
||||
* (`cycleTrackingEnabled`) — so there is exactly one source of truth, and
|
||||
* keep a small "manage" deep-link to the fuller settings surface beside the
|
||||
* switch. Every path then invalidates `authMe()` (delegated cycle also evicts
|
||||
* its own reads) so the nav, Insights pills, and dashboard tiles re-gate live
|
||||
* off `useAuth().user.modules`.
|
||||
*
|
||||
* Operator precedence stays honest: a module the operator turned off
|
||||
* server-wide (the module-availability blob, plus the assistant master flag
|
||||
* `flags.coach` for the coach row) renders a disabled switch + a
|
||||
* "disabled server-wide" hint — a per-user toggle could not re-enable it.
|
||||
*
|
||||
* The three CORE domains (weight, blood pressure, pulse) render as a
|
||||
* separate read-only "always on" group — locked switches with a short note —
|
||||
@@ -50,6 +61,7 @@ import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { ModuleToggleRow } from "@/components/settings/module-toggle-row";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -89,6 +101,7 @@ const MODULE_ICONS: Record<ModuleKey, LucideIcon> = {
|
||||
export function ModulesSection() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const flags = useFeatureFlags();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const modules = user?.modules ?? {};
|
||||
@@ -98,16 +111,36 @@ export function ModulesSection() {
|
||||
// Factory-routed; the in-repo eslint rule forbids a bare literal here.
|
||||
mutationKey: queryKeys.modulesPrefs(),
|
||||
mutationFn: async (vars: { key: ModuleKey; enabled: boolean }) => {
|
||||
// The delegated modules keep a single source of truth: their switch
|
||||
// drives the canonical column, not the `modulePreferencesJson`
|
||||
// allowlist (the gate ignores the blob for these two keys). Reuse the
|
||||
// existing per-column endpoints rather than inventing a new one.
|
||||
const delegate = moduleDelegatesTo(vars.key);
|
||||
if (delegate === "coach") {
|
||||
// The stored column is `disableCoach` — the inverse of "on".
|
||||
return apiPatch("/api/auth/me/disable-coach", {
|
||||
disableCoach: !vars.enabled,
|
||||
});
|
||||
}
|
||||
if (delegate === "cycle") {
|
||||
// `enabled` maps straight onto `cycleTrackingEnabled`.
|
||||
return apiPatch("/api/auth/me/cycle-prefs", { enabled: vars.enabled });
|
||||
}
|
||||
// DISABLED allowlist: send only the single key the user flipped.
|
||||
// Delegated keys (cycle/coach) never reach here — they render as
|
||||
// read-only deep-link rows, so the PATCH only ever carries a
|
||||
// directly-owned key.
|
||||
return apiPatch("/api/auth/me/modules", { [vars.key]: vars.enabled });
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: (_data, vars) => {
|
||||
// Re-gate every surface that reads the module map off /auth/me: nav,
|
||||
// Insights pills, dashboard tiles, quick-add, search.
|
||||
// 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() });
|
||||
if (moduleDelegatesTo(vars.key) === "cycle") {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.cyclePrefs(),
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.cycle() });
|
||||
}
|
||||
toast.success(t("settings.sections.modules.saved"));
|
||||
},
|
||||
onError: () => {
|
||||
@@ -136,16 +169,49 @@ export function ModulesSection() {
|
||||
.filter((key) => !isCodeDisabledModule(key))
|
||||
.map((key) => {
|
||||
const def = MODULE_REGISTRY[key];
|
||||
// Default-on: a module is enabled unless explicitly `false`.
|
||||
const enabled = modules[key] !== false;
|
||||
// Delegated modules (cycle/coach) are owned elsewhere — never a
|
||||
// live toggle here; deep-link to their real control instead.
|
||||
const delegated = moduleDelegatesTo(key) !== undefined;
|
||||
// Operator turned this module off server-wide: no per-user
|
||||
// toggle can re-enable it, so show a read-only note. Delegated
|
||||
// rows already deep-link, so this only matters for owned keys.
|
||||
const operatorDisabled =
|
||||
!delegated && moduleAvailability[key] === false;
|
||||
const delegate = moduleDelegatesTo(key);
|
||||
|
||||
// Enabled-state per key. Delegated modules read their canonical
|
||||
// per-user state, NOT the `modulePreferencesJson` allowlist the
|
||||
// gate ignores for them: coach ← `!disableCoach`, cycle ← the
|
||||
// resolved `cycleTrackingEnabled`. Every other module is
|
||||
// default-on unless explicitly `false`.
|
||||
let enabled: boolean;
|
||||
if (delegate === "coach") {
|
||||
enabled = !(user?.disableCoach ?? false);
|
||||
} else if (delegate === "cycle") {
|
||||
enabled = user?.cycleTrackingEnabled ?? false;
|
||||
} else {
|
||||
enabled = modules[key] !== false;
|
||||
}
|
||||
|
||||
// Operator precedence. A per-user toggle can never re-enable a
|
||||
// module the operator turned off server-wide, so the switch goes
|
||||
// disabled + hint. For coach the operator layer is BOTH the
|
||||
// module-availability blob AND the assistant master flag
|
||||
// (`flags.coach`, already master-composed); for cycle and the
|
||||
// owned modules it is the module-availability blob alone.
|
||||
const operatorAvailable =
|
||||
delegate === "coach"
|
||||
? moduleAvailability[key] !== false && flags.coach
|
||||
: moduleAvailability[key] !== false;
|
||||
const disabledReason = operatorAvailable
|
||||
? undefined
|
||||
: t("settings.sections.modules.operatorDisabled");
|
||||
|
||||
// Delegated modules carry more than on/off at their canonical
|
||||
// surface (Coach cadence/memory; cycle goal/predictions/lengths),
|
||||
// so keep a small deep-link to it beside the live switch.
|
||||
const manageLink =
|
||||
delegate !== undefined && def.managedAt
|
||||
? {
|
||||
href: def.managedAt.href,
|
||||
label: t("settings.sections.modules.manageIn", {
|
||||
section: t(def.managedAt.labelKey),
|
||||
}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ModuleToggleRow
|
||||
key={key}
|
||||
@@ -155,23 +221,8 @@ export function ModulesSection() {
|
||||
description={t(def.descriptionKey)}
|
||||
enabled={enabled}
|
||||
pending={toggle.isPending}
|
||||
managedAt={
|
||||
delegated && def.managedAt
|
||||
? {
|
||||
href: def.managedAt.href,
|
||||
label: t(def.managedAt.labelKey),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
manageLinkLabel={
|
||||
delegated && def.managedAt
|
||||
? t("settings.sections.modules.manageIn", {
|
||||
section: t(def.managedAt.labelKey),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
operatorDisabled={operatorDisabled}
|
||||
operatorNote={t("settings.sections.modules.operatorDisabled")}
|
||||
manageLink={manageLink}
|
||||
disabledReason={disabledReason}
|
||||
onToggle={(next) => toggle.mutate({ key, enabled: next })}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -296,6 +296,13 @@ describe("Coach disable cascade invariant", () => {
|
||||
// the nudges. Server-side cron, not a render path — the cascade
|
||||
// fixture cannot mount it, so the allowlist entry is the pin.
|
||||
"src/lib/jobs/coach-nudge.ts",
|
||||
// The Modules hub renders a live Coach on/off switch. It reads
|
||||
// `flags.coach` (the operator assistant master flag) so the switch goes
|
||||
// disabled + "disabled server-wide" when the operator killed Coach — a
|
||||
// per-user toggle cannot re-enable it. Distinct from the render-path
|
||||
// gates above: here the flag drives the switch's disabled state, not a
|
||||
// null-return.
|
||||
"src/components/settings/modules-section.tsx",
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -335,6 +335,13 @@ describe("Coach per-user disableCoach invariant", () => {
|
||||
// second copy in `modulePreferencesJson`. Covered by the coach-
|
||||
// delegation cases in `src/lib/modules/__tests__/gate.test.ts`.
|
||||
"src/lib/modules/gate.ts",
|
||||
// The Modules hub Coach row renders a live switch bound to the SAME
|
||||
// `user.disableCoach` source of truth: `checked` is `!disableCoach` and a
|
||||
// flip writes the inverted flag via `PATCH /api/auth/me/disable-coach`.
|
||||
// Reads the flag off `useAuth().user` (no second copy). Covered by the
|
||||
// delegated-coach cases in
|
||||
// `src/components/settings/__tests__/modules-section.test.tsx`.
|
||||
"src/components/settings/modules-section.tsx",
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -79,10 +79,13 @@ export interface ModuleDefinition {
|
||||
*/
|
||||
optIn?: boolean;
|
||||
/**
|
||||
* For delegated modules only: where the real on/off control lives, so the
|
||||
* Modules hub can render a read-only "managed in X" row that deep-links to
|
||||
* the canonical control instead of a dead toggle. `href` is the in-app
|
||||
* link; `labelKey` names the destination ("Account", "Coach settings").
|
||||
* For delegated modules only: where the canonical settings surface lives.
|
||||
* The Modules hub renders a LIVE switch for these keys (driving the real
|
||||
* column — coach → `User.disableCoach`, cycle → `cycleTrackingEnabled`) and
|
||||
* keeps this as a small "manage" deep-link beside the switch, because that
|
||||
* surface carries more than on/off (Coach cadence/memory; cycle
|
||||
* goal/predictions/lengths). `href` is the in-app link; `labelKey` names the
|
||||
* destination ("Account", "Coach settings").
|
||||
*/
|
||||
managedAt?: { href: string; labelKey: string };
|
||||
}
|
||||
@@ -193,7 +196,8 @@ export const MODULE_REGISTRY: Readonly<Record<ModuleKey, ModuleDefinition>> =
|
||||
descriptionKey: "modules.cycle.description",
|
||||
category: "tracking",
|
||||
delegatesTo: "cycle",
|
||||
// The real on/off lives in the Account section's cycle-tracking card.
|
||||
// The hub switch drives `cycleTrackingEnabled`; the fuller cycle-tracking
|
||||
// card (goal, predictions, cycle lengths) lives in the Account section.
|
||||
managedAt: {
|
||||
href: "/settings/account#cycle-tracking",
|
||||
labelKey: "settings.sections.account.title",
|
||||
@@ -253,7 +257,8 @@ export const MODULE_REGISTRY: Readonly<Record<ModuleKey, ModuleDefinition>> =
|
||||
descriptionKey: "modules.coach.description",
|
||||
category: "intelligence",
|
||||
delegatesTo: "coach",
|
||||
// The real on/off lives in the dedicated Coach settings section.
|
||||
// The hub switch drives `User.disableCoach`; the dedicated Coach settings
|
||||
// section carries the rest (cadence, memory, activation copy).
|
||||
managedAt: {
|
||||
href: "/settings/coach",
|
||||
labelKey: "settings.sections.coach.title",
|
||||
|
||||
Reference in new issue
Block a user