Files
HealthLog/src/app/api/admin/settings/assistant-flags/route.ts
T
Marc-André Bombeck 65677c2112 fix(api): bound every JSON body read
Raising the middleware body cap to 512 MB for archive imports removed
the implicit ceiling that protected JSON routes, so any bulk endpoint
would buffer an arbitrarily large body before validation ran. Every
JSON body read under /api now carries an explicit byte cap sized to its
legitimate payload (64 KB for single-record routes up to 16 MB for the
measurement import), webhooks cap before signature work, and oversized
bodies return 413.
2026-06-10 11:36:51 +02:00

171 lines
5.5 KiB
TypeScript

/**
* `PUT /api/admin/settings/assistant-flags` — operator-side flip
* surface for the six assistant feature flags.
*
* Dedicated endpoint (separate from the generic
* `/api/admin/settings` PUT) so the admin panel can wire its
* optimistic UI against a focused request/response shape and the
* audit trail carries `admin.settings.assistant-flags.update` as a
* single action rather than a noisy diff of the omnibus settings
* row.
*
* Request shape (every field optional — partial flips are allowed):
*
* {
* "assistantEnabled": true,
* "assistantCoachEnabled": false,
* ...
* }
*
* Response: the resolved flag matrix (master kills every sub-flag
* before the shape leaves the handler) plus the raw column values
* so the admin UI can render the master vs sub-flag distinction
* visually.
*
* `requireAdmin()` gates the route — non-admins get 403.
*/
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 { resolveAssistantFlags } from "@/lib/feature-flags";
import { annotate } from "@/lib/logging/context";
const assistantFlagsSchema = z
.object({
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();
type AssistantFlagsRow = {
assistantEnabled: boolean;
assistantCoachEnabled: boolean;
assistantBriefingEnabled: boolean;
assistantInsightStatusEnabled: boolean;
assistantCorrelationsEnabled: boolean;
assistantHealthScoreExplainerEnabled: boolean;
};
function buildResponseShape(row: AssistantFlagsRow) {
const resolved = resolveAssistantFlags({
enabled: row.assistantEnabled,
coach: row.assistantCoachEnabled,
briefing: row.assistantBriefingEnabled,
insightStatus: row.assistantInsightStatusEnabled,
correlations: row.assistantCorrelationsEnabled,
healthScoreExplainer: row.assistantHealthScoreExplainerEnabled,
});
return {
raw: {
assistantEnabled: row.assistantEnabled,
assistantCoachEnabled: row.assistantCoachEnabled,
assistantBriefingEnabled: row.assistantBriefingEnabled,
assistantInsightStatusEnabled: row.assistantInsightStatusEnabled,
assistantCorrelationsEnabled: row.assistantCorrelationsEnabled,
assistantHealthScoreExplainerEnabled:
row.assistantHealthScoreExplainerEnabled,
},
resolved,
};
}
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
await requireAdmin();
annotate({ action: { name: "admin.settings.assistant-flags.get" } });
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: true,
assistantHealthScoreExplainerEnabled: true,
},
});
const row: AssistantFlagsRow = {
assistantEnabled: settings?.assistantEnabled ?? true,
assistantCoachEnabled: settings?.assistantCoachEnabled ?? true,
assistantBriefingEnabled: settings?.assistantBriefingEnabled ?? true,
assistantInsightStatusEnabled:
settings?.assistantInsightStatusEnabled ?? true,
assistantCorrelationsEnabled:
settings?.assistantCorrelationsEnabled ?? true,
assistantHealthScoreExplainerEnabled:
settings?.assistantHealthScoreExplainerEnabled ?? true,
};
return apiSuccess(buildResponseShape(row));
});
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.settings.assistant-flags.update" } });
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = assistantFlagsSchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const updates: Partial<AssistantFlagsRow> = {};
const auditDetails: Record<string, unknown> = {};
for (const [key, value] of Object.entries(parsed.data)) {
if (typeof value === "boolean") {
(updates as Record<string, boolean>)[key] = value;
auditDetails[key] = value;
}
}
if (Object.keys(updates).length === 0) {
return apiError("No valid fields", 422);
}
const settings = await prisma.appSettings.upsert({
where: { id: "singleton" },
update: updates,
create: { id: "singleton", ...updates },
});
await auditLog("admin.settings.assistant-flags.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: auditDetails,
});
const row: AssistantFlagsRow = {
assistantEnabled: settings.assistantEnabled,
assistantCoachEnabled: settings.assistantCoachEnabled,
assistantBriefingEnabled: settings.assistantBriefingEnabled,
assistantInsightStatusEnabled: settings.assistantInsightStatusEnabled,
assistantCorrelationsEnabled: settings.assistantCorrelationsEnabled,
assistantHealthScoreExplainerEnabled:
settings.assistantHealthScoreExplainerEnabled,
};
return apiSuccess(buildResponseShape(row));
});