mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(admin): assistant feature-flag toggle panel
New admin slug `assistant` between `ai-quality` (provider config)
and `coach-feedback` (in-app feedback inbox). The panel renders six
`<SettingsToggle>` rows — master plus five sub-flags — and writes
through a dedicated `PUT /api/admin/settings/assistant-flags`
endpoint. Optimistic UI flips on click; failure surfaces a toast
and re-invalidates the read query.
The dedicated endpoint (separate from the generic `/api/admin/settings`
PUT) lets the panel scope its audit trail to a single
`admin.settings.assistant-flags.update` action and lets the
optimistic UI bind 1:1 to a focused request/response shape. The
PUT handler echoes both the raw column values and the resolved
shape so the panel can render the master vs sub-flag distinction
visually.
Sub-toggles grey out when the master is off (UX-wise) so the
operator can see which sub-flags are individually flipped before
unmuting the master. Writes invalidate the runtime
`/api/feature-flags` cache so the operator sees the toggled
surface react within the same session.
i18n: `admin.section.assistant.{title,subtitle}` and
`admin.assistant.*` keys land in all six locales (DE proper, the
five English-leaning admin locales mirror the English copy as is
the established convention for admin strings).
This commit is contained in:
1 parent
03d0b64bb8
commit
3b56bac59a
12 files changed
+712
No files matched your search
@@ -1952,6 +1952,34 @@
|
||||
"bugReportGithubDescription": "Hinterlege GitHub-Issue-Token und Repository für eingehende Bugreports.",
|
||||
"bugReportEnabledLabel": "Bug-Reports aktiviert",
|
||||
"bugReportEnabledDescription": "Wenn deaktiviert, ist der Report-Button im Frontend nicht mehr sichtbar — Token und Repository bleiben gespeichert.",
|
||||
"assistant": {
|
||||
"title": "Assistenz-Oberflächen",
|
||||
"description": "Jeder Toggle versteckt eine modellgestützte Oberfläche app-weit, ohne die Provider-Konfiguration zu entfernen. Der Hauptschalter erzwingt jede Unter-Option aus.",
|
||||
"master": {
|
||||
"title": "Assistent aktiv",
|
||||
"description": "Hauptschalter für jede modellgestützte Oberfläche. Wenn aus, sind alle Unter-Optionen unten zwangsweise aus."
|
||||
},
|
||||
"coach": {
|
||||
"title": "Coach",
|
||||
"description": "Chat-Drawer, Launch-Button und die mobile FAB auf jeder Insights-Seite."
|
||||
},
|
||||
"briefing": {
|
||||
"title": "Tagesbriefing",
|
||||
"description": "Briefing-Karte, Hero-Erzähltext, Empfehlungen und der Regenerieren-Knopf."
|
||||
},
|
||||
"insightStatus": {
|
||||
"title": "Status-Karten",
|
||||
"description": "Pro-Metrik-Status-Karten auf jeder /insights-Unterseite."
|
||||
},
|
||||
"correlations": {
|
||||
"title": "Korrelationen",
|
||||
"description": "Erzähl-Kachel zur Korrelation auf der Insights-Übersicht."
|
||||
},
|
||||
"healthScoreExplainer": {
|
||||
"title": "Health-Score-Erklärer",
|
||||
"description": "Das ?-Symbol neben der Health-Score-Differenz. Die Zahl bleibt unabhängig davon sichtbar."
|
||||
}
|
||||
},
|
||||
"bugReportRepo": "Repository",
|
||||
"bugReportRepoPlaceholder": "owner/repo",
|
||||
"bugReportToken": "Issue-Token",
|
||||
@@ -2134,6 +2162,10 @@
|
||||
"title": "KI-Qualität",
|
||||
"subtitle": "Hilfreichkeit nach Schweregrad und Provider — Nutzer-Feedback der letzten 30 Tage."
|
||||
},
|
||||
"assistant": {
|
||||
"title": "Assistenz-Oberflächen",
|
||||
"subtitle": "Jede modellgestützte Oberfläche separat ein- oder ausschalten, ohne die Provider-Konfiguration zu entfernen."
|
||||
},
|
||||
"coach-feedback": {
|
||||
"title": "Coach-Feedback",
|
||||
"subtitle": "Hilfreich-Quote pro Coach-Prompt-Version, Tonfall und Ausführlichkeit der letzten 30 Tage."
|
||||
|
||||
@@ -1952,6 +1952,34 @@
|
||||
"bugReportGithubDescription": "Configure GitHub issue token and repository for incoming bug reports.",
|
||||
"bugReportEnabledLabel": "Bug reports enabled",
|
||||
"bugReportEnabledDescription": "When off, the in-app report button hides — token and repository stay configured.",
|
||||
"assistant": {
|
||||
"title": "Assistant Surfaces",
|
||||
"description": "Each toggle hides a model-driven surface app-wide without removing the provider configuration. The master kills every sub-flag at once.",
|
||||
"master": {
|
||||
"title": "Assistant enabled",
|
||||
"description": "Master switch for every model-driven surface. When off, every sub-flag below is forced off."
|
||||
},
|
||||
"coach": {
|
||||
"title": "Coach",
|
||||
"description": "Chat drawer, launcher button, and the mobile FAB on every Insights page."
|
||||
},
|
||||
"briefing": {
|
||||
"title": "Daily Briefing",
|
||||
"description": "Briefing card, hero strip narration, recommendations grid, and the regenerate action."
|
||||
},
|
||||
"insightStatus": {
|
||||
"title": "Status cards",
|
||||
"description": "Per-metric status cards on every /insights sub-page."
|
||||
},
|
||||
"correlations": {
|
||||
"title": "Correlations",
|
||||
"description": "Correlation tile narration on the Insights overview."
|
||||
},
|
||||
"healthScoreExplainer": {
|
||||
"title": "Health-Score explainer",
|
||||
"description": "The ? glyph next to the Health-Score delta line. The delta digit stays visible regardless."
|
||||
}
|
||||
},
|
||||
"bugReportRepo": "Repository",
|
||||
"bugReportRepoPlaceholder": "owner/repo",
|
||||
"bugReportToken": "Issue token",
|
||||
@@ -2134,6 +2162,10 @@
|
||||
"title": "AI Quality",
|
||||
"subtitle": "Helpfulness scores grouped by severity and provider, from the last 30 days of user feedback."
|
||||
},
|
||||
"assistant": {
|
||||
"title": "Assistant Surfaces",
|
||||
"subtitle": "Toggle each model-driven surface on or off without removing the provider configuration."
|
||||
},
|
||||
"coach-feedback": {
|
||||
"title": "Coach Feedback",
|
||||
"subtitle": "Helpful-rate per Coach prompt version, tone, and verbosity from the last 30 days."
|
||||
|
||||
@@ -1952,6 +1952,34 @@
|
||||
"bugReportGithubDescription": "Configure GitHub issue token and repository for incoming bug reports.",
|
||||
"bugReportEnabledLabel": "Bug reports enabled",
|
||||
"bugReportEnabledDescription": "When off, the in-app report button hides — token and repository stay configured.",
|
||||
"assistant": {
|
||||
"title": "Superficies del asistente",
|
||||
"description": "Cada conmutador oculta una superficie impulsada por el modelo en toda la aplicación sin retirar la configuración del proveedor. El interruptor maestro fuerza cada opción secundaria a off.",
|
||||
"master": {
|
||||
"title": "Asistente activado",
|
||||
"description": "Interruptor maestro para cada superficie impulsada por el modelo. Cuando está desactivado, todas las opciones secundarias se fuerzan a off."
|
||||
},
|
||||
"coach": {
|
||||
"title": "Coach",
|
||||
"description": "Drawer de chat, botón de lanzamiento y FAB móvil en cada página de Insights."
|
||||
},
|
||||
"briefing": {
|
||||
"title": "Briefing diario",
|
||||
"description": "Tarjeta de briefing, narración del hero strip, cuadrícula de recomendaciones y la acción Regenerar."
|
||||
},
|
||||
"insightStatus": {
|
||||
"title": "Tarjetas de estado",
|
||||
"description": "Tarjetas de estado por métrica en cada sub-página /insights."
|
||||
},
|
||||
"correlations": {
|
||||
"title": "Correlaciones",
|
||||
"description": "Narración de la tarjeta de correlación en la vista general de Insights."
|
||||
},
|
||||
"healthScoreExplainer": {
|
||||
"title": "Explicador de Health-Score",
|
||||
"description": "El icono ? junto a la línea de delta del Health-Score. El dígito del delta permanece visible siempre."
|
||||
}
|
||||
},
|
||||
"bugReportRepo": "Repository",
|
||||
"bugReportRepoPlaceholder": "owner/repo",
|
||||
"bugReportToken": "Issue token",
|
||||
@@ -2134,6 +2162,10 @@
|
||||
"title": "AI Quality",
|
||||
"subtitle": "Helpfulness scores grouped by severity and provider, from the last 30 days of user feedback."
|
||||
},
|
||||
"assistant": {
|
||||
"title": "Superficies del asistente",
|
||||
"subtitle": "Activa o desactiva cada superficie impulsada por el modelo sin retirar la configuración del proveedor."
|
||||
},
|
||||
"coach-feedback": {
|
||||
"title": "Coach Feedback",
|
||||
"subtitle": "Helpful-rate per Coach prompt version, tone, and verbosity from the last 30 days."
|
||||
|
||||
@@ -1952,6 +1952,34 @@
|
||||
"bugReportGithubDescription": "Configure GitHub issue token and repository for incoming bug reports.",
|
||||
"bugReportEnabledLabel": "Bug reports enabled",
|
||||
"bugReportEnabledDescription": "When off, the in-app report button hides — token and repository stay configured.",
|
||||
"assistant": {
|
||||
"title": "Surfaces d'assistance",
|
||||
"description": "Chaque bascule masque une surface pilotée par le modèle dans toute l'application sans retirer la configuration du fournisseur. Le commutateur principal force chaque option secondaire à off.",
|
||||
"master": {
|
||||
"title": "Assistant activé",
|
||||
"description": "Commutateur principal pour chaque surface pilotée par le modèle. Lorsqu'il est désactivé, toutes les options secondaires ci-dessous sont forcées à off."
|
||||
},
|
||||
"coach": {
|
||||
"title": "Coach",
|
||||
"description": "Drawer de chat, bouton de lancement, et la FAB mobile sur chaque page Insights."
|
||||
},
|
||||
"briefing": {
|
||||
"title": "Briefing quotidien",
|
||||
"description": "Carte de briefing, narration du hero strip, grille de recommandations, et l'action Régénérer."
|
||||
},
|
||||
"insightStatus": {
|
||||
"title": "Cartes de statut",
|
||||
"description": "Cartes de statut par métrique sur chaque sous-page /insights."
|
||||
},
|
||||
"correlations": {
|
||||
"title": "Corrélations",
|
||||
"description": "Narration de la tuile de corrélation sur la vue d'ensemble Insights."
|
||||
},
|
||||
"healthScoreExplainer": {
|
||||
"title": "Explicateur de Health-Score",
|
||||
"description": "L'icône ? près de la ligne de delta du Health-Score. Le chiffre du delta reste visible quoi qu'il arrive."
|
||||
}
|
||||
},
|
||||
"bugReportRepo": "Repository",
|
||||
"bugReportRepoPlaceholder": "owner/repo",
|
||||
"bugReportToken": "Issue token",
|
||||
@@ -2134,6 +2162,10 @@
|
||||
"title": "AI Quality",
|
||||
"subtitle": "Helpfulness scores grouped by severity and provider, from the last 30 days of user feedback."
|
||||
},
|
||||
"assistant": {
|
||||
"title": "Surfaces d'assistance",
|
||||
"subtitle": "Activer ou désactiver chaque surface pilotée par le modèle sans supprimer la configuration du fournisseur."
|
||||
},
|
||||
"coach-feedback": {
|
||||
"title": "Coach Feedback",
|
||||
"subtitle": "Helpful-rate per Coach prompt version, tone, and verbosity from the last 30 days."
|
||||
|
||||
@@ -1952,6 +1952,34 @@
|
||||
"bugReportGithubDescription": "Configure GitHub issue token and repository for incoming bug reports.",
|
||||
"bugReportEnabledLabel": "Bug reports enabled",
|
||||
"bugReportEnabledDescription": "When off, the in-app report button hides — token and repository stay configured.",
|
||||
"assistant": {
|
||||
"title": "Superfici dell'assistente",
|
||||
"description": "Ogni interruttore nasconde una superficie guidata dal modello in tutta l'app senza rimuovere la configurazione del provider. Il master forza ogni opzione secondaria su off.",
|
||||
"master": {
|
||||
"title": "Assistente attivo",
|
||||
"description": "Interruttore master per ogni superficie guidata dal modello. Quando spento, ogni opzione secondaria qui sotto è forzata su off."
|
||||
},
|
||||
"coach": {
|
||||
"title": "Coach",
|
||||
"description": "Drawer chat, pulsante di lancio e FAB mobile su ogni pagina Insights."
|
||||
},
|
||||
"briefing": {
|
||||
"title": "Briefing giornaliero",
|
||||
"description": "Scheda briefing, narrazione hero strip, griglia raccomandazioni e l'azione Rigenera."
|
||||
},
|
||||
"insightStatus": {
|
||||
"title": "Schede di stato",
|
||||
"description": "Schede di stato per metrica su ogni sotto-pagina /insights."
|
||||
},
|
||||
"correlations": {
|
||||
"title": "Correlazioni",
|
||||
"description": "Narrazione del tile di correlazione sulla panoramica Insights."
|
||||
},
|
||||
"healthScoreExplainer": {
|
||||
"title": "Spiegatore Health-Score",
|
||||
"description": "L'icona ? accanto alla riga del delta dell'Health-Score. La cifra del delta resta visibile in ogni caso."
|
||||
}
|
||||
},
|
||||
"bugReportRepo": "Repository",
|
||||
"bugReportRepoPlaceholder": "owner/repo",
|
||||
"bugReportToken": "Issue token",
|
||||
@@ -2134,6 +2162,10 @@
|
||||
"title": "AI Quality",
|
||||
"subtitle": "Helpfulness scores grouped by severity and provider, from the last 30 days of user feedback."
|
||||
},
|
||||
"assistant": {
|
||||
"title": "Superfici dell'assistente",
|
||||
"subtitle": "Attiva o disattiva ciascuna superficie guidata dal modello senza rimuovere la configurazione del provider."
|
||||
},
|
||||
"coach-feedback": {
|
||||
"title": "Coach Feedback",
|
||||
"subtitle": "Helpful-rate per Coach prompt version, tone, and verbosity from the last 30 days."
|
||||
|
||||
@@ -1952,6 +1952,34 @@
|
||||
"bugReportGithubDescription": "Configure GitHub issue token and repository for incoming bug reports.",
|
||||
"bugReportEnabledLabel": "Bug reports enabled",
|
||||
"bugReportEnabledDescription": "When off, the in-app report button hides — token and repository stay configured.",
|
||||
"assistant": {
|
||||
"title": "Powierzchnie asystenta",
|
||||
"description": "Każdy przełącznik ukrywa powierzchnię opartą na modelu w całej aplikacji bez usuwania konfiguracji dostawcy. Główny wyłącznik wymusza wyłączenie każdej opcji podrzędnej.",
|
||||
"master": {
|
||||
"title": "Asystent włączony",
|
||||
"description": "Główny wyłącznik dla każdej powierzchni opartej na modelu. Gdy wyłączony, każda opcja podrzędna jest wymuszana na off."
|
||||
},
|
||||
"coach": {
|
||||
"title": "Coach",
|
||||
"description": "Szuflada czatu, przycisk uruchamiający i mobilny FAB na każdej stronie Insights."
|
||||
},
|
||||
"briefing": {
|
||||
"title": "Codzienny briefing",
|
||||
"description": "Karta briefingu, narracja hero strip, siatka rekomendacji i akcja Regeneruj."
|
||||
},
|
||||
"insightStatus": {
|
||||
"title": "Karty statusu",
|
||||
"description": "Karty statusu na metrykę na każdej podstronie /insights."
|
||||
},
|
||||
"correlations": {
|
||||
"title": "Korelacje",
|
||||
"description": "Narracja kafelka korelacji w przeglądzie Insights."
|
||||
},
|
||||
"healthScoreExplainer": {
|
||||
"title": "Objaśnienie Health-Score",
|
||||
"description": "Ikona ? obok linii delty Health-Score. Cyfra delty pozostaje widoczna niezależnie."
|
||||
}
|
||||
},
|
||||
"bugReportRepo": "Repository",
|
||||
"bugReportRepoPlaceholder": "owner/repo",
|
||||
"bugReportToken": "Issue token",
|
||||
@@ -2134,6 +2162,10 @@
|
||||
"title": "AI Quality",
|
||||
"subtitle": "Helpfulness scores grouped by severity and provider, from the last 30 days of user feedback."
|
||||
},
|
||||
"assistant": {
|
||||
"title": "Powierzchnie asystenta",
|
||||
"subtitle": "Włącz lub wyłącz każdą powierzchnię opartą na modelu bez usuwania konfiguracji dostawcy."
|
||||
},
|
||||
"coach-feedback": {
|
||||
"title": "Coach Feedback",
|
||||
"subtitle": "Helpful-rate per Coach prompt version, tone, and verbosity from the last 30 days."
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { JSX } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { AiQualitySection } from "@/components/admin/ai-quality-section";
|
||||
import { AssistantSection } from "@/components/admin/assistant-section";
|
||||
import { CoachFeedbackSection } from "@/components/admin/coach-feedback-section";
|
||||
import { ApiTokenOverviewSection } from "@/components/admin/api-token-overview-section";
|
||||
import { AppLogPreviewSection } from "@/components/admin/app-log-preview-section";
|
||||
@@ -94,6 +95,15 @@ export function AdminSectionRenderer({
|
||||
<AiQualitySection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "assistant":
|
||||
return (
|
||||
<SectionFrame
|
||||
title={t("admin.section.assistant.title")}
|
||||
subtitle={t("admin.section.assistant.subtitle")}
|
||||
>
|
||||
<AssistantSection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "coach-feedback":
|
||||
return (
|
||||
<SectionFrame
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -75,6 +75,11 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [
|
||||
titleKey: "admin.section.ai-quality.title",
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
slug: "assistant",
|
||||
titleKey: "admin.section.assistant.title",
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
slug: "coach-feedback",
|
||||
titleKey: "admin.section.coach-feedback.title",
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsToggle, getApiErrorMessage } from "./_shared";
|
||||
|
||||
/**
|
||||
* v1.4.31 — operator-side panel for the six assistant feature
|
||||
* flags. The master toggle gates the whole assistant; five
|
||||
* sub-toggles carve specific surfaces (Coach, Daily Briefing,
|
||||
* per-metric status cards, correlation narration, Health-Score
|
||||
* delta explainer).
|
||||
*
|
||||
* UX:
|
||||
* - Master toggle at the top. When off, the sub-toggles are
|
||||
* visually greyed out (kept rendered so the operator can see
|
||||
* which sub-flags are individually flipped before they unmute
|
||||
* the master) but cannot be flipped via the disabled `<Switch>`.
|
||||
* - Optimistic flip + server confirm via `useMutation`; failure
|
||||
* surfaces a toast and reverts the in-flight toggle by
|
||||
* re-invalidating the read query.
|
||||
*/
|
||||
|
||||
interface AssistantFlagsResponse {
|
||||
raw: {
|
||||
assistantEnabled: boolean;
|
||||
assistantCoachEnabled: boolean;
|
||||
assistantBriefingEnabled: boolean;
|
||||
assistantInsightStatusEnabled: boolean;
|
||||
assistantCorrelationsEnabled: boolean;
|
||||
assistantHealthScoreExplainerEnabled: boolean;
|
||||
};
|
||||
resolved: {
|
||||
enabled: boolean;
|
||||
coach: boolean;
|
||||
briefing: boolean;
|
||||
insightStatus: boolean;
|
||||
correlations: boolean;
|
||||
healthScoreExplainer: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function useAssistantFlags() {
|
||||
return useQuery({
|
||||
queryKey: ["admin", "settings", "assistant-flags"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/admin/settings/assistant-flags");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return (await res.json()).data as AssistantFlagsResponse;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function useUpdateAssistantFlags() {
|
||||
const client = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
patch: Partial<AssistantFlagsResponse["raw"]>,
|
||||
): Promise<AssistantFlagsResponse> => {
|
||||
const res = await fetch("/api/admin/settings/assistant-flags", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await getApiErrorMessage(res));
|
||||
}
|
||||
return (await res.json()).data as AssistantFlagsResponse;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
client.setQueryData(
|
||||
["admin", "settings", "assistant-flags"],
|
||||
data,
|
||||
);
|
||||
// Bust the runtime `/api/feature-flags` cache so the operator
|
||||
// sees the toggled surface react within the same session.
|
||||
client.invalidateQueries({ queryKey: ["feature-flags"] });
|
||||
toast.success(t("common.saved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.settingsSaveError"),
|
||||
);
|
||||
client.invalidateQueries({
|
||||
queryKey: ["admin", "settings", "assistant-flags"],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function AssistantSection() {
|
||||
const { t } = useTranslations();
|
||||
const { data } = useAssistantFlags();
|
||||
const mutation = useUpdateAssistantFlags();
|
||||
|
||||
const raw = data?.raw;
|
||||
const masterOn = raw?.assistantEnabled ?? true;
|
||||
const disabledSubs = !masterOn || mutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border space-y-6 rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="text-primary h-5 w-5" />
|
||||
<div className="text-lg font-semibold">{t("admin.assistant.title")}</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("admin.assistant.description")}
|
||||
</p>
|
||||
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.master.title")}
|
||||
description={t("admin.assistant.master.description")}
|
||||
checked={raw?.assistantEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantEnabled: checked })
|
||||
}
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
|
||||
<div className="border-border space-y-4 border-t pt-4">
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.coach.title")}
|
||||
description={t("admin.assistant.coach.description")}
|
||||
checked={raw?.assistantCoachEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantCoachEnabled: checked })
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.briefing.title")}
|
||||
description={t("admin.assistant.briefing.description")}
|
||||
checked={raw?.assistantBriefingEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantBriefingEnabled: checked })
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.insightStatus.title")}
|
||||
description={t("admin.assistant.insightStatus.description")}
|
||||
checked={raw?.assistantInsightStatusEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantInsightStatusEnabled: checked })
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.correlations.title")}
|
||||
description={t("admin.assistant.correlations.description")}
|
||||
checked={raw?.assistantCorrelationsEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantCorrelationsEnabled: checked })
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.healthScoreExplainer.title")}
|
||||
description={t("admin.assistant.healthScoreExplainer.description")}
|
||||
checked={raw?.assistantHealthScoreExplainerEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({
|
||||
assistantHealthScoreExplainerEnabled: checked,
|
||||
})
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,11 @@ export const ADMIN_SECTION_SLUGS = [
|
||||
"services",
|
||||
"integrations",
|
||||
"ai-quality",
|
||||
// v1.4.31 — operator-side assistant feature-flag toggles. Sits
|
||||
// between `ai-quality` (provider config) and `coach-feedback`
|
||||
// (in-app feedback inbox) per
|
||||
// `.planning/research/v15-assistant-optional.md` Part B.
|
||||
"assistant",
|
||||
"coach-feedback",
|
||||
"feedback",
|
||||
"reminders",
|
||||
|
||||
Reference in new issue
Block a user