mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
feat(api): expose the assistant feature-flag matrix
`GET /api/feature-flags` projects the six `AppSettings.assistant*`
booleans over HTTP as a single typed envelope:
{ data: { assistant: { enabled, coach, briefing, insightStatus,
correlations, healthScoreExplainer } } }
`requireAuth()` gates the route — any logged-in user passes. The
resolver in `src/lib/feature-flags/index.ts` forces every sub-flag
false when the master is off, so the response is always
operator-effective and clients never have to compose
`master && sub`. `Cache-Control: private, max-age=60` caps the
read cost on the hot /insights mount path while still propagating
admin changes within a minute.
iOS reads this endpoint on cold start and gates every Coach,
Briefing, status-card, correlation and explainer surface against
the matrix — regardless of whether the surface ends up calling the
server or running on-device. Locked per
`.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5.
This commit is contained in:
1 parent
b68206eca4
commit
61c9a1e792
2 files changed
+208
No files matched your search
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
function req(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/feature-flags", {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
const FIND = prisma.appSettings.findUnique as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/feature-flags", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await GET(req());
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns the all-on default shape for a fresh install", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
FIND.mockResolvedValue(null);
|
||||
const res = await GET(req());
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
assistant: {
|
||||
enabled: boolean;
|
||||
coach: boolean;
|
||||
briefing: boolean;
|
||||
insightStatus: boolean;
|
||||
correlations: boolean;
|
||||
healthScoreExplainer: boolean;
|
||||
};
|
||||
};
|
||||
error: null;
|
||||
};
|
||||
|
||||
expect(body.error).toBeNull();
|
||||
expect(body.data.assistant).toEqual({
|
||||
enabled: true,
|
||||
coach: true,
|
||||
briefing: true,
|
||||
insightStatus: true,
|
||||
correlations: true,
|
||||
healthScoreExplainer: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("forces every sub-flag false when the master is off", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
FIND.mockResolvedValue({
|
||||
assistantEnabled: false,
|
||||
assistantCoachEnabled: true,
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: true,
|
||||
assistantCorrelationsEnabled: true,
|
||||
assistantHealthScoreExplainerEnabled: true,
|
||||
});
|
||||
|
||||
const res = await GET(req());
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: { assistant: Record<string, boolean> };
|
||||
};
|
||||
|
||||
expect(body.data.assistant).toEqual({
|
||||
enabled: false,
|
||||
coach: false,
|
||||
briefing: false,
|
||||
insightStatus: false,
|
||||
correlations: false,
|
||||
healthScoreExplainer: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads each sub-flag column", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
FIND.mockResolvedValue({
|
||||
assistantEnabled: true,
|
||||
assistantCoachEnabled: false,
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: false,
|
||||
assistantCorrelationsEnabled: true,
|
||||
assistantHealthScoreExplainerEnabled: false,
|
||||
});
|
||||
|
||||
const res = await GET(req());
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: { assistant: Record<string, boolean> };
|
||||
};
|
||||
|
||||
expect(body.data.assistant).toEqual({
|
||||
enabled: true,
|
||||
coach: false,
|
||||
briefing: true,
|
||||
insightStatus: false,
|
||||
correlations: true,
|
||||
healthScoreExplainer: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches a private 60-second Cache-Control header", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
FIND.mockResolvedValue(null);
|
||||
const res = await GET(req());
|
||||
expect(res.headers.get("Cache-Control")).toBe("private, max-age=60");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* `GET /api/feature-flags` — operator-side assistant flag matrix.
|
||||
*
|
||||
* Projects `AppSettings.assistant*Enabled` over HTTP so every client
|
||||
* (web React tree + iOS native) reads the same authoritative shape.
|
||||
*
|
||||
* Response envelope:
|
||||
*
|
||||
* {
|
||||
* "data": {
|
||||
* "assistant": {
|
||||
* "enabled": true,
|
||||
* "coach": true,
|
||||
* "briefing": true,
|
||||
* "insightStatus": true,
|
||||
* "correlations": true,
|
||||
* "healthScoreExplainer": true
|
||||
* }
|
||||
* },
|
||||
* "error": null
|
||||
* }
|
||||
*
|
||||
* - `requireAuth()` — any logged-in user. Per-request flag fetches
|
||||
* from the iOS native client always arrive after auth, so the
|
||||
* gate matches the rest of the read-only profile surface.
|
||||
* - Master kills every sub-flag in the resolver before the shape
|
||||
* leaves the handler, so callers never have to compose
|
||||
* `master && sub`.
|
||||
* - `Cache-Control: private, max-age=60` — operator toggles flip
|
||||
* rarely; a 60-second per-session cache keeps the cost off the
|
||||
* hot /insights mount path while still propagating an admin
|
||||
* change within a minute.
|
||||
*
|
||||
* Locked per `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5
|
||||
* and `.planning/research/v15-assistant-optional.md` Part D.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { getAssistantFlags } from "@/lib/feature-flags";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const GET = apiHandler(async (_request: NextRequest) => {
|
||||
await requireAuth();
|
||||
annotate({ action: { name: "feature-flags.read" } });
|
||||
|
||||
const assistant = await getAssistantFlags();
|
||||
|
||||
const response = apiSuccess({ assistant });
|
||||
response.headers.set("Cache-Control", "private, max-age=60");
|
||||
return response;
|
||||
});
|
||||
Reference in new issue
Block a user