mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
The admin panel offered a toggle for the Health-Score explainer and described it as the ? next to the delta line. That ? had already been taken off the insights surface a release earlier, and the caption that replaced it went away entirely when the health score was replaced by the reference composite. Since then an operator could turn the switch off, get a saved confirmation, and change absolutely nothing. The toggle, its wording in all six languages, its place in the flag matrix the app and the iOS client both read, and its column on app_settings are all gone. Nothing is left to promise a control that does not exist. A guard goes in with it: every remaining assistant sub-flag has to be read by some surface, and the panel and the plumbing that carry all of them by construction do not count as proof.
153 lines
5.4 KiB
TypeScript
153 lines
5.4 KiB
TypeScript
import { prisma } from "@/lib/db";
|
|
import { getEvent } from "@/lib/logging/context";
|
|
import { memoizePerRequest } from "@/lib/request-cache";
|
|
|
|
/**
|
|
* v1.4.31 — Assistant-surface operator feature flags.
|
|
*
|
|
* Five boolean toggles on `AppSettings` carve the visibility cut for
|
|
* the LLM-driven surfaces. The master flag is a single kill-switch;
|
|
* the four sub-flags carve specific surfaces.
|
|
*
|
|
* `assistant.enabled = false` forces every sub-flag false in the
|
|
* resolved shape — the master always wins. This means a server-side
|
|
* caller never has to compose `master && sub`; reading
|
|
* `flags.coach` already accounts for both layers.
|
|
*
|
|
* Per `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5: the
|
|
* matrix gates BOTH server-routed AND iOS on-device assistant
|
|
* surfaces. The `GET /api/feature-flags` endpoint projects this
|
|
* shape so iOS reads the same authoritative set the web reads.
|
|
*/
|
|
|
|
/** The four assistant sub-flags the operator can carve. */
|
|
export type AssistantSurface =
|
|
"coach" | "briefing" | "insightStatus" | "correlations";
|
|
|
|
export interface AssistantFlagSet {
|
|
/** Master kill-switch — when false, every sub-flag is forced false. */
|
|
enabled: boolean;
|
|
/** Coach drawer, chat SSE, history rail, feedback. */
|
|
coach: boolean;
|
|
/** Daily Briefing card + advisor recommendations + regen icon. */
|
|
briefing: boolean;
|
|
/** Per-metric status cards on every `/insights/<metric>` sub-page. */
|
|
insightStatus: boolean;
|
|
/** Correlation narration tile on the mother page. */
|
|
correlations: boolean;
|
|
}
|
|
|
|
/** All-on default; mirrors v1.4.30 behaviour for fresh installs. */
|
|
export const ASSISTANT_FLAGS_DEFAULT: AssistantFlagSet = Object.freeze({
|
|
enabled: true,
|
|
coach: true,
|
|
briefing: true,
|
|
insightStatus: true,
|
|
correlations: true,
|
|
});
|
|
|
|
/**
|
|
* Load the assistant flag set from `AppSettings`. Read-through pattern
|
|
* matches `getGlobalServiceAvailability()` — null/error/missing-row all
|
|
* fall back to defaults so the assistant stays visible on first boot.
|
|
*
|
|
* The master always wins: every sub-flag is forced false when the
|
|
* master is off, before the resolved set leaves this function.
|
|
*
|
|
* v1.4.33 — memoised per-request so a single Coach-drawer open that
|
|
* fires five gated fetches in parallel (`/api/insights/chat`,
|
|
* `/api/insights/comprehensive`, six `/api/insights/<metric>-status`
|
|
* routes, the rail list) reads `AppSettings.singleton` once instead of
|
|
* five times. The cache lives on the active `WideEventBuilder` so it
|
|
* goes away with the request; behaviour outside an event context (unit
|
|
* tests, background jobs) stays unchanged.
|
|
*/
|
|
export async function getAssistantFlags(): Promise<AssistantFlagSet> {
|
|
return memoizePerRequest("assistant-flags", async () => {
|
|
try {
|
|
const settings = await prisma.appSettings.findUnique({
|
|
where: { id: "singleton" },
|
|
select: {
|
|
assistantEnabled: true,
|
|
assistantCoachEnabled: true,
|
|
assistantBriefingEnabled: true,
|
|
assistantInsightStatusEnabled: true,
|
|
assistantCorrelationsEnabled: true,
|
|
},
|
|
});
|
|
|
|
const master = settings?.assistantEnabled ?? true;
|
|
return resolveAssistantFlags({
|
|
enabled: master,
|
|
coach: settings?.assistantCoachEnabled ?? true,
|
|
briefing: settings?.assistantBriefingEnabled ?? true,
|
|
insightStatus: settings?.assistantInsightStatusEnabled ?? true,
|
|
correlations: settings?.assistantCorrelationsEnabled ?? true,
|
|
});
|
|
} catch {
|
|
getEvent()?.addWarning(
|
|
"Failed to load assistant feature flags, using defaults",
|
|
);
|
|
return ASSISTANT_FLAGS_DEFAULT;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Pure resolver — given a raw set of master + sub-flag values, return
|
|
* the operator-effective shape. Exposed for unit tests and for the
|
|
* admin PUT handler that needs to echo the resolved set after a write.
|
|
*/
|
|
export function resolveAssistantFlags(raw: AssistantFlagSet): AssistantFlagSet {
|
|
if (!raw.enabled) {
|
|
return {
|
|
enabled: false,
|
|
coach: false,
|
|
briefing: false,
|
|
insightStatus: false,
|
|
correlations: false,
|
|
};
|
|
}
|
|
return { ...raw };
|
|
}
|
|
|
|
/**
|
|
* Error class thrown by `requireAssistantSurface()` when the operator
|
|
* has disabled the surface. The api-handler catches it and returns
|
|
* the 403 envelope per the iOS contract (R5):
|
|
*
|
|
* { data: null, error: "...", meta: { errorCode: "assistant.disabled.<surface>" } }
|
|
*
|
|
* Older iOS clients that predate the v1.4.31 contract surface this as
|
|
* a generic 403 (their existing 403-handler covers the case); v1.4.31+
|
|
* clients read the `errorCode` to render an inline operator-disabled
|
|
* notice.
|
|
*/
|
|
export class AssistantDisabledError extends Error {
|
|
readonly surface: AssistantSurface;
|
|
readonly errorCode: string;
|
|
|
|
constructor(surface: AssistantSurface) {
|
|
super(`Assistant surface "${surface}" is disabled on this server`);
|
|
this.name = "AssistantDisabledError";
|
|
this.surface = surface;
|
|
this.errorCode = `assistant.disabled.${surface}`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Server-side gate helper. Throws `AssistantDisabledError` when the
|
|
* relevant surface (or the master) is off. Routes call this near the
|
|
* top, after auth and rate-limit, and let the apiHandler catch turn
|
|
* it into the 403 envelope.
|
|
*/
|
|
export async function requireAssistantSurface(
|
|
surface: AssistantSurface,
|
|
): Promise<AssistantFlagSet> {
|
|
const flags = await getAssistantFlags();
|
|
if (!flags[surface]) {
|
|
throw new AssistantDisabledError(surface);
|
|
}
|
|
return flags;
|
|
}
|