mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(api): surface and update per-user module preferences
GET /api/auth/me now returns a resolved modules map for every toggleable module (cycle/coach reflect their delegated state). New PATCH/GET /api/auth/me/modules read and update the preferences: the body is strict over the toggleable key set so a core-domain or unknown key is a 422, the merge is field-by-field (no mass assignment), and userId is narrowed from the session. Documented in the OpenAPI registry and yaml. The disable-coach gate-inventory allowlist records the module gate's intentional coach delegation read.
This commit is contained in:
1 parent
d1089ba232
commit
fa53631c55
7 files changed
+738
-1
No files matched your search
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* v1.18.0 — PATCH/GET /api/auth/me/modules.
|
||||
*
|
||||
* Covers the read projection, the field-by-field merge, core-module
|
||||
* refusal (strict schema), the cycle/coach delegation reflected in the
|
||||
* resolved map, and the standard auth / rate-limit / 422 envelope.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
cycleProfile: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
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("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
|
||||
// Operator master flag = on by default; coach delegation keys off it.
|
||||
// Partial mock — api-handler imports `AssistantDisabledError` from here.
|
||||
vi.mock("@/lib/feature-flags", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/feature-flags")>();
|
||||
return {
|
||||
...actual,
|
||||
getAssistantFlags: vi.fn().mockResolvedValue({ coach: true }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, PATCH } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getAssistantFlags } from "@/lib/feature-flags";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
type ModuleMap = Record<string, boolean>;
|
||||
|
||||
function mkPatch(body: unknown): Request {
|
||||
return new Request("http://localhost/api/auth/me/modules", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Default DB shape: fresh user, no module prefs, no cycle row. The
|
||||
* `findUnique` mock reads from a mutable holder so a PATCH `update`
|
||||
* (which writes `modulePreferencesJson`) is reflected by the post-update
|
||||
* `resolveModuleMap` re-read — mirroring real DB round-trip semantics.
|
||||
*/
|
||||
function primeUser(over: {
|
||||
gender?: string | null;
|
||||
disableCoach?: boolean;
|
||||
modulePreferencesJson?: unknown;
|
||||
cycleTrackingEnabled?: boolean | null;
|
||||
} = {}) {
|
||||
const row: {
|
||||
gender: string | null;
|
||||
disableCoach: boolean;
|
||||
modulePreferencesJson: unknown;
|
||||
} = {
|
||||
gender: over.gender ?? null,
|
||||
disableCoach: over.disableCoach ?? false,
|
||||
modulePreferencesJson: over.modulePreferencesJson ?? null,
|
||||
};
|
||||
vi.mocked(prisma.user.findUnique).mockImplementation(
|
||||
(() => Promise.resolve({ ...row })) as never,
|
||||
);
|
||||
vi.mocked(prisma.user.update).mockImplementation(((args: {
|
||||
data?: { modulePreferencesJson?: unknown };
|
||||
}) => {
|
||||
const data = args.data;
|
||||
if (data && "modulePreferencesJson" in data) {
|
||||
row.modulePreferencesJson = data.modulePreferencesJson;
|
||||
}
|
||||
return Promise.resolve({});
|
||||
}) as never);
|
||||
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(
|
||||
over.cycleTrackingEnabled === undefined
|
||||
? null
|
||||
: ({ cycleTrackingEnabled: over.cycleTrackingEnabled } as never),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getAssistantFlags).mockResolvedValue({ coach: true } as never);
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me/modules", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/modules"),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns every toggleable module on for a fresh user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser();
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/modules"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: { modules: ModuleMap } };
|
||||
expect(env.data.modules.mood).toBe(true);
|
||||
expect(env.data.modules.sleep).toBe(true);
|
||||
expect(env.data.modules.insights).toBe(true);
|
||||
// cycle gender-derived (null gender ⇒ off); coach delegated (on).
|
||||
expect(env.data.modules.cycle).toBe(false);
|
||||
expect(env.data.modules.coach).toBe(true);
|
||||
});
|
||||
|
||||
it("reflects a disabled module from the persisted allowlist", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser({ modulePreferencesJson: { glucose: false } });
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/modules"),
|
||||
);
|
||||
const env = (await res.json()) as { data: { modules: ModuleMap } };
|
||||
expect(env.data.modules.glucose).toBe(false);
|
||||
expect(env.data.modules.mood).toBe(true);
|
||||
});
|
||||
|
||||
it("reflects coach delegation (operator master flag off)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(getAssistantFlags).mockResolvedValue({ coach: false } as never);
|
||||
primeUser();
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/modules"),
|
||||
);
|
||||
const env = (await res.json()) as { data: { modules: ModuleMap } };
|
||||
expect(env.data.modules.coach).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/auth/me/modules", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ mood: false }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables a module and persists the merged allowlist", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser({ modulePreferencesJson: null });
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ mood: false }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: { modules: ModuleMap } };
|
||||
expect(env.data.modules.mood).toBe(false);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { modulePreferencesJson: { mood: false } },
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.modules.update",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({ changed: ["mood"] }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("merges over the existing allowlist field-by-field (siblings intact)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// user previously disabled glucose; now disabling sleep too.
|
||||
primeUser({ modulePreferencesJson: { glucose: false } });
|
||||
|
||||
await (PATCH as (r: Request) => Promise<Response>)(mkPatch({ sleep: false }));
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { modulePreferencesJson: { glucose: false, sleep: false } },
|
||||
});
|
||||
});
|
||||
|
||||
it("can re-enable a previously disabled module", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser({ modulePreferencesJson: { glucose: false } });
|
||||
|
||||
await (PATCH as (r: Request) => Promise<Response>)(mkPatch({ glucose: true }));
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { modulePreferencesJson: { glucose: true } },
|
||||
});
|
||||
});
|
||||
|
||||
it("REFUSES to disable a core module (strict schema 422)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser();
|
||||
|
||||
for (const core of ["weight", "bloodPressure", "pulse", "medications"]) {
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ [core]: false }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
}
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an unknown module key with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser();
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ notAModule: false }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a non-boolean value with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser();
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ mood: "false" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects malformed JSON with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const req = new Request("http://localhost/api/auth/me/modules", {
|
||||
method: "PATCH",
|
||||
body: "{ not valid json",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 429 when the per-user rate-limit fires", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 30_000,
|
||||
});
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ mood: false }),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops a previously-corrupted stored blob to clean booleans on merge", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// junk in the stored row must not leak into the persisted merge.
|
||||
primeUser({ modulePreferencesJson: { mood: "yes", sleep: false } });
|
||||
|
||||
await (PATCH as (r: Request) => Promise<Response>)(mkPatch({ labs: false }));
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { modulePreferencesJson: { sleep: false, labs: false } },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* v1.18.0 — per-user module enable/disable preferences.
|
||||
*
|
||||
* GET /api/auth/me/modules — resolved `{ <moduleKey>: boolean }` map
|
||||
* for every toggleable module (cycle/coach
|
||||
* reflect their real delegated state).
|
||||
* PATCH /api/auth/me/modules — body `{ <moduleKey>?: boolean }`. Merges
|
||||
* the supplied keys into the persisted
|
||||
* `modulePreferencesJson` DISABLED allowlist
|
||||
* (field-by-field, no mass assignment) and
|
||||
* 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.
|
||||
*
|
||||
* `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 {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { resolveModuleMap, normalisePrefs } from "@/lib/modules/gate";
|
||||
import { modulePrefsPatchSchema } from "@/lib/validations/modules";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const PATCH_RATE_LIMIT = 60;
|
||||
const PATCH_WINDOW_MS = 60_000;
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "auth.me.modules.get" } });
|
||||
|
||||
const modules = await resolveModuleMap(user.id);
|
||||
return apiSuccess({ modules });
|
||||
});
|
||||
|
||||
export const PATCH = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`modules:patch:${user.id}`,
|
||||
PATCH_RATE_LIMIT,
|
||||
PATCH_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
throw new HttpError(422, "modules.body.invalid_json");
|
||||
}
|
||||
|
||||
const parsed = modulePrefsPatchSchema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "auth.me.modules.patch.invalid_shape" },
|
||||
meta: { issues: parsed.error.issues.length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "modules.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
// Merge the supplied keys into the persisted DISABLED allowlist,
|
||||
// field-by-field. `normalisePrefs` strips any junk from the stored
|
||||
// row so a previously-corrupted blob can't poison the merge, and the
|
||||
// strict schema guarantees `parsed.data` carries only toggleable keys
|
||||
// (core domains were rejected at the 422 above).
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { modulePreferencesJson: true },
|
||||
});
|
||||
const merged: Record<string, boolean> = normalisePrefs(
|
||||
existing?.modulePreferencesJson,
|
||||
);
|
||||
const changed: string[] = [];
|
||||
for (const [key, value] of Object.entries(parsed.data)) {
|
||||
if (value === undefined) continue;
|
||||
merged[key] = value;
|
||||
changed.push(key);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { modulePreferencesJson: merged },
|
||||
});
|
||||
|
||||
const modules = await resolveModuleMap(user.id);
|
||||
|
||||
await auditLog("user.modules.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: { changed },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.me.modules.patch" },
|
||||
meta: { changed },
|
||||
});
|
||||
|
||||
return apiSuccess({ modules });
|
||||
});
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -21,12 +22,18 @@ 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] = await Promise.all([
|
||||
const [, cycleProfile, modules] = await Promise.all([
|
||||
setOnboardingPendingCookie(user.onboardingCompletedAt == null),
|
||||
prisma.cycleProfile.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { cycleTrackingEnabled: true },
|
||||
}),
|
||||
// v1.18.0 — resolved module enable/disable map for every toggleable
|
||||
// module. cycle/coach reflect their real delegated state (the cycle
|
||||
// gate / disableCoach + operator assistant flag); the rest read the
|
||||
// disabled-allowlist `modulePreferencesJson`. Default-on. Clients
|
||||
// hide a whole module surface end-to-end when its key is `false`.
|
||||
resolveModuleMap(user.id),
|
||||
]);
|
||||
const cycleTrackingEnabled = isCycleEnabled(user.gender, cycleProfile);
|
||||
|
||||
@@ -85,5 +92,12 @@ export const GET = apiHandler(async () => {
|
||||
// v1.15.0 — cycle-tracking feature gate, resolved server-side. iOS
|
||||
// hides the whole cycle tab when this is false.
|
||||
cycleTrackingEnabled,
|
||||
// v1.18.0 — module enable/disable map. `{ <moduleKey>: boolean }`
|
||||
// for every toggleable module; `false` means the module is OFF and
|
||||
// the surface should disappear end-to-end (nav, dashboard, insights,
|
||||
// …). `cycle` mirrors `cycleTrackingEnabled` and `coach` mirrors the
|
||||
// resolved `disableCoach` + operator master flag, so this map is the
|
||||
// single thing a client needs to gate every secondary domain.
|
||||
modules,
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user