mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(modules): add the canonical module registry and enforcement gate
registry.ts is the single source of truth for the toggleable secondary domains (cycle, mood, sleep, glucose, workouts, recovery, labs, achievements, coach, insights, doctorReport) plus the always-on core domain denylist. Each entry carries a stable key, label/description i18n keys, a category, and an optional delegatesTo marker. gate.ts is the one enforcement point: isModuleEnabled / requireModuleEnabled (403 module.disabled envelope) plus resolveModuleMap for the /api/auth/me projection. No double source of truth — cycle delegates to isCycleEnabled and coach delegates to disableCoach AND the operator assistant master flag; every other module resolves from the disabled allowlist. Default-on, fail-open per key, per-request memoised reads. insights gates only the AI-analysis surfaces (briefing, status cards, correlation narration, score explainer) — never the raw measurement charts, which are core and render regardless.
This commit is contained in:
1 parent
6116d92ff5
commit
d1089ba232
3 files changed
+605
No files matched your search
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* v1.18.0 — module enable/disable gate resolver.
|
||||
*
|
||||
* Exercises the PURE resolver (`resolveModuleEnabled`) + the registry
|
||||
* helpers, so the disabled-allowlist + delegation logic is asserted
|
||||
* without a DB. The async `isModuleEnabled` / `requireModuleEnabled`
|
||||
* thin wrappers are covered by the route test.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
resolveModuleEnabled,
|
||||
normalisePrefs,
|
||||
MODULE_DISABLED_ERROR_CODE,
|
||||
type ModuleGateInputs,
|
||||
} from "../gate";
|
||||
import {
|
||||
isCoreDomain,
|
||||
isModuleKey,
|
||||
moduleDelegatesTo,
|
||||
MODULE_KEYS,
|
||||
CORE_DOMAIN_KEYS,
|
||||
} from "../registry";
|
||||
|
||||
function inputs(over: Partial<ModuleGateInputs> = {}): ModuleGateInputs {
|
||||
return {
|
||||
gender: null,
|
||||
disableCoach: false,
|
||||
modulePreferences: {},
|
||||
cycleTrackingEnabled: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveModuleEnabled — disabled allowlist (default-on)", () => {
|
||||
it("enables a module when no preference is recorded", () => {
|
||||
expect(resolveModuleEnabled("mood", inputs(), true)).toBe(true);
|
||||
expect(resolveModuleEnabled("sleep", inputs(), true)).toBe(true);
|
||||
expect(resolveModuleEnabled("labs", inputs(), true)).toBe(true);
|
||||
});
|
||||
|
||||
it("enables a module when the key is present and true", () => {
|
||||
expect(
|
||||
resolveModuleEnabled("mood", inputs({ modulePreferences: { mood: true } }), true),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("disables a module ONLY on an explicit false", () => {
|
||||
expect(
|
||||
resolveModuleEnabled(
|
||||
"glucose",
|
||||
inputs({ modulePreferences: { glucose: false } }),
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("isolates per-module: disabling one leaves siblings on", () => {
|
||||
const i = inputs({ modulePreferences: { workouts: false } });
|
||||
expect(resolveModuleEnabled("workouts", i, true)).toBe(false);
|
||||
expect(resolveModuleEnabled("recovery", i, true)).toBe(true);
|
||||
expect(resolveModuleEnabled("labs", i, true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModuleEnabled — cycle delegation", () => {
|
||||
it("ignores the module blob and reads the cycle gate (gender-derived)", () => {
|
||||
// Even a crafted `{ cycle: false }` blob cannot override the real
|
||||
// FEMALE-derived enablement — the gate delegates to isCycleEnabled.
|
||||
const i = inputs({
|
||||
gender: "FEMALE",
|
||||
cycleTrackingEnabled: null,
|
||||
modulePreferences: { cycle: false },
|
||||
});
|
||||
expect(resolveModuleEnabled("cycle", i, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("respects the cycle toggle when set", () => {
|
||||
expect(
|
||||
resolveModuleEnabled(
|
||||
"cycle",
|
||||
inputs({ gender: "MALE", cycleTrackingEnabled: true }),
|
||||
true,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveModuleEnabled(
|
||||
"cycle",
|
||||
inputs({ gender: "FEMALE", cycleTrackingEnabled: false }),
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("a MALE account with a null toggle is cycle-disabled", () => {
|
||||
expect(
|
||||
resolveModuleEnabled("cycle", inputs({ gender: "MALE" }), true),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModuleEnabled — coach delegation (two-layer)", () => {
|
||||
it("is enabled only when the operator master flag AND !disableCoach", () => {
|
||||
expect(
|
||||
resolveModuleEnabled("coach", inputs({ disableCoach: false }), true),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is disabled when the per-user opt-out is set", () => {
|
||||
expect(
|
||||
resolveModuleEnabled("coach", inputs({ disableCoach: true }), true),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is disabled when the operator master flag is off", () => {
|
||||
expect(
|
||||
resolveModuleEnabled("coach", inputs({ disableCoach: false }), false),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores the module blob for coach", () => {
|
||||
// A crafted `{ coach: true }` cannot re-enable against a disabled
|
||||
// operator flag or a user opt-out.
|
||||
const i = inputs({ disableCoach: true, modulePreferences: { coach: true } });
|
||||
expect(resolveModuleEnabled("coach", i, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("insights — narrative layer, not a delegated module", () => {
|
||||
it("resolves from the disabled allowlist like the other secondary domains", () => {
|
||||
expect(resolveModuleEnabled("insights", inputs(), true)).toBe(true);
|
||||
expect(
|
||||
resolveModuleEnabled(
|
||||
"insights",
|
||||
inputs({ modulePreferences: { insights: false } }),
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is not delegated (no double source of truth marker)", () => {
|
||||
expect(moduleDelegatesTo("insights")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalisePrefs — fail-open coercion", () => {
|
||||
it("treats null / non-object / array as empty (all-on)", () => {
|
||||
expect(normalisePrefs(null)).toEqual({});
|
||||
expect(normalisePrefs(undefined)).toEqual({});
|
||||
expect(normalisePrefs("nope")).toEqual({});
|
||||
expect(normalisePrefs(["mood"])).toEqual({});
|
||||
});
|
||||
|
||||
it("keeps only boolean values, dropping junk entries", () => {
|
||||
expect(
|
||||
normalisePrefs({ mood: false, sleep: "yes", glucose: 0, labs: true }),
|
||||
).toEqual({ mood: false, labs: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("registry — core domains can never be disabled", () => {
|
||||
it("the four core domains are not toggleable module keys", () => {
|
||||
for (const core of CORE_DOMAIN_KEYS) {
|
||||
expect(isModuleKey(core)).toBe(false);
|
||||
expect(isCoreDomain(core)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("a crafted core-domain key is inert (never read as a module)", () => {
|
||||
// `weight: false` in the blob can't matter: weight is not a ModuleKey,
|
||||
// so the gate never consults it. The resolver only ever runs for the
|
||||
// 11 declared toggleable keys.
|
||||
expect(isModuleKey("weight")).toBe(false);
|
||||
expect(isModuleKey("medications")).toBe(false);
|
||||
});
|
||||
|
||||
it("every toggleable key is a known module and not a core domain", () => {
|
||||
for (const key of MODULE_KEYS) {
|
||||
expect(isModuleKey(key)).toBe(true);
|
||||
expect(isCoreDomain(key)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("wire contract", () => {
|
||||
it("exports the iOS-classifier errorCode", () => {
|
||||
expect(MODULE_DISABLED_ERROR_CODE).toBe("module.disabled");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* v1.18.0 — module enable/disable gate.
|
||||
*
|
||||
* The ONE enforcement point for the module foundation. Every leak-point
|
||||
* (nav, dashboard tiles, insights sections, coach snapshot, status
|
||||
* routes, reminder jobs, achievements, doctor-report / FHIR, search,
|
||||
* quick-add) resolves its decision through `isModuleEnabled(userId, key)`
|
||||
* and `requireModuleEnabled(userId, key)`. Mirrors the cycle gate
|
||||
* (`src/lib/cycle/gate.ts`) and the assistant feature flags
|
||||
* (`src/lib/feature-flags/index.ts`).
|
||||
*
|
||||
* Source-of-truth discipline (NO double source of truth):
|
||||
*
|
||||
* - `cycle` delegates to `isCycleEnabled(gender, CycleProfile)` — the
|
||||
* existing cycle gate. `modulePreferencesJson.cycle` is
|
||||
* ignored on purpose.
|
||||
* - `coach` delegates to `User.disableCoach` AND the operator-level
|
||||
* assistant master flag (`getAssistantFlags().coach`). Both
|
||||
* must agree, matching the two-layer model the client gates
|
||||
* already enforce.
|
||||
* - every other module resolves purely from `modulePreferencesJson`
|
||||
* as a DISABLED allowlist (absent / empty / `true` ⇒ on; an
|
||||
* explicit `false` ⇒ off). Default-on.
|
||||
*
|
||||
* Fail-closed posture: the resolver only ever DISABLES a module when it
|
||||
* sees an explicit, well-typed `false` (or, for delegated modules, the
|
||||
* delegated source returning disabled). A malformed blob, a missing
|
||||
* column, an unknown key — none of these can silently disable a module
|
||||
* (default-on), and none can enable a delegated one against its real
|
||||
* source. A crafted `{ "weight": false }` blob is inert because `weight`
|
||||
* is a core domain, not a `ModuleKey`.
|
||||
*
|
||||
* Reads are memoised per-request (the assistant-flags pattern) so a
|
||||
* single request that gates several modules hits the row once.
|
||||
*/
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { getAssistantFlags } from "@/lib/feature-flags";
|
||||
import { memoizePerRequest } from "@/lib/request-cache";
|
||||
import {
|
||||
MODULE_KEYS,
|
||||
moduleDelegatesTo,
|
||||
type ModuleKey,
|
||||
} from "./registry";
|
||||
|
||||
export { MODULE_KEYS } from "./registry";
|
||||
export type { ModuleKey } from "./registry";
|
||||
|
||||
/** Wire `errorCode` the iOS retry classifier branches on. */
|
||||
export const MODULE_DISABLED_ERROR_CODE = "module.disabled";
|
||||
|
||||
/**
|
||||
* The per-user fields the gate needs. Bundled so the resolver does a
|
||||
* single round-trip and the memo caches one row per request.
|
||||
*/
|
||||
export interface ModuleGateInputs {
|
||||
gender: string | null;
|
||||
disableCoach: boolean;
|
||||
modulePreferences: Record<string, boolean>;
|
||||
/** `CycleProfile.cycleTrackingEnabled`; NULL ⇒ derive from gender. */
|
||||
cycleTrackingEnabled: boolean | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce the persisted blob into a plain `Record<string, boolean>`,
|
||||
* keeping only boolean values. A null column, a non-object, or junk
|
||||
* entries all collapse to an empty / partial map — which the
|
||||
* disabled-allowlist semantics read as "all on". Fail-open per key:
|
||||
* only a literal `false` disables.
|
||||
*/
|
||||
export function normalisePrefs(raw: unknown): Record<string, boolean> {
|
||||
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return {};
|
||||
}
|
||||
const out: Record<string, boolean> = {};
|
||||
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (typeof v === "boolean") {
|
||||
out[k] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure resolver — exposed for unit tests so the disabled-allowlist +
|
||||
* delegation logic can be asserted without a DB. `assistantCoach` is the
|
||||
* resolved operator-level `getAssistantFlags().coach`; pass it in so this
|
||||
* stays synchronous and side-effect-free.
|
||||
*/
|
||||
export function resolveModuleEnabled(
|
||||
key: ModuleKey,
|
||||
inputs: ModuleGateInputs,
|
||||
assistantCoach: boolean,
|
||||
): boolean {
|
||||
const delegate = moduleDelegatesTo(key);
|
||||
|
||||
if (delegate === "cycle") {
|
||||
// Single source of truth: the cycle gate. The module blob is ignored.
|
||||
return isCycleEnabled(inputs.gender, {
|
||||
cycleTrackingEnabled: inputs.cycleTrackingEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
if (delegate === "coach") {
|
||||
// Two-layer model: operator master flag AND per-user opt-out.
|
||||
return assistantCoach && !inputs.disableCoach;
|
||||
}
|
||||
|
||||
// Disabled allowlist: only an explicit `false` turns the module off.
|
||||
return inputs.modulePreferences[key] !== false;
|
||||
}
|
||||
|
||||
/** Internal: load the gate inputs once per request. */
|
||||
function loadInputs(userId: string): Promise<ModuleGateInputs> {
|
||||
return memoizePerRequest(`module-gate-inputs:${userId}`, async () => {
|
||||
const [user, cycleProfile] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
gender: true,
|
||||
disableCoach: true,
|
||||
modulePreferencesJson: true,
|
||||
},
|
||||
}),
|
||||
prisma.cycleProfile.findUnique({
|
||||
where: { userId },
|
||||
select: { cycleTrackingEnabled: true },
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
gender: user?.gender ?? null,
|
||||
disableCoach: user?.disableCoach ?? false,
|
||||
modulePreferences: normalisePrefs(user?.modulePreferencesJson),
|
||||
cycleTrackingEnabled: cycleProfile?.cycleTrackingEnabled ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve whether a module is enabled for an account. Default-on:
|
||||
* an unknown / absent / non-`false` state reads as enabled. Delegated
|
||||
* modules (`cycle`, `coach`) resolve through their real source.
|
||||
*/
|
||||
export async function isModuleEnabled(
|
||||
userId: string,
|
||||
moduleKey: ModuleKey,
|
||||
): Promise<boolean> {
|
||||
const inputs = await loadInputs(userId);
|
||||
const delegate = moduleDelegatesTo(moduleKey);
|
||||
// Only pay the assistant-flags read when resolving the coach delegate.
|
||||
const assistantCoach =
|
||||
delegate === "coach" ? (await getAssistantFlags()).coach : false;
|
||||
return resolveModuleEnabled(moduleKey, inputs, assistantCoach);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the full `{ <key>: boolean }` map for every toggleable module,
|
||||
* suitable for the `GET /api/auth/me` projection. cycle/coach reflect
|
||||
* their real delegated state.
|
||||
*/
|
||||
export async function resolveModuleMap(
|
||||
userId: string,
|
||||
): Promise<Record<ModuleKey, boolean>> {
|
||||
const inputs = await loadInputs(userId);
|
||||
const assistantCoach = (await getAssistantFlags()).coach;
|
||||
const out = {} as Record<ModuleKey, boolean>;
|
||||
for (const key of MODULE_KEYS) {
|
||||
out[key] = resolveModuleEnabled(key, inputs, assistantCoach);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route-guard outcome: either a clear pass or a ready 403 envelope to
|
||||
* return verbatim — mirrors `CycleGateResult`.
|
||||
*/
|
||||
export type ModuleGateResult =
|
||||
| { enabled: true }
|
||||
| { enabled: false; response: Response };
|
||||
|
||||
/**
|
||||
* Enforce the gate for a module-scoped route. Returns a 403
|
||||
* `module.disabled` envelope (carrying the module key in `meta`) when the
|
||||
* account has the module turned off — even with a valid Bearer token.
|
||||
*/
|
||||
export async function requireModuleEnabled(
|
||||
userId: string,
|
||||
moduleKey: ModuleKey,
|
||||
): Promise<ModuleGateResult> {
|
||||
if (await isModuleEnabled(userId, moduleKey)) {
|
||||
return { enabled: true };
|
||||
}
|
||||
return {
|
||||
enabled: false,
|
||||
response: apiError(`Module "${moduleKey}" is not enabled`, 403, {
|
||||
errorCode: MODULE_DISABLED_ERROR_CODE,
|
||||
module: moduleKey,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* v1.18.0 — canonical module registry.
|
||||
*
|
||||
* The single source of truth for "what is a module" in HealthLog's
|
||||
* enable/disable foundation. Every leak-point (nav, dashboard tiles,
|
||||
* insights sections, coach snapshot, status routes, reminder jobs,
|
||||
* achievements, doctor-report / FHIR, search, quick-add) reads its gate
|
||||
* decision through `src/lib/modules/gate.ts`, which keys off the entries
|
||||
* declared here.
|
||||
*
|
||||
* Two classes of domain:
|
||||
*
|
||||
* - CORE (always-on): weight, blood pressure, pulse, medications — the
|
||||
* measurement engine + meds. These are NOT in `MODULE_KEYS` and have
|
||||
* NO registry entry. They can never be disabled; the gate has no key
|
||||
* to flip and the write endpoint refuses them. This is structural,
|
||||
* not a runtime check future code can soften.
|
||||
*
|
||||
* - TOGGLEABLE (the "secondary domains"): the maintainer-chosen scope
|
||||
* below. Each carries a stable key, an i18n label + description key,
|
||||
* a category for the Settings hub grouping, and an optional
|
||||
* `delegatesTo` marker for the two modules whose enabled-state is
|
||||
* owned elsewhere (no double source of truth).
|
||||
*
|
||||
* The persisted shape (`User.modulePreferencesJson`) is a DISABLED
|
||||
* allowlist: absent / empty / `true` ⇒ enabled, an explicit `false` ⇒
|
||||
* disabled. Default-on, no backfill.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Where a module's enabled-state is resolved when it is NOT owned by
|
||||
* `modulePreferencesJson` directly. Keeps a single source of truth:
|
||||
*
|
||||
* - "cycle" → `isCycleEnabled(gender, CycleProfile)` (the existing
|
||||
* cycle gate; `modulePreferencesJson.cycle` is ignored).
|
||||
* - "coach" → `User.disableCoach` AND the operator-level assistant
|
||||
* master flag (`getAssistantFlags().coach`); again the
|
||||
* module blob is ignored for this key.
|
||||
*
|
||||
* Every other toggleable module has no `delegatesTo` and resolves purely
|
||||
* from `modulePreferencesJson`.
|
||||
*/
|
||||
export type ModuleDelegation = "cycle" | "coach";
|
||||
|
||||
/** Coarse grouping for the Settings "What you track" hub. */
|
||||
export type ModuleCategory =
|
||||
| "tracking" // user-logged domains (mood, cycle, glucose, labs)
|
||||
| "device" // device / passive-sync domains (sleep, workouts, recovery)
|
||||
| "engagement" // gamification (achievements)
|
||||
| "intelligence" // AI-driven surfaces (coach, insights)
|
||||
| "export"; // outbound reporting (doctor report / FHIR)
|
||||
|
||||
export interface ModuleDefinition {
|
||||
/** Stable key — the literal used in `modulePreferencesJson` + the API. */
|
||||
key: ModuleKey;
|
||||
/** i18n key for the human-readable module name (messages/*.json). */
|
||||
labelKey: string;
|
||||
/** i18n key for the one-line "what turning this off does" explainer. */
|
||||
descriptionKey: string;
|
||||
/** Settings-hub grouping. */
|
||||
category: ModuleCategory;
|
||||
/**
|
||||
* When set, the module's enabled-state is NOT resolved from
|
||||
* `modulePreferencesJson` — the gate delegates to the named source so
|
||||
* there is exactly one source of truth. See `ModuleDelegation`.
|
||||
*/
|
||||
delegatesTo?: ModuleDelegation;
|
||||
}
|
||||
|
||||
/**
|
||||
* The toggleable modules — the maintainer's "secondary domains" scope.
|
||||
*
|
||||
* Declared as a const tuple so `ModuleKey` is the exact union and the
|
||||
* registry stays exhaustively typed.
|
||||
*/
|
||||
export const MODULE_KEYS = [
|
||||
"cycle",
|
||||
"mood",
|
||||
"sleep",
|
||||
"glucose",
|
||||
"workouts",
|
||||
"recovery",
|
||||
"labs",
|
||||
"achievements",
|
||||
"coach",
|
||||
"insights",
|
||||
"doctorReport",
|
||||
] as const;
|
||||
|
||||
export type ModuleKey = (typeof MODULE_KEYS)[number];
|
||||
|
||||
/**
|
||||
* CORE domains — the always-on measurement engine + medications. Listed
|
||||
* here for documentation + the write-endpoint denylist; they have NO
|
||||
* `ModuleDefinition` and can never appear as a toggle. A crafted
|
||||
* `{ "weight": false }` blob is inert: `weight` is not a `ModuleKey`, so
|
||||
* the gate never reads it and the PATCH validator rejects it.
|
||||
*/
|
||||
export const CORE_DOMAIN_KEYS = [
|
||||
"weight",
|
||||
"bloodPressure",
|
||||
"pulse",
|
||||
"medications",
|
||||
] as const;
|
||||
|
||||
export type CoreDomainKey = (typeof CORE_DOMAIN_KEYS)[number];
|
||||
|
||||
const CORE_DOMAIN_SET: ReadonlySet<string> = new Set(CORE_DOMAIN_KEYS);
|
||||
|
||||
/** True for the four always-on core domains (never disableable). */
|
||||
export function isCoreDomain(key: string): key is CoreDomainKey {
|
||||
return CORE_DOMAIN_SET.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* The registry. Order is the Settings-hub display order (grouped by
|
||||
* category in the UI; this flat order is the canonical iteration order).
|
||||
*
|
||||
* `insights` decision (documented): disabling `insights` hides only the
|
||||
* AI-ANALYSIS surfaces — the Daily Briefing, the per-metric AI status
|
||||
* cards, the correlation narration, the Health-Score explainer. It does
|
||||
* NOT hide the raw weight / BP / pulse charts or any measurement data:
|
||||
* those are core and render from live measurements regardless. `insights`
|
||||
* is the narrative layer, not the data layer. (The operator-level
|
||||
* assistant master flag is a separate, server-wide kill-switch; this
|
||||
* per-user toggle sits below it.)
|
||||
*
|
||||
* `coach` is the per-user opt-out half of the existing two-layer model
|
||||
* (`User.disableCoach` AND the operator master flag). It is surfaced here
|
||||
* so the Modules hub lists it alongside its siblings, but it delegates —
|
||||
* the module blob never owns it.
|
||||
*/
|
||||
export const MODULE_REGISTRY: Readonly<Record<ModuleKey, ModuleDefinition>> =
|
||||
Object.freeze({
|
||||
cycle: {
|
||||
key: "cycle",
|
||||
labelKey: "modules.cycle.label",
|
||||
descriptionKey: "modules.cycle.description",
|
||||
category: "tracking",
|
||||
delegatesTo: "cycle",
|
||||
},
|
||||
mood: {
|
||||
key: "mood",
|
||||
labelKey: "modules.mood.label",
|
||||
descriptionKey: "modules.mood.description",
|
||||
category: "tracking",
|
||||
},
|
||||
glucose: {
|
||||
key: "glucose",
|
||||
labelKey: "modules.glucose.label",
|
||||
descriptionKey: "modules.glucose.description",
|
||||
category: "tracking",
|
||||
},
|
||||
labs: {
|
||||
key: "labs",
|
||||
labelKey: "modules.labs.label",
|
||||
descriptionKey: "modules.labs.description",
|
||||
category: "tracking",
|
||||
},
|
||||
sleep: {
|
||||
key: "sleep",
|
||||
labelKey: "modules.sleep.label",
|
||||
descriptionKey: "modules.sleep.description",
|
||||
category: "device",
|
||||
},
|
||||
workouts: {
|
||||
key: "workouts",
|
||||
labelKey: "modules.workouts.label",
|
||||
descriptionKey: "modules.workouts.description",
|
||||
category: "device",
|
||||
},
|
||||
recovery: {
|
||||
key: "recovery",
|
||||
labelKey: "modules.recovery.label",
|
||||
descriptionKey: "modules.recovery.description",
|
||||
category: "device",
|
||||
},
|
||||
achievements: {
|
||||
key: "achievements",
|
||||
labelKey: "modules.achievements.label",
|
||||
descriptionKey: "modules.achievements.description",
|
||||
category: "engagement",
|
||||
},
|
||||
coach: {
|
||||
key: "coach",
|
||||
labelKey: "modules.coach.label",
|
||||
descriptionKey: "modules.coach.description",
|
||||
category: "intelligence",
|
||||
delegatesTo: "coach",
|
||||
},
|
||||
insights: {
|
||||
key: "insights",
|
||||
labelKey: "modules.insights.label",
|
||||
descriptionKey: "modules.insights.description",
|
||||
category: "intelligence",
|
||||
},
|
||||
doctorReport: {
|
||||
key: "doctorReport",
|
||||
labelKey: "modules.doctorReport.label",
|
||||
descriptionKey: "modules.doctorReport.description",
|
||||
category: "export",
|
||||
},
|
||||
});
|
||||
|
||||
const MODULE_KEY_SET: ReadonlySet<string> = new Set(MODULE_KEYS);
|
||||
|
||||
/** True when `key` is a known toggleable module. */
|
||||
export function isModuleKey(key: string): key is ModuleKey {
|
||||
return MODULE_KEY_SET.has(key);
|
||||
}
|
||||
|
||||
/** The two delegated keys, resolved by their existing source of truth. */
|
||||
export function moduleDelegatesTo(key: ModuleKey): ModuleDelegation | undefined {
|
||||
return MODULE_REGISTRY[key].delegatesTo;
|
||||
}
|
||||
Reference in new issue
Block a user