diff --git a/messages/de.json b/messages/de.json index 9f48090bd..10ceb83ec 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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." diff --git a/messages/en.json b/messages/en.json index 65cbf4fb5..7480c01c7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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." diff --git a/messages/es.json b/messages/es.json index 1b9e493a0..b32243ad4 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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." diff --git a/messages/fr.json b/messages/fr.json index 5a0f4f34e..c66045e2d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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." diff --git a/messages/it.json b/messages/it.json index 2aa68aed8..05effc46d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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." diff --git a/messages/pl.json b/messages/pl.json index 2538e0400..53a3316fa 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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." diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx index f4178eb6f..dd22db0bc 100644 --- a/src/app/admin/[section]/renderer.tsx +++ b/src/app/admin/[section]/renderer.tsx @@ -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({ ); + case "assistant": + return ( + + + + ); case "coach-feedback": return ( ({ + 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)( + 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)( + 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)( + new NextRequest("http://localhost/api/admin/settings/assistant-flags"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + raw: Record; + resolved: Record; + }; + }; + 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; resolved: Record }; + }; + 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; resolved: Record }; + }; + 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); + }); +}); diff --git a/src/app/api/admin/settings/assistant-flags/route.ts b/src/app/api/admin/settings/assistant-flags/route.ts new file mode 100644 index 000000000..dd08386e9 --- /dev/null +++ b/src/app/api/admin/settings/assistant-flags/route.ts @@ -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 = {}; + const auditDetails: Record = {}; + for (const [key, value] of Object.entries(parsed.data)) { + if (typeof value === "boolean") { + (updates as Record)[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)); +}); diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx index 14fedfd0c..0708429ad 100644 --- a/src/components/admin/admin-shell.tsx +++ b/src/components/admin/admin-shell.tsx @@ -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", diff --git a/src/components/admin/assistant-section.tsx b/src/components/admin/assistant-section.tsx new file mode 100644 index 000000000..abac363fd --- /dev/null +++ b/src/components/admin/assistant-section.tsx @@ -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 ``. + * - 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, + ): Promise => { + 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 ( +
+
+ +
{t("admin.assistant.title")}
+
+

+ {t("admin.assistant.description")} +

+ + + mutation.mutate({ assistantEnabled: checked }) + } + disabled={mutation.isPending} + /> + +
+ + mutation.mutate({ assistantCoachEnabled: checked }) + } + disabled={disabledSubs} + /> + + mutation.mutate({ assistantBriefingEnabled: checked }) + } + disabled={disabledSubs} + /> + + mutation.mutate({ assistantInsightStatusEnabled: checked }) + } + disabled={disabledSubs} + /> + + mutation.mutate({ assistantCorrelationsEnabled: checked }) + } + disabled={disabledSubs} + /> + + mutation.mutate({ + assistantHealthScoreExplainerEnabled: checked, + }) + } + disabled={disabledSubs} + /> +
+
+ ); +} diff --git a/src/components/admin/section-slugs.ts b/src/components/admin/section-slugs.ts index 895ddfded..449a07560 100644 --- a/src/components/admin/section-slugs.ts +++ b/src/components/admin/section-slugs.ts @@ -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",