diff --git a/messages/de.json b/messages/de.json index ab727c410..91e6def45 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1596,7 +1596,8 @@ "restoreConfirm": "Jetzt wiederherstellen", "restoreInProgress": "Wiederherstellung läuft...", "restoreSuccess": "Wiederherstellung abgeschlossen", - "restoreFailed": "Wiederherstellung fehlgeschlagen" + "restoreFailed": "Wiederherstellung fehlgeschlagen", + "docsLink": "Dokumentation" }, "danger-zone": { "title": "Gefahrenzone", diff --git a/messages/en.json b/messages/en.json index 1a872b0eb..9e218c427 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1596,7 +1596,8 @@ "restoreConfirm": "Restore now", "restoreInProgress": "Restoring...", "restoreSuccess": "Restore complete", - "restoreFailed": "Restore failed" + "restoreFailed": "Restore failed", + "docsLink": "Documentation" }, "danger-zone": { "title": "Danger Zone", diff --git a/src/app/api/notifications/status/route.ts b/src/app/api/notifications/status/route.ts new file mode 100644 index 000000000..998f028d6 --- /dev/null +++ b/src/app/api/notifications/status/route.ts @@ -0,0 +1,120 @@ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { CHANNEL_TYPE_LABELS } from "@/lib/notifications/types"; +import type { ChannelType } from "@/lib/notifications/types"; +import { reEnableChannel } from "@/lib/notifications/channel-state"; + +export const dynamic = "force-dynamic"; + +interface ChannelStatus { + id: string; + type: ChannelType; + label: string; + enabled: boolean; + /** + * "active" — enabled & not in cooldown + * "auto_disabled" — enabled=false AND disabledReason is set + * "manually_disabled"— enabled=false AND no disabledReason (the user + * flipped a toggle, not the dispatcher) + * "sending_paused" — enabled=true but `nextRetryAt` is in the future + */ + state: "active" | "auto_disabled" | "manually_disabled" | "sending_paused"; + disabledReason: string | null; + consecutiveFailures: number; + lastSuccessAt: string | null; + lastFailureAt: string | null; + lastFailureReason: string | null; + nextRetryAt: string | null; +} + +function deriveState(channel: { + enabled: boolean; + disabledReason: string | null; + nextRetryAt: Date | null; +}): ChannelStatus["state"] { + if (!channel.enabled) { + return channel.disabledReason ? "auto_disabled" : "manually_disabled"; + } + if (channel.nextRetryAt && channel.nextRetryAt > new Date()) { + return "sending_paused"; + } + return "active"; +} + +/** + * GET /api/notifications/status + * + * Returns the per-channel reliability state — what the Settings → + * Notifications page paints into "Active / Auto-disabled / Sending paused" + * badges + last-send / last-failure timestamps. + */ +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "notifications.status.get" } }); + + const channels = await prisma.notificationChannel.findMany({ + where: { userId: user.id }, + orderBy: { type: "asc" }, + }); + + const data: ChannelStatus[] = channels.map((ch) => ({ + id: ch.id, + type: ch.type as ChannelType, + label: CHANNEL_TYPE_LABELS[ch.type as ChannelType] ?? ch.type, + enabled: ch.enabled, + state: deriveState(ch), + disabledReason: ch.disabledReason, + consecutiveFailures: ch.consecutiveFailures, + lastSuccessAt: ch.lastSuccessAt?.toISOString() ?? null, + lastFailureAt: ch.lastFailureAt?.toISOString() ?? null, + lastFailureReason: ch.lastFailureReason, + nextRetryAt: ch.nextRetryAt?.toISOString() ?? null, + })); + + return apiSuccess({ channels: data }); +}); + +const reEnableSchema = z.object({ + channelId: z.string().min(1).max(64), +}); + +/** + * POST /api/notifications/status + * + * Re-enable an auto-disabled channel. Clears `disabledReason`, + * `consecutiveFailures`, `nextRetryAt`, flips `enabled=true`, and writes + * an audit-log entry (`notification.channel.re_enabled`). The Settings UI + * follows up with a "Send test" call so the user gets immediate feedback + * that the channel actually works again. + */ +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "notifications.status.re_enable" } }); + + let body: unknown; + try { + body = await request.json(); + } catch { + return apiError("Invalid JSON data", 422); + } + + const parsed = reEnableSchema.safeParse(body); + if (!parsed.success) return apiError("Invalid data", 422); + + const channel = await prisma.notificationChannel.findFirst({ + where: { id: parsed.data.channelId, userId: user.id }, + }); + if (!channel) return apiError("Channel not found", 404); + + await reEnableChannel({ + id: channel.id, + userId: channel.userId, + type: channel.type as ChannelType, + }); + + return apiSuccess({ ok: true }); +}); diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index f9554c387..c1b2e3bdb 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -13,6 +13,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { + BookOpen, Database, Download, History, @@ -351,7 +352,19 @@ export function BackupsSection() {

- {t("admin.section.backups.description")} + {t("admin.section.backups.description")}{" "} + {/* External docs link — Phase E will publish the matching page on + the docs site. `noopener noreferrer` because this leaves the + admin shell. */} + +

{/* Upload card — separate from the table so admins can ingest a diff --git a/src/components/settings/__tests__/notification-status-card.test.tsx b/src/components/settings/__tests__/notification-status-card.test.tsx new file mode 100644 index 000000000..b4630efd4 --- /dev/null +++ b/src/components/settings/__tests__/notification-status-card.test.tsx @@ -0,0 +1,196 @@ +/** + * SSR smoke (v1.4.15 Phase B3). + * + * The card is wrapped in TanStack Query, but each test below mocks + * `useQuery` to return a specific channel-state shape so the static + * markup snapshot tells us: + * 1. The right state badge is painted (Active / Auto-disabled / etc). + * 2. "Re-enable" only renders for `auto_disabled`. + * 3. The reason line shows up next to the badge for auto-disabled + * channels (so users know WHY their channel went silent). + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("@tanstack/react-query", () => { + const useQueryMock = vi.fn(); + return { + __esModule: true, + useQuery: useQueryMock, + useMutation: () => ({ mutate: vi.fn(), isPending: false }), + useQueryClient: () => ({ + invalidateQueries: vi.fn(), + setQueryData: vi.fn(), + }), + __useQueryMock: useQueryMock, + }; +}); + +import * as ReactQuery from "@tanstack/react-query"; +const useQueryMock = ( + ReactQuery as unknown as { __useQueryMock: ReturnType } +).__useQueryMock; + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { NotificationStatusCard } from "../notification-status-card"; + +function render(node: React.ReactElement, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +interface MockChannel { + id: string; + type: "TELEGRAM" | "NTFY" | "WEB_PUSH"; + label: string; + enabled: boolean; + state: "active" | "auto_disabled" | "manually_disabled" | "sending_paused"; + disabledReason: string | null; + consecutiveFailures: number; + lastSuccessAt: string | null; + lastFailureAt: string | null; + lastFailureReason: string | null; + nextRetryAt: string | null; +} + +function setChannels(channels: MockChannel[]) { + useQueryMock.mockReturnValue({ data: channels, isLoading: false }); +} + +describe("", () => { + it("renders empty state when no channels exist", () => { + setChannels([]); + const html = render(); + expect(html).toContain("Channel reliability"); + expect(html).toContain("No channels configured yet"); + expect(html).not.toContain('data-testid="notification-status-list"'); + }); + + it("paints Active badge for a healthy channel", () => { + setChannels([ + { + id: "ch-1", + type: "WEB_PUSH", + label: "Web Push", + enabled: true, + state: "active", + disabledReason: null, + consecutiveFailures: 0, + lastSuccessAt: "2026-05-09T10:00:00.000Z", + lastFailureAt: null, + lastFailureReason: null, + nextRetryAt: null, + }, + ]); + const html = render(); + expect(html).toMatch( + /data-testid="notification-status-row-WEB_PUSH"[^>]*data-state="active"/, + ); + expect(html).toContain(">Active"); + // No "Re-enable" button when the channel is healthy. + expect(html).not.toContain('data-testid="re-enable-WEB_PUSH"'); + }); + + it("paints Auto-disabled badge + Re-enable button + reason for an auto-disabled channel", () => { + setChannels([ + { + id: "ch-2", + type: "WEB_PUSH", + label: "Web Push", + enabled: false, + state: "auto_disabled", + disabledReason: "web_push_410_gone", + consecutiveFailures: 1, + lastSuccessAt: null, + lastFailureAt: "2026-05-09T11:00:00.000Z", + lastFailureReason: "web_push_410_gone", + nextRetryAt: null, + }, + ]); + const html = render(); + expect(html).toContain('data-state="auto_disabled"'); + expect(html).toContain(">Auto-disabled"); + expect(html).toContain('data-testid="re-enable-WEB_PUSH"'); + expect(html).toContain("web_push_410_gone"); + // Send-test must be present but disabled (the channel is dead until re-enabled). + // React renders attributes in source order — `disabled` comes before + // `data-testid` because we wrote the JSX prop that way. + expect(html).toMatch(/disabled[^>]*data-testid="send-test-WEB_PUSH"/); + }); + + it("paints Sending paused badge when nextRetryAt is in the future", () => { + setChannels([ + { + id: "ch-3", + type: "NTFY", + label: "ntfy", + enabled: true, + state: "sending_paused", + disabledReason: null, + consecutiveFailures: 2, + lastSuccessAt: null, + lastFailureAt: "2026-05-09T11:00:00.000Z", + lastFailureReason: "ntfy_503", + nextRetryAt: "2026-05-09T11:05:00.000Z", + }, + ]); + const html = render(); + expect(html).toContain('data-state="sending_paused"'); + expect(html).toContain(">Sending paused"); + expect(html).toContain('data-testid="next-retry"'); + // No re-enable button — the channel is still enabled, just cooling down. + expect(html).not.toContain('data-testid="re-enable-NTFY"'); + }); + + it("paints Manually-disabled badge when the user toggled enabled=false themselves", () => { + setChannels([ + { + id: "ch-4", + type: "TELEGRAM", + label: "Telegram", + enabled: false, + state: "manually_disabled", + disabledReason: null, + consecutiveFailures: 0, + lastSuccessAt: null, + lastFailureAt: null, + lastFailureReason: null, + nextRetryAt: null, + }, + ]); + const html = render(); + expect(html).toContain('data-state="manually_disabled"'); + expect(html).toContain(">Disabled"); + // Manually-disabled does NOT get the Re-enable button (only + // auto-disabled does — manual disable is reversed via the + // channel's own "Enabled" switch in the per-channel card). + expect(html).not.toContain('data-testid="re-enable-TELEGRAM"'); + }); + + it("renders DE labels under German locale", () => { + setChannels([ + { + id: "ch-5", + type: "WEB_PUSH", + label: "Web Push", + enabled: false, + state: "auto_disabled", + disabledReason: "web_push_410_gone", + consecutiveFailures: 5, + lastSuccessAt: null, + lastFailureAt: "2026-05-09T11:00:00.000Z", + lastFailureReason: "web_push_410_gone", + nextRetryAt: null, + }, + ]); + const html = render(, "de"); + expect(html).toContain("Kanalzuverlässigkeit"); + expect(html).toContain("Automatisch deaktiviert"); + expect(html).toContain("Wieder aktivieren"); + }); +}); diff --git a/src/components/settings/notification-status-card.tsx b/src/components/settings/notification-status-card.tsx new file mode 100644 index 000000000..1fbceda47 --- /dev/null +++ b/src/components/settings/notification-status-card.tsx @@ -0,0 +1,338 @@ +"use client"; + +/** + * Per-channel reliability status surface (v1.4.15 Phase B3). + * + * Shows for each configured Telegram / ntfy / Web Push channel: + * - State badge (Active / Auto-disabled / Sending paused / Manually off) + * - Last successful send timestamp + * - Last failure + reason + * - Consecutive failure counter + * - "Re-enable" button when auto-disabled + * - "Send test" button (delegates to per-channel test endpoints that + * already exist for Telegram, ntfy, Web Push). + * + * Pulls data from `GET /api/notifications/status`. Re-enable hits + * `POST /api/notifications/status`, which clears `disabledReason` + + * `consecutiveFailures`, then the user can fire a test send via the + * channel's existing test endpoint. + */ + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + AlertCircle, + CheckCircle2, + Clock, + Loader2, + RotateCw, + Send, +} from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/hooks/use-auth"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; + +type ChannelType = "TELEGRAM" | "NTFY" | "WEB_PUSH"; + +type ChannelState = + | "active" + | "auto_disabled" + | "manually_disabled" + | "sending_paused"; + +interface ChannelStatus { + id: string; + type: ChannelType; + label: string; + enabled: boolean; + state: ChannelState; + disabledReason: string | null; + consecutiveFailures: number; + lastSuccessAt: string | null; + lastFailureAt: string | null; + lastFailureReason: string | null; + nextRetryAt: string | null; +} + +const TEST_ENDPOINTS: Record = { + TELEGRAM: "/api/settings/telegram/test", + NTFY: "/api/settings/ntfy/test", + WEB_PUSH: "/api/notifications/web-push/test", +}; + +export function NotificationStatusCard() { + const { isAuthenticated } = useAuth(); + const { t } = useTranslations(); + const fmt = useFormatters(); + const queryClient = useQueryClient(); + + const { data, isLoading } = useQuery({ + queryKey: ["notifications", "status"], + queryFn: async () => { + const res = await fetch("/api/notifications/status"); + if (!res.ok) throw new Error("Failed"); + return ((await res.json()).data as { channels: ChannelStatus[] }) + .channels; + }, + enabled: isAuthenticated, + refetchInterval: 30_000, + }); + + const reEnable = useMutation({ + mutationFn: async (channelId: string) => { + const res = await fetch("/api/notifications/status", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ channelId }), + }); + if (!res.ok) throw new Error("Failed"); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["notifications", "status"] }); + }, + }); + + const test = useMutation({ + mutationFn: async (channel: ChannelStatus) => { + const res = await fetch(TEST_ENDPOINTS[channel.type], { method: "POST" }); + if (!res.ok) { + const json = await res.json().catch(() => ({})); + throw new Error((json as { error?: string }).error ?? "test_failed"); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["notifications", "status"] }); + }, + }); + + if (!isAuthenticated) return null; + + if (isLoading) { + return ( +
+ +
+ ); + } + + const channels = data ?? []; + if (channels.length === 0) { + return ( +
+

+ {t("settings.notificationStatus.title")} +

+

+ {t("settings.notificationStatus.emptyDescription")} +

+
+ ); + } + + return ( +
+
+

+ {t("settings.notificationStatus.title")} +

+

+ {t("settings.notificationStatus.description")} +

+
+ +
    + {channels.map((ch) => ( + fmt.dateTime(iso)} + t={t} + onReEnable={() => reEnable.mutate(ch.id)} + onTest={() => test.mutate(ch)} + reEnablePending={reEnable.isPending} + testPending={test.isPending} + /> + ))} +
