chore(release): v1.4.31

Operator-side assistant feature-flag matrix, /insights tab-strip blocking fix on mobile, and the GHCR-propagation-race Coolify auto-deploy fix. Full notes in CHANGELOG.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-16 14:19:57 +02:00
1 parent 175e3aea97
commit 5ea813e20c
60 files changed
+2790 -30

No files matched your search

@@ -0,0 +1,157 @@
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, PUT } 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: "marc", 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 },
};
const ALL_ON = {
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: true,
assistantHealthScoreExplainerEnabled: true,
};
beforeEach(() => {
vi.resetAllMocks();
});
describe("GET /api/admin/settings/assistant-flags", () => {
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/assistant-flags"),
);
expect(res.status).toBe(401);
});
it("returns 403 when the caller is not an admin", 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/assistant-flags"),
);
expect(res.status).toBe(403);
});
it("returns the all-on raw + resolved shape for a fresh install", 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/assistant-flags"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
raw: Record<string, boolean>;
resolved: Record<string, boolean>;
};
};
expect(body.data.raw.assistantEnabled).toBe(true);
expect(body.data.resolved.coach).toBe(true);
});
});
describe("PUT /api/admin/settings/assistant-flags", () => {
function putReq(payload: object) {
return new NextRequest(
"http://localhost/api/admin/settings/assistant-flags",
{
method: "PUT",
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 PUT(putReq({ assistantCoachEnabled: false }));
expect(res.status).toBe(403);
});
it("flips a sub-flag and echoes the resolved shape", async () => {
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
...ALL_ON,
assistantCoachEnabled: false,
} as never);
const res = await PUT(putReq({ assistantCoachEnabled: false }));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { raw: Record<string, boolean>; resolved: Record<string, boolean> };
};
expect(body.data.raw.assistantCoachEnabled).toBe(false);
expect(body.data.resolved.coach).toBe(false);
expect(body.data.resolved.briefing).toBe(true);
});
it("forces every sub-flag false when the master is flipped off", async () => {
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
...ALL_ON,
assistantEnabled: false,
} as never);
const res = await PUT(putReq({ assistantEnabled: false }));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { raw: Record<string, boolean>; resolved: Record<string, boolean> };
};
expect(body.data.resolved.enabled).toBe(false);
expect(body.data.resolved.coach).toBe(false);
expect(body.data.resolved.briefing).toBe(false);
expect(body.data.resolved.insightStatus).toBe(false);
expect(body.data.resolved.correlations).toBe(false);
expect(body.data.resolved.healthScoreExplainer).toBe(false);
});
it("rejects an empty body", async () => {
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
const res = await PUT(putReq({}));
expect(res.status).toBe(422);
});
it("rejects unknown fields strictly", async () => {
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
const res = await PUT(putReq({ assistantPotatoEnabled: false }));
expect(res.status).toBe(422);
});
});
@@ -0,0 +1,166 @@
/**
* `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,
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);
if (jsonError) return jsonError;
const parsed = assistantFlagsSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 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));
});