mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
merge(reconcile): r1-modulehub audit findings
This commit is contained in:
commit
5e6e010e2f
14 files changed
+216
-36
No files matched your search
+3
-1
@@ -4120,7 +4120,9 @@
|
||||
"description": "Die Grundlagen von HealthLog. Diese Bereiche lassen sich nicht abschalten.",
|
||||
"note": "Kernbereich — immer aktiv"
|
||||
},
|
||||
"description": "Lege fest, was HealthLog für dich erfasst. Schalte einzelne Bereiche ein oder aus — die Navigation, Kacheln und Auswertungen passen sich sofort an."
|
||||
"description": "Lege fest, was HealthLog für dich erfasst. Schalte einzelne Bereiche ein oder aus — die Navigation, Kacheln und Auswertungen passen sich sofort an.",
|
||||
"manageIn": "In {section} verwalten",
|
||||
"operatorDisabled": "Serverweit deaktiviert"
|
||||
},
|
||||
"gesundheitsakte": {
|
||||
"title": "Gesundheitsakte",
|
||||
|
||||
+3
-1
@@ -4120,7 +4120,9 @@
|
||||
"description": "HealthLog's foundations. These areas can't be turned off.",
|
||||
"note": "Core area — always on"
|
||||
},
|
||||
"description": "Choose what HealthLog tracks for you. Turn individual areas on or off — the navigation, tiles, and insights adapt right away."
|
||||
"description": "Choose what HealthLog tracks for you. Turn individual areas on or off — the navigation, tiles, and insights adapt right away.",
|
||||
"manageIn": "Manage in {section}",
|
||||
"operatorDisabled": "Disabled server-wide"
|
||||
},
|
||||
"gesundheitsakte": {
|
||||
"title": "Health record",
|
||||
|
||||
+3
-1
@@ -4120,7 +4120,9 @@
|
||||
"description": "Los pilares de HealthLog. Estas áreas no se pueden desactivar.",
|
||||
"note": "Área esencial — siempre activa"
|
||||
},
|
||||
"description": "Elige qué registra HealthLog por ti. Activa o desactiva áreas concretas: la navegación, los paneles y los análisis se adaptan al instante."
|
||||
"description": "Elige qué registra HealthLog por ti. Activa o desactiva áreas concretas: la navegación, los paneles y los análisis se adaptan al instante.",
|
||||
"manageIn": "Gestionar en {section}",
|
||||
"operatorDisabled": "Desactivado en todo el servidor"
|
||||
},
|
||||
"gesundheitsakte": {
|
||||
"title": "Historial médico",
|
||||
|
||||
+3
-1
@@ -4120,7 +4120,9 @@
|
||||
"description": "Les fondations de HealthLog. Ces domaines ne peuvent pas être désactivés.",
|
||||
"note": "Domaine essentiel — toujours actif"
|
||||
},
|
||||
"description": "Choisissez ce que HealthLog suit pour vous. Activez ou désactivez chaque domaine : la navigation, les tuiles et les analyses s'adaptent aussitôt."
|
||||
"description": "Choisissez ce que HealthLog suit pour vous. Activez ou désactivez chaque domaine : la navigation, les tuiles et les analyses s'adaptent aussitôt.",
|
||||
"manageIn": "Gérer dans {section}",
|
||||
"operatorDisabled": "Désactivé sur tout le serveur"
|
||||
},
|
||||
"gesundheitsakte": {
|
||||
"title": "Dossier médical",
|
||||
|
||||
+3
-1
@@ -4120,7 +4120,9 @@
|
||||
"description": "Le fondamenta di HealthLog. Questi ambiti non possono essere disattivati.",
|
||||
"note": "Ambito di base — sempre attivo"
|
||||
},
|
||||
"description": "Scegli cosa registra HealthLog per te. Attiva o disattiva i singoli ambiti: navigazione, riquadri e analisi si adattano subito."
|
||||
"description": "Scegli cosa registra HealthLog per te. Attiva o disattiva i singoli ambiti: navigazione, riquadri e analisi si adattano subito.",
|
||||
"manageIn": "Gestisci in {section}",
|
||||
"operatorDisabled": "Disattivato a livello di server"
|
||||
},
|
||||
"gesundheitsakte": {
|
||||
"title": "Cartella clinica",
|
||||
|
||||
+3
-1
@@ -4120,7 +4120,9 @@
|
||||
"description": "Podstawy HealthLog. Tych obszarów nie można wyłączyć.",
|
||||
"note": "Obszar podstawowy — zawsze aktywny"
|
||||
},
|
||||
"description": "Wybierz, co HealthLog ma dla Ciebie śledzić. Włączaj i wyłączaj poszczególne obszary — nawigacja, kafelki i analizy dostosują się od razu."
|
||||
"description": "Wybierz, co HealthLog ma dla Ciebie śledzić. Włączaj i wyłączaj poszczególne obszary — nawigacja, kafelki i analizy dostosują się od razu.",
|
||||
"manageIn": "Zarządzaj w {section}",
|
||||
"operatorDisabled": "Wyłączone na całym serwerze"
|
||||
},
|
||||
"gesundheitsakte": {
|
||||
"title": "Dokumentacja zdrowotna",
|
||||
|
||||
@@ -11,15 +11,18 @@
|
||||
* returns the freshly-resolved module map.
|
||||
*
|
||||
* The body schema (`modulePrefsPatchSchema`) is `strict()` over the
|
||||
* canonical toggleable key set, so a core-domain key (`weight`,
|
||||
* `bloodPressure`, `pulse`, `medications`) or any unknown key is a 422 —
|
||||
* the core measurement engine + meds can never be disabled here.
|
||||
* directly-owned toggleable key set, so a core-domain key (`weight`,
|
||||
* `bloodPressure`, `pulse`, `medications`), a delegated key (`cycle`,
|
||||
* `coach`), or any unknown key is a 422 — the core measurement engine +
|
||||
* meds can never be disabled here, and a value for a delegated module can
|
||||
* never land inert in `modulePreferencesJson`. The delegated modules are
|
||||
* managed at their real control (cycle in Account, coach in Settings →
|
||||
* Coach); the Modules hub deep-links there rather than offering a dead
|
||||
* toggle.
|
||||
*
|
||||
* `userId` is always narrowed from `requireAuth()`; the body never
|
||||
* carries it. The semantics are a DISABLED allowlist: a key set to
|
||||
* `false` disables that module; `true` (or absence) leaves it enabled.
|
||||
* cycle/coach keys are accepted for forward-compat but the gate ignores
|
||||
* the blob for them (they delegate to their existing source of truth).
|
||||
*/
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import {
|
||||
|
||||
@@ -6,7 +6,10 @@ import { buildAvatarUrl } from "@/lib/avatar";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import {
|
||||
resolveModuleMap,
|
||||
getOperatorModuleAvailability,
|
||||
} from "@/lib/modules/gate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -22,7 +25,7 @@ export const GET = apiHandler(async () => {
|
||||
// cycle-tracking gate; no row is forced, a NULL toggle derives from
|
||||
// gender) is a Postgres round-trip. Running them sequentially added
|
||||
// the cookie hop to every /me — and /me sits on every app boot path.
|
||||
const [, cycleProfile, modules] = await Promise.all([
|
||||
const [, cycleProfile, modules, moduleAvailability] = await Promise.all([
|
||||
setOnboardingPendingCookie(user.onboardingCompletedAt == null),
|
||||
prisma.cycleProfile.findUnique({
|
||||
where: { userId: user.id },
|
||||
@@ -34,6 +37,12 @@ export const GET = apiHandler(async () => {
|
||||
// disabled-allowlist `modulePreferencesJson`. Default-on. Clients
|
||||
// hide a whole module surface end-to-end when its key is `false`.
|
||||
resolveModuleMap(user.id),
|
||||
// v1.18.0 — operator-layer availability map (server-wide kill-switch).
|
||||
// The resolved `modules` map above already AND-s this in, so it cannot
|
||||
// distinguish operator-off from user-off. The Modules hub needs that
|
||||
// distinction to render an operator-disabled module as a read-only
|
||||
// "disabled server-wide" row instead of a live toggle that no-ops.
|
||||
getOperatorModuleAvailability(),
|
||||
]);
|
||||
const cycleTrackingEnabled = isCycleEnabled(user.gender, cycleProfile);
|
||||
|
||||
@@ -99,5 +108,11 @@ export const GET = apiHandler(async () => {
|
||||
// resolved `disableCoach` + operator master flag, so this map is the
|
||||
// single thing a client needs to gate every secondary domain.
|
||||
modules,
|
||||
// v1.18.0 — operator-layer availability per toggleable module. `false`
|
||||
// ⇒ the operator turned the module off server-wide (off for every
|
||||
// account regardless of personal preference). The Modules hub reads
|
||||
// this to show a "disabled server-wide" read-only row; everywhere else
|
||||
// the already-AND-ed `modules` map is the single gate to read.
|
||||
moduleAvailability,
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,11 @@ 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, CORE_DOMAIN_KEYS } from "@/lib/modules/registry";
|
||||
import {
|
||||
MODULE_KEYS,
|
||||
CORE_DOMAIN_KEYS,
|
||||
moduleDelegatesTo,
|
||||
} 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
|
||||
@@ -104,12 +108,22 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("<ModulesSection>", () => {
|
||||
it("renders a Switch for every toggleable + core module", () => {
|
||||
it("renders a Switch for every non-delegated toggleable module, a managed deep-link for delegated ones, and locked switches for core", () => {
|
||||
const html = render();
|
||||
for (const key of MODULE_KEYS) {
|
||||
expect(html, `toggleable switch ${key}`).toContain(
|
||||
`id="module-toggle-${key}"`,
|
||||
);
|
||||
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 {
|
||||
expect(html, `toggleable switch ${key}`).toContain(
|
||||
`id="module-toggle-${key}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const key of CORE_DOMAIN_KEYS) {
|
||||
expect(html, `core switch ${key}`).toContain(`id="module-toggle-${key}"`);
|
||||
|
||||
@@ -9,14 +9,25 @@
|
||||
* row is presentation-only — the parent `<ModulesSection>` owns the mutation
|
||||
* and cache invalidation, so this component stays a pure, easily-tested leaf.
|
||||
*
|
||||
* Two modes:
|
||||
* Four modes:
|
||||
* - toggleable: the Switch is live; flipping it calls `onToggle(next)`.
|
||||
* - locked (core domains): the Switch is checked + disabled with a short
|
||||
* "always on" note, so the always-on measurement engine + meds read as
|
||||
* deliberately fixed rather than broken. Palette stays neutral — no
|
||||
* alarming green-when-on / red-when-off tint.
|
||||
* 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.
|
||||
*
|
||||
* Palette stays neutral throughout — no alarming green-when-on /
|
||||
* red-when-off tint.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -37,6 +48,21 @@ export interface ModuleToggleRowProps {
|
||||
locked?: boolean;
|
||||
/** 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.
|
||||
*/
|
||||
managedAt?: { href: string; label: string };
|
||||
/** Localised "Manage in {section}" link text (managed-elsewhere rows). */
|
||||
manageLinkLabel?: 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.
|
||||
*/
|
||||
operatorDisabled?: boolean;
|
||||
operatorNote?: 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). */
|
||||
@@ -51,10 +77,18 @@ export function ModuleToggleRow({
|
||||
enabled,
|
||||
locked = false,
|
||||
lockedNote,
|
||||
managedAt,
|
||||
manageLinkLabel,
|
||||
operatorDisabled = false,
|
||||
operatorNote,
|
||||
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;
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 py-3">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
@@ -63,23 +97,44 @@ export function ModuleToggleRow({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<Label htmlFor={inputId} className="text-sm font-medium">
|
||||
{label}
|
||||
</Label>
|
||||
{managed ? (
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
) : (
|
||||
<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/80 text-xs">{lockedNote}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<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}
|
||||
/>
|
||||
{operatorDisabled ? (
|
||||
// Operator kill-switch: read-only note, no toggle.
|
||||
<span className="text-muted-foreground/80 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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -52,6 +52,7 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
CORE_DOMAIN_KEYS,
|
||||
MODULE_REGISTRY,
|
||||
moduleDelegatesTo,
|
||||
type CoreDomainKey,
|
||||
type ModuleKey,
|
||||
} from "@/lib/modules/registry";
|
||||
@@ -85,12 +86,16 @@ export function ModulesSection() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const modules = user?.modules ?? {};
|
||||
const moduleAvailability = user?.moduleAvailability ?? {};
|
||||
|
||||
const toggle = useMutation({
|
||||
// Factory-routed; the in-repo eslint rule forbids a bare literal here.
|
||||
mutationKey: queryKeys.modulesPrefs(),
|
||||
mutationFn: async (vars: { key: ModuleKey; enabled: boolean }) => {
|
||||
// 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: () => {
|
||||
@@ -131,6 +136,14 @@ export function ModulesSection() {
|
||||
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;
|
||||
return (
|
||||
<ModuleToggleRow
|
||||
key={key}
|
||||
@@ -140,6 +153,23 @@ 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")}
|
||||
onToggle={(next) => toggle.mutate({ key, enabled: next })}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -95,6 +95,17 @@ export interface AuthUser {
|
||||
* absent), so live code reads a real map.
|
||||
*/
|
||||
modules?: Partial<Record<ModuleKey, boolean>>;
|
||||
/**
|
||||
* v1.18.0 — operator-layer availability per toggleable module. `false`
|
||||
* ⇒ the operator disabled the module server-wide (off for every account,
|
||||
* regardless of personal preference). Distinct from `modules`, which is
|
||||
* the already-AND-ed effective state and cannot tell operator-off from
|
||||
* user-off apart. Only the Modules hub needs this distinction (to render
|
||||
* an operator-disabled module as a read-only "disabled server-wide" row);
|
||||
* every other gate reads `modules`. Coerced to `{}` against a stale /me
|
||||
* payload so a missing map reads as all-available.
|
||||
*/
|
||||
moduleAvailability?: Partial<Record<ModuleKey, boolean>>;
|
||||
}
|
||||
|
||||
async function fetchMe(): Promise<AuthUser> {
|
||||
@@ -137,6 +148,13 @@ async function fetchMe(): Promise<AuthUser> {
|
||||
// open: a missing key reads as enabled and the nav stays intact.
|
||||
modules:
|
||||
data.modules && typeof data.modules === "object" ? data.modules : {},
|
||||
// v1.18.0 — operator availability map, coerced against a stale payload
|
||||
// to an empty map (all-available) so the Modules hub never shows a
|
||||
// spurious "disabled server-wide" row on an older server image.
|
||||
moduleAvailability:
|
||||
data.moduleAvailability && typeof data.moduleAvailability === "object"
|
||||
? data.moduleAvailability
|
||||
: {},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,13 @@ export interface ModuleDefinition {
|
||||
* there is exactly one source of truth. See `ModuleDelegation`.
|
||||
*/
|
||||
delegatesTo?: ModuleDelegation;
|
||||
/**
|
||||
* 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").
|
||||
*/
|
||||
managedAt?: { href: string; labelKey: string };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,6 +145,11 @@ 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.
|
||||
managedAt: {
|
||||
href: "/settings/account#cycle-tracking",
|
||||
labelKey: "settings.sections.account.title",
|
||||
},
|
||||
},
|
||||
mood: {
|
||||
key: "mood",
|
||||
@@ -187,6 +199,11 @@ 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.
|
||||
managedAt: {
|
||||
href: "/settings/coach",
|
||||
labelKey: "settings.sections.coach.title",
|
||||
},
|
||||
},
|
||||
insights: {
|
||||
key: "insights",
|
||||
|
||||
@@ -11,17 +11,33 @@
|
||||
* `strict()` rejects unknown keys outright rather than silently dropping
|
||||
* them, so a client gets a clear error instead of a no-op.
|
||||
*
|
||||
* Delegated keys (`cycle`, `coach`) are NOT accepted here: their
|
||||
* enabled-state is owned elsewhere (the cycle gate / `disableCoach` + the
|
||||
* operator assistant flag), so a value for them would land inert in
|
||||
* `modulePreferencesJson` and mislead — the Modules hub renders them as
|
||||
* read-only "managed in X" rows that deep-link to the real control. Omitting
|
||||
* them from the schema means a stale client sending `{ coach: false }` gets
|
||||
* a clear 422 instead of a silent no-op behind a green "saved" toast.
|
||||
*
|
||||
* The shared shape is reused by the OpenAPI registry so the wire
|
||||
* contract stays single-source.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { MODULE_KEYS } from "@/lib/modules/registry";
|
||||
import { MODULE_KEYS, moduleDelegatesTo } from "@/lib/modules/registry";
|
||||
|
||||
/** Build `{ <toggleableKey>?: boolean }` from the canonical key list. */
|
||||
/**
|
||||
* The directly-owned (non-delegated) toggleable keys — the only keys this
|
||||
* PATCH surface may write into `modulePreferencesJson`.
|
||||
*/
|
||||
const WRITABLE_MODULE_KEYS = MODULE_KEYS.filter(
|
||||
(k) => moduleDelegatesTo(k) === undefined,
|
||||
);
|
||||
|
||||
/** Build `{ <writableKey>?: boolean }` from the directly-owned key list. */
|
||||
const moduleShape = Object.fromEntries(
|
||||
MODULE_KEYS.map((k) => [k, z.boolean().optional()]),
|
||||
) as Record<(typeof MODULE_KEYS)[number], z.ZodOptional<z.ZodBoolean>>;
|
||||
WRITABLE_MODULE_KEYS.map((k) => [k, z.boolean().optional()]),
|
||||
) as Record<(typeof WRITABLE_MODULE_KEYS)[number], z.ZodOptional<z.ZodBoolean>>;
|
||||
|
||||
/**
|
||||
* Partial module-preferences PATCH body. Every key is an optional
|
||||
|
||||
Reference in new issue
Block a user