+
+ ); +} + +function ChannelRow({ + channel, + formatDateTime, + t, + onReEnable, + onTest, + reEnablePending, + testPending, +}: { + channel: ChannelStatus; + formatDateTime: (iso: string | Date) => string; + t: (key: string, vars?: Record) => string; + onReEnable: () => void; + onTest: () => void; + reEnablePending: boolean; + testPending: boolean; +}) { + const stateBadge = stateBadgeFor(channel.state, t); + + return ( +
  • +
    +
    + {channel.label} + + {stateBadge.icon} + {stateBadge.label} + +
    +
    + {channel.lastSuccessAt && ( +
    +
    + {t("settings.notificationStatus.lastSuccess")}: +
    {" "} +
    + {formatDateTime(channel.lastSuccessAt)} +
    +
    + )} + {channel.lastFailureAt && ( +
    +
    + {t("settings.notificationStatus.lastFailure")}: +
    {" "} +
    + {formatDateTime(channel.lastFailureAt)} + {channel.lastFailureReason + ? ` · ${channel.lastFailureReason}` + : ""} +
    +
    + )} + {channel.consecutiveFailures > 0 && ( +
    +
    + {t("settings.notificationStatus.consecutiveFailures")}: +
    {" "} +
    + {channel.consecutiveFailures} +
    +
    + )} + {channel.disabledReason && ( +
    +
    + {t("settings.notificationStatus.disabledReason")}: +
    {" "} +
    + {channel.disabledReason} +
    +
    + )} + {channel.state === "sending_paused" && channel.nextRetryAt && ( +
    +
    + {t("settings.notificationStatus.nextRetry")}: +
    {" "} +
    + {formatDateTime(channel.nextRetryAt)} +
    +
    + )} +
    +
    + +
    + {channel.state === "auto_disabled" && ( + + )} + +
    +
  • + ); +} + +function stateBadgeFor( + state: ChannelState, + t: (key: string) => string, +): { + label: string; + variant: "default" | "outline" | "destructive" | "secondary"; + className: string; + icon: React.ReactNode; +} { + switch (state) { + case "active": + return { + label: t("settings.notificationStatus.stateActive"), + variant: "default", + className: + "border-dracula-green/30 bg-dracula-green/15 text-dracula-green", + icon: , + }; + case "auto_disabled": + return { + label: t("settings.notificationStatus.stateAutoDisabled"), + variant: "destructive", + className: "", + icon: , + }; + case "sending_paused": + return { + label: t("settings.notificationStatus.stateSendingPaused"), + variant: "outline", + className: "border-dracula-orange/40 text-dracula-orange", + icon: , + }; + case "manually_disabled": + default: + return { + label: t("settings.notificationStatus.stateManuallyDisabled"), + variant: "secondary", + className: "", + icon: , + }; + } +}