mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(settings): add 6 assistant-surface feature flags to AppSettings
Operator-side kill matrix for every LLM-driven surface. Master flag `assistantEnabled` plus five sub-flags carve the seven surfaces: Coach + chat, Daily Briefing + advisor, per-metric status cards, correlation narration, Health-Score delta explainer. Defaults are `true` so existing installs upgrade without losing affordances. New `src/lib/feature-flags/` module exposes `getAssistantFlags()`, `resolveAssistantFlags()` and the `AssistantDisabledError` typed error route handlers throw to surface the 403 + `errorCode: "assistant.disabled.<surface>"` envelope. The resolver forces every sub-flag false when the master is off so callers never have to compose `master && sub`. Locks per `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5: the same matrix gates server-routed AND iOS on-device surfaces. Migration 0065 is additive only; `app_settings` gains six boolean columns, all default `true`, idempotent under `IF NOT EXISTS`.
This commit is contained in:
1 parent
0aaf10d796
commit
b68206eca4
5 files changed
+385
No files matched your search
@@ -0,0 +1,35 @@
|
||||
-- v1.4.31 — Assistant-surface operator feature flags
|
||||
--
|
||||
-- Six new boolean columns on `app_settings`. One master flag plus
|
||||
-- five sub-flags covering the seven LLM-driven surfaces (Coach,
|
||||
-- Daily Briefing, per-metric status cards, correlations,
|
||||
-- Health-Score delta explainer). All default `true` so existing
|
||||
-- installs upgrade without losing surfaces. The runtime resolver
|
||||
-- forces every sub-flag false when the master is off — so flipping
|
||||
-- one row in the admin panel can mute the whole assistant.
|
||||
--
|
||||
-- Additive-only — six nullable-then-default columns, no data
|
||||
-- movement. `IF NOT EXISTS` keeps the migration idempotent against
|
||||
-- partial historical runs (same posture as 0058, 0061, 0063).
|
||||
--
|
||||
-- Locks per `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5
|
||||
-- and the architecture doc at
|
||||
-- `.planning/research/v15-assistant-optional.md`.
|
||||
|
||||
ALTER TABLE "app_settings"
|
||||
ADD COLUMN IF NOT EXISTS "assistant_enabled" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
ALTER TABLE "app_settings"
|
||||
ADD COLUMN IF NOT EXISTS "assistant_coach_enabled" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
ALTER TABLE "app_settings"
|
||||
ADD COLUMN IF NOT EXISTS "assistant_briefing_enabled" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
ALTER TABLE "app_settings"
|
||||
ADD COLUMN IF NOT EXISTS "assistant_insight_status_enabled" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
ALTER TABLE "app_settings"
|
||||
ADD COLUMN IF NOT EXISTS "assistant_correlations_enabled" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
ALTER TABLE "app_settings"
|
||||
ADD COLUMN IF NOT EXISTS "assistant_health_score_explainer_enabled" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -1208,6 +1208,26 @@ model AppSettings {
|
||||
// 32 chars) plus headroom.
|
||||
defaultUserTimezone String? @map("default_user_timezone") @db.VarChar(64)
|
||||
|
||||
// v1.4.31 — Assistant-surface operator feature flags. The master
|
||||
// gates every LLM-driven surface app-wide; five sub-flags carve
|
||||
// the visibility cut per surface (Coach drawer + chat, Daily
|
||||
// Briefing + advisor, per-metric status cards, correlation
|
||||
// narration, Health-Score delta explainer). Defaults preserve the
|
||||
// v1.4.30 behaviour (every surface visible) so upgrades require
|
||||
// no admin action. The runtime resolver in
|
||||
// `src/lib/feature-flags/index.ts` forces every sub-flag false
|
||||
// when the master is off. Locked per
|
||||
// `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5: the same
|
||||
// flag matrix gates server-routed AND iOS on-device surfaces, so
|
||||
// operators can opt out of every assistant surface regardless of
|
||||
// where the model runs.
|
||||
assistantEnabled Boolean @default(true) @map("assistant_enabled")
|
||||
assistantCoachEnabled Boolean @default(true) @map("assistant_coach_enabled")
|
||||
assistantBriefingEnabled Boolean @default(true) @map("assistant_briefing_enabled")
|
||||
assistantInsightStatusEnabled Boolean @default(true) @map("assistant_insight_status_enabled")
|
||||
assistantCorrelationsEnabled Boolean @default(true) @map("assistant_correlations_enabled")
|
||||
assistantHealthScoreExplainerEnabled Boolean @default(true) @map("assistant_health_score_explainer_enabled")
|
||||
|
||||
@@map("app_settings")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
getEvent: () => ({ addWarning: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
ASSISTANT_FLAGS_DEFAULT,
|
||||
AssistantDisabledError,
|
||||
getAssistantFlags,
|
||||
requireAssistantSurface,
|
||||
resolveAssistantFlags,
|
||||
} from "../index";
|
||||
|
||||
const FIND = prisma.appSettings.findUnique as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
FIND.mockReset();
|
||||
});
|
||||
|
||||
describe("resolveAssistantFlags", () => {
|
||||
it("returns the input unchanged when master is on", () => {
|
||||
const input = {
|
||||
enabled: true,
|
||||
coach: true,
|
||||
briefing: false,
|
||||
insightStatus: true,
|
||||
correlations: false,
|
||||
healthScoreExplainer: true,
|
||||
};
|
||||
expect(resolveAssistantFlags(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it("forces every sub-flag false when master is off", () => {
|
||||
const input = {
|
||||
enabled: false,
|
||||
coach: true,
|
||||
briefing: true,
|
||||
insightStatus: true,
|
||||
correlations: true,
|
||||
healthScoreExplainer: true,
|
||||
};
|
||||
expect(resolveAssistantFlags(input)).toEqual({
|
||||
enabled: false,
|
||||
coach: false,
|
||||
briefing: false,
|
||||
insightStatus: false,
|
||||
correlations: false,
|
||||
healthScoreExplainer: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAssistantFlags", () => {
|
||||
it("returns defaults when the row is missing", async () => {
|
||||
FIND.mockResolvedValue(null);
|
||||
const flags = await getAssistantFlags();
|
||||
expect(flags).toEqual(ASSISTANT_FLAGS_DEFAULT);
|
||||
});
|
||||
|
||||
it("reads each column correctly", async () => {
|
||||
FIND.mockResolvedValue({
|
||||
assistantEnabled: true,
|
||||
assistantCoachEnabled: false,
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: false,
|
||||
assistantCorrelationsEnabled: true,
|
||||
assistantHealthScoreExplainerEnabled: false,
|
||||
});
|
||||
const flags = await getAssistantFlags();
|
||||
expect(flags).toEqual({
|
||||
enabled: true,
|
||||
coach: false,
|
||||
briefing: true,
|
||||
insightStatus: false,
|
||||
correlations: true,
|
||||
healthScoreExplainer: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("forces every sub-flag false when master is off", async () => {
|
||||
FIND.mockResolvedValue({
|
||||
assistantEnabled: false,
|
||||
assistantCoachEnabled: true,
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: true,
|
||||
assistantCorrelationsEnabled: true,
|
||||
assistantHealthScoreExplainerEnabled: true,
|
||||
});
|
||||
const flags = await getAssistantFlags();
|
||||
expect(flags).toEqual({
|
||||
enabled: false,
|
||||
coach: false,
|
||||
briefing: false,
|
||||
insightStatus: false,
|
||||
correlations: false,
|
||||
healthScoreExplainer: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to defaults on a Prisma error", async () => {
|
||||
FIND.mockRejectedValue(new Error("db down"));
|
||||
const flags = await getAssistantFlags();
|
||||
expect(flags).toEqual(ASSISTANT_FLAGS_DEFAULT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireAssistantSurface", () => {
|
||||
it("returns the resolved flag set when the surface is enabled", async () => {
|
||||
FIND.mockResolvedValue({
|
||||
assistantEnabled: true,
|
||||
assistantCoachEnabled: true,
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: true,
|
||||
assistantCorrelationsEnabled: true,
|
||||
assistantHealthScoreExplainerEnabled: true,
|
||||
});
|
||||
const flags = await requireAssistantSurface("coach");
|
||||
expect(flags.coach).toBe(true);
|
||||
});
|
||||
|
||||
it("throws AssistantDisabledError when the sub-flag is off", async () => {
|
||||
FIND.mockResolvedValue({
|
||||
assistantEnabled: true,
|
||||
assistantCoachEnabled: false,
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: true,
|
||||
assistantCorrelationsEnabled: true,
|
||||
assistantHealthScoreExplainerEnabled: true,
|
||||
});
|
||||
await expect(requireAssistantSurface("coach")).rejects.toThrow(
|
||||
AssistantDisabledError,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws AssistantDisabledError when the master kills the surface", async () => {
|
||||
FIND.mockResolvedValue({
|
||||
assistantEnabled: false,
|
||||
assistantCoachEnabled: true,
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: true,
|
||||
assistantCorrelationsEnabled: true,
|
||||
assistantHealthScoreExplainerEnabled: true,
|
||||
});
|
||||
await expect(requireAssistantSurface("briefing")).rejects.toThrow(
|
||||
AssistantDisabledError,
|
||||
);
|
||||
});
|
||||
|
||||
it("AssistantDisabledError carries the surface-tagged errorCode", () => {
|
||||
const err = new AssistantDisabledError("correlations");
|
||||
expect(err.surface).toBe("correlations");
|
||||
expect(err.errorCode).toBe("assistant.disabled.correlations");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
|
||||
/**
|
||||
* v1.4.31 — Assistant-surface operator feature flags.
|
||||
*
|
||||
* Six boolean toggles on `AppSettings` carve the visibility cut for
|
||||
* the seven LLM-driven surfaces. The master flag is a single kill-
|
||||
* switch; the five 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 five assistant sub-flags the operator can carve. */
|
||||
export type AssistantSurface =
|
||||
| "coach"
|
||||
| "briefing"
|
||||
| "insightStatus"
|
||||
| "correlations"
|
||||
| "healthScoreExplainer";
|
||||
|
||||
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;
|
||||
/** `?` glyph that opens the Health-Score delta explainer popover. */
|
||||
healthScoreExplainer: 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,
|
||||
healthScoreExplainer: 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.
|
||||
*/
|
||||
export async function getAssistantFlags(): Promise<AssistantFlagSet> {
|
||||
try {
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
assistantEnabled: true,
|
||||
assistantCoachEnabled: true,
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: true,
|
||||
assistantCorrelationsEnabled: true,
|
||||
assistantHealthScoreExplainerEnabled: 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,
|
||||
healthScoreExplainer:
|
||||
settings?.assistantHealthScoreExplainerEnabled ?? 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,
|
||||
healthScoreExplainer: 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 the `AssistantDisabledNotice`
|
||||
* empty-state.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
@@ -80,5 +80,19 @@ export const adminSettingsSchema = z
|
||||
// against Intl.supportedValuesOf at runtime; this schema just
|
||||
// guards the shape + length.
|
||||
defaultUserTimezone: z.string().max(64).optional(),
|
||||
// v1.4.31 — assistant-surface operator feature flags. Master
|
||||
// kills every sub-flag; sub-flags carve specific surfaces. The
|
||||
// dedicated admin endpoint at
|
||||
// /api/admin/settings/assistant-flags also accepts these via
|
||||
// its own schema; this entry exists so the generic
|
||||
// /api/admin/settings PUT path can still carry the values for
|
||||
// operators who script their settings updates over a single
|
||||
// route.
|
||||
assistantEnabled: z.boolean().optional(),
|
||||
assistantCoachEnabled: z.boolean().optional(),
|
||||
assistantBriefingEnabled: z.boolean().optional(),
|
||||
assistantInsightStatusEnabled: z.boolean().optional(),
|
||||
assistantCorrelationsEnabled: z.boolean().optional(),
|
||||
assistantHealthScoreExplainerEnabled: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
Reference in new issue
Block a user