feat(admin): operator-wide notification health panel

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 00:46:21 +02:00
1 parent 909c2aa99e
commit 17dd10c3b9
5 files changed
+380 -3

No files matched your search

@@ -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<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return { ...actual, requireAdmin: vi.fn() };
});
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
eventStorage: { run: <T>(_e: unknown, fn: () => Promise<T>) => 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);
});
});
@@ -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<string, ChannelHealthRow>();
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);
});