From 17dd10c3b99b8f0a33c2cc184ec9c8aa25cd3b02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 00:46:21 +0200 Subject: [PATCH] feat(admin): operator-wide notification health panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the webhook + email cards on the notifications settings page, and add an operator-wide delivery-health view the per-self diagnostic could not give. A single groupBy(channel, result) over the trailing 24h of push_attempts lets the operator answer "are anyone's pushes failing?" across all users without DB shell, and a count of auto-disabled channels (enabled=false with an automated disabledReason) flags a flapping upstream that silently disabled a channel after five failures. The aggregate is anonymous — it counts channel x result, never names a user. Admin-only by construction (cookie auth, never Bearer); the window is clamped 1..168h. The panel rides the existing admin Reminders section. --- .../health/__tests__/route.test.ts | 155 ++++++++++++++++++ .../api/admin/notifications/health/route.ts | 103 ++++++++++++ src/components/admin/reminders-section.tsx | 110 ++++++++++++- .../settings/notifications-section.tsx | 12 ++ src/lib/query-keys/admin.ts | 3 + 5 files changed, 380 insertions(+), 3 deletions(-) create mode 100644 src/app/api/admin/notifications/health/__tests__/route.test.ts create mode 100644 src/app/api/admin/notifications/health/route.ts diff --git a/src/app/api/admin/notifications/health/__tests__/route.test.ts b/src/app/api/admin/notifications/health/__tests__/route.test.ts new file mode 100644 index 000000000..319776185 --- /dev/null +++ b/src/app/api/admin/notifications/health/__tests__/route.test.ts @@ -0,0 +1,155 @@ +/** + * v1.17.1 — coverage for the operator-wide notification-health admin route. + * + * Pins: + * * 401 unauthenticated / 403 non-admin (requireAdmin is cookie-only), + * * the groupBy(channel, result) aggregate folds into per-channel + * ok/error/skipped/total rows, + * * auto-disabled channels (enabled=false + disabledReason not null) are + * surfaced with a count, + * * the window is clamped to 1..168 hours (default 24). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + pushAttempt: { groupBy: vi.fn() }, + notificationChannel: { groupBy: vi.fn() }, + }, +})); + +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual( + "@/lib/api-handler", + ); + return { ...actual, requireAdmin: vi.fn() }; +}); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(() => null), + eventStorage: { run: (_e: unknown, fn: () => Promise) => fn() }, +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { requireAdmin, HttpError } from "@/lib/api-handler"; +import { NextRequest } from "next/server"; + +const ADMIN_CTX = { + session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "admin-1", username: "admin", role: "ADMIN" }, +} as never; + +function req(hours?: string): NextRequest { + const url = hours + ? `http://localhost/api/admin/notifications/health?hours=${hours}` + : "http://localhost/api/admin/notifications/health"; + return new NextRequest(url); +} + +interface HealthEnvelope { + data: { + windowHours: number; + channels: Array<{ + channel: string; + ok: number; + error: number; + skipped: number; + total: number; + }>; + autoDisabledChannels: Array<{ type: string; count: number }>; + }; + error: string | null; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.pushAttempt.groupBy).mockResolvedValue([] as never); + vi.mocked(prisma.notificationChannel.groupBy).mockResolvedValue([] as never); +}); + +describe("GET /api/admin/notifications/health — auth gates", () => { + it("returns 401 when unauthenticated", async () => { + vi.mocked(requireAdmin).mockRejectedValue( + new HttpError(401, "Not authenticated"), + ); + const res = await GET(req()); + expect(res.status).toBe(401); + }); + + it("returns 403 for a non-admin", async () => { + vi.mocked(requireAdmin).mockRejectedValue( + new HttpError(403, "Admin access required"), + ); + const res = await GET(req()); + expect(res.status).toBe(403); + }); +}); + +describe("GET /api/admin/notifications/health — aggregate", () => { + beforeEach(() => { + vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX); + }); + + it("folds groupBy rows into per-channel ok/error/skipped/total", async () => { + vi.mocked(prisma.pushAttempt.groupBy).mockResolvedValue([ + { channel: "NTFY", result: "ok", _count: { _all: 40 } }, + { channel: "NTFY", result: "error", _count: { _all: 12 } }, + { channel: "EMAIL", result: "ok", _count: { _all: 5 } }, + { channel: "EMAIL", result: "skipped", _count: { _all: 2 } }, + ] as never); + + const res = await GET(req()); + expect(res.status).toBe(200); + const body = (await res.json()) as HealthEnvelope; + + const ntfy = body.data.channels.find((c) => c.channel === "NTFY"); + expect(ntfy).toEqual({ + channel: "NTFY", + ok: 40, + error: 12, + skipped: 0, + total: 52, + }); + const email = body.data.channels.find((c) => c.channel === "EMAIL"); + expect(email).toEqual({ + channel: "EMAIL", + ok: 5, + error: 0, + skipped: 2, + total: 7, + }); + // Sorted alphabetically. + expect(body.data.channels.map((c) => c.channel)).toEqual(["EMAIL", "NTFY"]); + }); + + it("surfaces auto-disabled channels with a count", async () => { + vi.mocked(prisma.notificationChannel.groupBy).mockResolvedValue([ + { type: "WEBHOOK", _count: { _all: 3 } }, + ] as never); + + const res = await GET(req()); + const body = (await res.json()) as HealthEnvelope; + expect(body.data.autoDisabledChannels).toEqual([ + { type: "WEBHOOK", count: 3 }, + ]); + }); + + it("defaults the window to 24h and clamps out-of-range values", async () => { + let res = await GET(req("999")); + let body = (await res.json()) as HealthEnvelope; + expect(body.data.windowHours).toBe(24); + + res = await GET(req("0")); + body = (await res.json()) as HealthEnvelope; + expect(body.data.windowHours).toBe(24); + + res = await GET(req("48")); + body = (await res.json()) as HealthEnvelope; + expect(body.data.windowHours).toBe(48); + }); +}); diff --git a/src/app/api/admin/notifications/health/route.ts b/src/app/api/admin/notifications/health/route.ts new file mode 100644 index 000000000..f39ff0909 --- /dev/null +++ b/src/app/api/admin/notifications/health/route.ts @@ -0,0 +1,103 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAdmin } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess } from "@/lib/api-response"; + +export const dynamic = "force-dynamic"; + +/** + * v1.17.1 — operator-wide notification delivery health. + * + * The per-self diagnostic (`/api/admin/notifications/diagnostic`) only shows + * the calling admin's own account. On a multi-user instance the operator + * cannot answer "are anyone's pushes failing?" without DB shell. This route + * does a fleet-wide `groupBy(channel, result)` over the trailing window of the + * `push_attempts` ledger, plus a list of channels currently auto-disabled + * (across ALL users), so the operator can spot a flapping upstream that + * silently disabled a channel after five failures. + * + * Admin-only (cookie auth, never Bearer) by construction via `requireAdmin()`. + * The aggregate is anonymous: it counts channel×result, it never names a user. + */ + +interface ChannelHealthRow { + channel: string; + ok: number; + error: number; + skipped: number; + total: number; +} + +interface DisabledChannelRow { + type: string; + count: number; +} + +interface HealthPayload { + windowHours: number; + since: string; + channels: ChannelHealthRow[]; + autoDisabledChannels: DisabledChannelRow[]; +} + +export const GET = apiHandler(async (request) => { + await requireAdmin(); + annotate({ action: { name: "admin.notifications.health" } }); + + // Window is operator-tunable via ?hours=, clamped 1..168 (7 days). The + // `(channel, result, created_at)`-friendly index on push_attempts keeps the + // groupBy bounded regardless of instance lifetime volume. + const url = new URL(request.url); + const hoursRaw = Number(url.searchParams.get("hours")); + const windowHours = + Number.isFinite(hoursRaw) && hoursRaw >= 1 && hoursRaw <= 168 + ? Math.floor(hoursRaw) + : 24; + const since = new Date(Date.now() - windowHours * 60 * 60 * 1000); + + const [grouped, disabled] = await Promise.all([ + prisma.pushAttempt.groupBy({ + by: ["channel", "result"], + where: { createdAt: { gte: since } }, + _count: { _all: true }, + }), + // Channels disabled with an automated reason. A manual disable leaves + // `disabledReason` NULL (see schema comment), so a non-null reason on a + // disabled channel is the auto-disable signal we surface to the operator. + prisma.notificationChannel.groupBy({ + by: ["type"], + where: { enabled: false, disabledReason: { not: null } }, + _count: { _all: true }, + }), + ]); + + const byChannel = new Map(); + for (const row of grouped) { + const existing = + byChannel.get(row.channel) ?? + ({ channel: row.channel, ok: 0, error: 0, skipped: 0, total: 0 } satisfies ChannelHealthRow); + const count = row._count._all; + if (row.result === "ok") existing.ok += count; + else if (row.result === "error") existing.error += count; + else if (row.result === "skipped") existing.skipped += count; + existing.total += count; + byChannel.set(row.channel, existing); + } + + const channels = Array.from(byChannel.values()).sort((a, b) => + a.channel.localeCompare(b.channel), + ); + + const autoDisabledChannels: DisabledChannelRow[] = disabled + .map((d) => ({ type: d.type, count: d._count._all })) + .sort((a, b) => a.type.localeCompare(b.type)); + + const payload: HealthPayload = { + windowHours, + since: since.toISOString(), + channels, + autoDisabledChannels, + }; + + return apiSuccess(payload); +}); diff --git a/src/components/admin/reminders-section.tsx b/src/components/admin/reminders-section.tsx index 3f4449a43..3f60454fd 100644 --- a/src/components/admin/reminders-section.tsx +++ b/src/components/admin/reminders-section.tsx @@ -1,8 +1,15 @@ "use client"; import { useState } from "react"; -import { useMutation } from "@tanstack/react-query"; -import { Activity, Bell, CheckCircle2, Loader2, XCircle } from "lucide-react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + Activity, + Bell, + CheckCircle2, + HeartPulse, + Loader2, + XCircle, +} from "lucide-react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -10,8 +17,102 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; import { useAdminSettings, useUpdateSettings } from "./_shared"; -import { apiPost } from "@/lib/api/api-fetch"; +import { apiGet, apiPost } from "@/lib/api/api-fetch"; + +interface NotificationHealth { + windowHours: number; + since: string; + channels: Array<{ + channel: string; + ok: number; + error: number; + skipped: number; + total: number; + }>; + autoDisabledChannels: Array<{ type: string; count: number }>; +} + +/** + * v1.17.1 — operator-wide notification delivery-health panel. A single + * `groupBy(channel, result)` over the trailing 24h of `push_attempts` plus the + * count of auto-disabled channels across all users. Lets the operator answer + * "are anyone's pushes failing?" without DB shell. + */ +function NotificationHealthPanel() { + const { t } = useTranslations(); + const windowHours = 24; + + const { data, isLoading } = useQuery({ + queryKey: queryKeys.adminNotificationHealth(windowHours), + queryFn: async () => + apiGet( + `/api/admin/notifications/health?hours=${windowHours}`, + ), + }); + + return ( +
+ + +
+ {isLoading && ( + + )} + + {data && data.channels.length === 0 && ( +

+ {t("admin.notificationHealth.empty")} +

+ )} + + {data && data.channels.length > 0 && ( +
+ {data.channels.map((c) => ( +
+ {c.channel} + + + {t("admin.notificationHealth.ok")}: {c.ok} + + + {t("admin.notificationHealth.error")}: {c.error} + + + {t("admin.notificationHealth.skipped")}: {c.skipped} + + +
+ ))} +
+ )} + + {data && data.autoDisabledChannels.length > 0 && ( +
+

+ {t("admin.notificationHealth.autoDisabled")} +

+
+ {data.autoDisabledChannels.map((d) => ( + + {d.type} ({d.count}) + + ))} +
+
+ )} +
+
+ ); +} export function RemindersSection() { const { t } = useTranslations(); @@ -92,6 +193,7 @@ export function RemindersSection() { }); return ( + <>
{/* v1.4.22 D / F-58 — icon swap so the section header matches the admin-shell nav tile (Bell). Clock now lives on the @@ -295,5 +397,7 @@ export function RemindersSection() {
)} + + ); } diff --git a/src/components/settings/notifications-section.tsx b/src/components/settings/notifications-section.tsx index e7172f7c5..600569109 100644 --- a/src/components/settings/notifications-section.tsx +++ b/src/components/settings/notifications-section.tsx @@ -14,6 +14,8 @@ import { NotificationStatusCard } from "@/components/settings/notification-statu import { NtfyCard } from "@/components/settings/ntfy-card"; import { TelegramCard } from "@/components/settings/telegram-card"; import { WebPushCard } from "@/components/settings/web-push-card"; +import { WebhookCard } from "@/components/settings/webhook-card"; +import { EmailCard } from "@/components/settings/email-card"; import { apiGet } from "@/lib/api/api-fetch"; interface GlobalServiceAvailability { @@ -105,6 +107,16 @@ export function NotificationsSection() { )} + {/* v1.17.1 — generic outbound webhook (Gotify / Discord / Slack / + Matrix / Home Assistant in one channel). */} +
+ +
+ {/* v1.17.1 — SMTP / email. The card hides itself when the operator + hasn't configured SMTP_* env, so it never shows a dead toggle. */} +
+ +
diff --git a/src/lib/query-keys/admin.ts b/src/lib/query-keys/admin.ts index 0403206e1..5f604108e 100644 --- a/src/lib/query-keys/admin.ts +++ b/src/lib/query-keys/admin.ts @@ -63,6 +63,9 @@ export const adminKeys = { adminSettings: () => ["admin", "settings"] as const, adminStatus: () => ["admin", "status"] as const, + /** v1.17.1 — operator-wide notification delivery-health panel. */ + adminNotificationHealth: (hours: number) => + ["admin", "notification-health", hours] as const, adminUsers: () => ["admin", "users"] as const, adminTokens: () => ["admin", "tokens"] as const, adminAuditLog: (filter: unknown) => ["admin", "audit-log", filter] as const,