mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
feat(modules): add operator-level server-wide module availability
Second layer of the module enable/disable model: an operator can turn a module off for the whole server, and an operator-disabled module is off for every account regardless of personal preference — mirroring the coach master flag above the per-user opt-out. Storage is a DISABLED allowlist on AppSettings.moduleAvailabilityJson (absent/empty/true = available, explicit false = disabled), resolved by getOperatorModuleAvailability(). The gate now resolves every toggleable module as operatorAvailable(key) AND userEnabled(key); cycle and coach keep their existing delegation but are still AND-ed with operator availability. Core domains are never disableable. GET/PATCH /api/admin/settings/module-availability is cookie-only (requireAdmin); a strict per-module-key schema rejects core domains and unknown keys at the boundary.
This commit is contained in:
1 parent
3cae7beda8
commit
5d55347dd0
7 files changed
+667
-30
No files matched your search
@@ -0,0 +1,166 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: {
|
||||
findUnique: vi.fn(),
|
||||
upsert: 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("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, PATCH } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const ADMIN_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "admin-1", username: "testuser", role: "ADMIN" as const },
|
||||
};
|
||||
const USER_OK = {
|
||||
session: { id: "sess-2", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "bob", role: "USER" as const },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/admin/settings/module-availability", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
|
||||
new NextRequest("http://localhost/api/admin/settings/module-availability"),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 403 when the caller is not an admin (cookie-only boundary)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(USER_OK as never);
|
||||
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
|
||||
new NextRequest("http://localhost/api/admin/settings/module-availability"),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns an all-available map for a fresh install (null column)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
|
||||
new NextRequest("http://localhost/api/admin/settings/module-availability"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { availability: Record<string, boolean> };
|
||||
};
|
||||
expect(body.data.availability.mood).toBe(true);
|
||||
expect(body.data.availability.coach).toBe(true);
|
||||
expect(body.data.availability.doctorReport).toBe(true);
|
||||
});
|
||||
|
||||
it("reflects a persisted operator-disabled module", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
moduleAvailabilityJson: { mood: false },
|
||||
} as never);
|
||||
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
|
||||
new NextRequest("http://localhost/api/admin/settings/module-availability"),
|
||||
);
|
||||
const body = (await res.json()) as {
|
||||
data: { availability: Record<string, boolean> };
|
||||
};
|
||||
expect(body.data.availability.mood).toBe(false);
|
||||
expect(body.data.availability.sleep).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/admin/settings/module-availability", () => {
|
||||
function patchReq(payload: object) {
|
||||
return new NextRequest(
|
||||
"http://localhost/api/admin/settings/module-availability",
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
it("returns 403 to a non-admin caller", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(USER_OK as never);
|
||||
const res = await PATCH(patchReq({ mood: false }));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("disables a module server-wide and echoes the resolved map", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
moduleAvailabilityJson: { mood: false },
|
||||
} as never);
|
||||
|
||||
const res = await PATCH(patchReq({ mood: false }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { availability: Record<string, boolean> };
|
||||
};
|
||||
expect(body.data.availability.mood).toBe(false);
|
||||
expect(body.data.availability.glucose).toBe(true);
|
||||
});
|
||||
|
||||
it("merges onto the existing blob (partial patch keeps other keys)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
moduleAvailabilityJson: { sleep: false },
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
moduleAvailabilityJson: { sleep: false, mood: false },
|
||||
} as never);
|
||||
|
||||
const res = await PATCH(patchReq({ mood: false }));
|
||||
expect(res.status).toBe(200);
|
||||
// The upsert must carry BOTH the pre-existing sleep:false and the new
|
||||
// mood:false — the partial patch never drops untouched keys.
|
||||
const upsertArg = vi.mocked(prisma.appSettings.upsert).mock.calls[0][0];
|
||||
expect(
|
||||
(upsertArg.update as { moduleAvailabilityJson: Record<string, boolean> })
|
||||
.moduleAvailabilityJson,
|
||||
).toEqual({ sleep: false, mood: false });
|
||||
});
|
||||
|
||||
it("rejects an empty body", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
|
||||
const res = await PATCH(patchReq({}));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("rejects a core domain key strictly (weight can never be disabled)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
|
||||
const res = await PATCH(patchReq({ weight: false }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("rejects an unknown module key strictly", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
|
||||
const res = await PATCH(patchReq({ potato: false }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* v1.18.0 — `GET` / `PATCH /api/admin/settings/module-availability` —
|
||||
* operator-side surface for the server-wide module availability matrix.
|
||||
*
|
||||
* The SECOND layer of the two-layer module model. A module the operator
|
||||
* turns off here is off for EVERY account regardless of that account's
|
||||
* personal preference — mirroring the coach master flag
|
||||
* (`assistant_coach_enabled`) sitting above the per-user
|
||||
* `User.disableCoach`. The gate (`src/lib/modules/gate.ts`) AND-s this
|
||||
* operator availability with the per-user opt-out, and `GET /api/auth/me`
|
||||
* already projects the resolved (operator AND user) map, so web + iOS need
|
||||
* no extra logic.
|
||||
*
|
||||
* Dedicated endpoint (separate from the generic `/api/admin/settings`)
|
||||
* so the admin panel can wire an optimistic matrix against a focused
|
||||
* request/response shape and the audit trail carries a single
|
||||
* `admin.settings.module-availability.update` action.
|
||||
*
|
||||
* Request shape — a partial DISABLED-allowlist patch keyed by toggleable
|
||||
* module key; every field optional; `true` ⇒ available, `false` ⇒ disabled
|
||||
* server-wide:
|
||||
*
|
||||
* { "mood": false, "glucose": true }
|
||||
*
|
||||
* Core domains (weight, BP, pulse, medications) are NOT module keys; the
|
||||
* `.strict()` schema rejects them, so the measurement engine + meds can
|
||||
* never be disabled through this route.
|
||||
*
|
||||
* `requireAdmin()` gates the route — cookie-only by construction; a Bearer
|
||||
* token (even `["*"]` scope) cannot reach it.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
mergeAvailabilityPatch,
|
||||
resolveOperatorAvailability,
|
||||
} from "@/lib/modules/operator-availability";
|
||||
import { MODULE_KEYS } from "@/lib/modules/registry";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Strict per-module boolean patch. Building the shape from `MODULE_KEYS`
|
||||
* keeps the schema exhaustive and rejects any non-module key (including
|
||||
* the four core domains) at the validation boundary.
|
||||
*/
|
||||
const moduleAvailabilitySchema = z
|
||||
.object(
|
||||
Object.fromEntries(
|
||||
MODULE_KEYS.map((key) => [key, z.boolean().optional()]),
|
||||
) as Record<(typeof MODULE_KEYS)[number], z.ZodOptional<z.ZodBoolean>>,
|
||||
)
|
||||
.strict();
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.settings.module-availability.get" } });
|
||||
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { moduleAvailabilityJson: true },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
availability: resolveOperatorAvailability(settings?.moduleAvailabilityJson),
|
||||
});
|
||||
});
|
||||
|
||||
export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.settings.module-availability.update" } });
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = moduleAvailabilitySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const patch: Record<string, boolean> = {};
|
||||
for (const [key, value] of Object.entries(parsed.data)) {
|
||||
if (typeof value === "boolean") {
|
||||
patch[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
return apiError("No valid fields", 422);
|
||||
}
|
||||
|
||||
// Merge onto the existing blob so a partial PATCH leaves untouched keys
|
||||
// intact; the merge drops any non-module key defensively.
|
||||
const existing = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { moduleAvailabilityJson: true },
|
||||
});
|
||||
const merged = mergeAvailabilityPatch(
|
||||
existing?.moduleAvailabilityJson,
|
||||
patch,
|
||||
);
|
||||
|
||||
const settings = await prisma.appSettings.upsert({
|
||||
where: { id: "singleton" },
|
||||
update: { moduleAvailabilityJson: merged },
|
||||
create: { id: "singleton", moduleAvailabilityJson: merged },
|
||||
select: { moduleAvailabilityJson: true },
|
||||
});
|
||||
|
||||
await auditLog("admin.settings.module-availability.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: patch,
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
availability: resolveOperatorAvailability(settings.moduleAvailabilityJson),
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user