feat(notifications): täglicher Lauf über SMTP und Apprise

- Kanäle SMTP (HTML-Mail mit Logos als CID-Anhang) und Apprise, beide
  blockierenden Bibliotheken laufen in einem Thread
- Vier Anlässe: Fälligkeiten im Vorlauf, Kündigungsfristen in drei Stufen,
  überschrittene Budgets je Monat, Vertragsverlängerungen im Folgemonat
- APScheduler im Anwendungsprozess, täglich 07:00 Europe/Berlin, räumt zugleich
  abgelaufene Sitzungen auf
- Duplikatsschutz über den Zieltag statt den Versandtag; fehlgeschlagener
  Versand wird beim nächsten Lauf erneut versucht
- Endpunkte für Regeln, Protokoll, Testversand und sofortigen Lauf
- Einstellungsseite mit Einrichtungsstand, Regelpflege und Protokoll
- 25 neue Backend-Tests (260 gesamt), 9 neue Frontend-Tests (74 gesamt)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
This commit is contained in:
moneyfy
2026-09-09 17:04:30 +02:00
co-authored by Claude Opus 5
parent 0adf154049
commit 54c59c9f71
17 changed files with 2500 additions and 1 deletions
@@ -0,0 +1,214 @@
/** Tests der Benachrichtigungs-Einstellungen. */
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NotificationSettings } from "@/components/NotificationSettings";
import { renderWithProviders } from "@/test/utils";
import type { NotificationRule, NotificationSettings as Einstellungen } from "@/types/api";
const EINSTELLUNGEN: Einstellungen = {
enabled: true,
scheduler_enabled: true,
run_at: "07:00",
timezone: "Europe/Berlin",
next_run_at: "2026-03-11T07:00:00+01:00",
channels: [
{ channel: "smtp", configured: true, detail: "Einsatzbereit." },
{ channel: "apprise", configured: false, detail: "APPRISE_URLS setzen, komma-separiert." },
],
};
const REGEL: NotificationRule = {
id: 1,
type: "due_soon",
channel: "smtp",
lead_days: 3,
target: null,
is_active: true,
created_at: "2026-03-01T10:00:00+01:00",
};
function mockApi(
overrides: {
rules?: NotificationRule[];
/** "sent" liefert Erfolg, "failed" einen Fehler, "unconfigured" gar keinen Kanal. */
test?: "sent" | "failed" | "unconfigured";
} = {},
) {
const anfragen: { url: string; method: string; body: unknown }[] = [];
const json = (daten: unknown, status = 200) =>
new Response(JSON.stringify(daten), {
status,
headers: { "content-type": "application/json" },
});
vi.stubGlobal(
"fetch",
vi.fn(async (eingabe: RequestInfo | URL, init?: RequestInit) => {
const url = typeof eingabe === "string" ? eingabe : eingabe.toString();
const methode = init?.method ?? "GET";
anfragen.push({
url,
method: methode,
body: typeof init?.body === "string" ? JSON.parse(init.body) : null,
});
if (url.includes("/notifications/settings")) return json(EINSTELLUNGEN);
if (url.includes("/notifications/log")) return json([]);
if (url.includes("/notifications/test")) {
const modus = overrides.test ?? "sent";
const smtp =
modus === "sent"
? { channel: "smtp", configured: true, sent: true, error: null }
: modus === "failed"
? { channel: "smtp", configured: true, sent: false, error: "Server weg" }
: { channel: "smtp", configured: false, sent: false, error: null };
return json({
results: [smtp, { channel: "apprise", configured: false, sent: false, error: null }],
any_sent: modus === "sent",
});
}
if (url.includes("/notifications/run")) {
return json({ checked: 4, sent: 2, skipped: 2, failed: 0 });
}
if (url.includes("/notifications/rules")) {
if (methode === "POST") return json({ ...REGEL, id: 9 }, 201);
return json(overrides.rules ?? [REGEL]);
}
return json({});
}),
);
return anfragen;
}
describe("Benachrichtigungs-Einstellungen", () => {
beforeEach(() => {
mockApi();
});
it("zeigt Zeitplan und Einrichtungsstand der Kanäle", async () => {
renderWithProviders(<NotificationSettings />);
await waitFor(() => {
expect(screen.getByText(/Täglicher Lauf um 07:00 Uhr/)).toBeInTheDocument();
});
// "E-Mail" steht sowohl auf der Kanalkarte als auch als Abzeichen an der Regel.
expect(screen.getAllByText("E-Mail").length).toBeGreaterThan(0);
expect(screen.getByText("Einsatzbereit.")).toBeInTheDocument();
// Der nicht eingerichtete Kanal nennt die fehlende Variable.
expect(screen.getByText(/APPRISE_URLS setzen/)).toBeInTheDocument();
});
it("listet Regeln mit Anlass, Kanal und Vorlauf", async () => {
renderWithProviders(<NotificationSettings />);
await waitFor(() => expect(screen.getByText("Bald fällig")).toBeInTheDocument());
expect(screen.getByText("3 Tage Vorlauf")).toBeInTheDocument();
expect(screen.getByText(/Meldet Fälligkeiten innerhalb der Vorlaufzeit/)).toBeInTheDocument();
});
it("meldet einen leeren Regelsatz verständlich", async () => {
mockApi({ rules: [] });
renderWithProviders(<NotificationSettings />);
await waitFor(() => expect(screen.getByText("Keine Regeln")).toBeInTheDocument());
expect(screen.getByText(/verschickt moneyfy nichts/)).toBeInTheDocument();
});
it("löst den Testversand aus", async () => {
const anfragen = mockApi();
const nutzer = userEvent.setup();
renderWithProviders(<NotificationSettings />);
await waitFor(() => expect(screen.getByText("Zeitplan")).toBeInTheDocument());
await nutzer.click(screen.getByRole("button", { name: /Testnachricht/ }));
await waitFor(() => {
expect(anfragen.some((eintrag) => eintrag.url.includes("/notifications/test"))).toBe(true);
});
// Der Erfolg wird sichtbar zurückgemeldet.
expect(await screen.findByText(/Testnachricht versendet über E-Mail/)).toBeInTheDocument();
});
it("meldet einen fehlgeschlagenen Testversand mit Ursache", async () => {
mockApi({ test: "failed" });
const nutzer = userEvent.setup();
renderWithProviders(<NotificationSettings />);
await waitFor(() => expect(screen.getByText("Zeitplan")).toBeInTheDocument());
await nutzer.click(screen.getByRole("button", { name: /Testnachricht/ }));
expect(await screen.findByText(/Testversand ist fehlgeschlagen/)).toBeInTheDocument();
expect(screen.getByText("Server weg")).toBeInTheDocument();
});
it("weist auf fehlende Kanäle hin, statt einen Fehler zu melden", async () => {
mockApi({ test: "unconfigured" });
const nutzer = userEvent.setup();
renderWithProviders(<NotificationSettings />);
await waitFor(() => expect(screen.getByText("Zeitplan")).toBeInTheDocument());
await nutzer.click(screen.getByRole("button", { name: /Testnachricht/ }));
expect(await screen.findByText(/Kein Kanal eingerichtet/)).toBeInTheDocument();
});
it("startet einen Lauf und meldet das Ergebnis", async () => {
const anfragen = mockApi();
const nutzer = userEvent.setup();
renderWithProviders(<NotificationSettings />);
await waitFor(() => expect(screen.getByText("Zeitplan")).toBeInTheDocument());
await nutzer.click(screen.getByRole("button", { name: /Jetzt prüfen/ }));
await waitFor(() => {
expect(anfragen.some((eintrag) => eintrag.url.includes("/notifications/run"))).toBe(true);
});
expect(await screen.findByText(/2 Benachrichtigungen versendet/)).toBeInTheDocument();
expect(screen.getByText(/4 geprüft, 2 bereits gemeldet/)).toBeInTheDocument();
});
it("legt eine Regel über das Formular an", async () => {
const anfragen = mockApi();
const nutzer = userEvent.setup();
renderWithProviders(<NotificationSettings />);
await waitFor(() => expect(screen.getByText("Regeln")).toBeInTheDocument());
await nutzer.click(screen.getByRole("button", { name: /^Regel$/ }));
const dialog = await screen.findByRole("dialog", { name: "Neue Regel" });
await nutzer.selectOptions(within(dialog).getByLabelText(/Anlass/), "notice_deadline");
await nutzer.selectOptions(within(dialog).getByLabelText(/Kanal/), "apprise");
await nutzer.click(within(dialog).getByRole("button", { name: "Speichern" }));
await waitFor(() => {
const angelegt = anfragen.find(
(eintrag) => eintrag.method === "POST" && eintrag.url.includes("/notifications/rules"),
);
expect(angelegt?.body).toMatchObject({
type: "notice_deadline",
channel: "apprise",
is_active: true,
});
});
});
it("blendet den Vorlauf nur bei Fälligkeiten ein", async () => {
const nutzer = userEvent.setup();
renderWithProviders(<NotificationSettings />);
await waitFor(() => expect(screen.getByText("Regeln")).toBeInTheDocument());
await nutzer.click(screen.getByRole("button", { name: /^Regel$/ }));
const dialog = await screen.findByRole("dialog", { name: "Neue Regel" });
expect(within(dialog).getByLabelText(/Vorlauf in Tagen/)).toBeInTheDocument();
// Für ein Budget gibt es keinen Vorlauf.
await nutzer.selectOptions(within(dialog).getByLabelText(/Anlass/), "budget_exceeded");
expect(within(dialog).queryByLabelText(/Vorlauf in Tagen/)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,390 @@
/** Einstellungen der Benachrichtigungen: Kanäle, Regeln, Testversand, Protokoll. */
import { type FormEvent, useState } from "react";
import {
BellRing,
CheckCircle2,
Play,
Plus,
Send,
Trash2,
XCircle,
} from "lucide-react";
import { Button } from "@/components/ui/Button";
import { Badge, ConfirmDialog, EmptyState, Skeleton } from "@/components/ui/Feedback";
import { Checkbox, Field, Input, Select } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import {
useDeleteNotificationRule,
useNotificationLog,
useNotificationRules,
useNotificationSettings,
useRunNotifications,
useSaveNotificationRule,
useSendTestNotification,
} from "@/hooks/useNotifications";
import { formatDate } from "@/lib/format";
import type { NotificationChannel, NotificationRule, NotificationType } from "@/types/api";
const TYP_NAMEN: Record<NotificationType, string> = {
due_soon: "Bald fällig",
notice_deadline: "Kündigungsfrist",
budget_exceeded: "Budget überschritten",
contract_renewal: "Vertragsverlängerung",
};
const TYP_HINWEISE: Record<NotificationType, string> = {
due_soon: "Meldet Fälligkeiten innerhalb der Vorlaufzeit.",
notice_deadline: "Meldet 30, 14 und 7 Tage vor dem letzten Kündigungstermin.",
budget_exceeded: "Meldet überschrittene Budgets, einmal je Monat und Kategorie.",
contract_renewal: "Meldet Verträge, die sich im kommenden Monat verlängern.",
};
const KANAL_NAMEN: Record<NotificationChannel, string> = {
smtp: "E-Mail",
apprise: "Apprise",
};
export function NotificationSettings() {
const [formularOffen, setFormularOffen] = useState(false);
const [bearbeiten, setBearbeiten] = useState<NotificationRule | null>(null);
const [loeschen, setLoeschen] = useState<NotificationRule | null>(null);
const { data: einstellungen, isLoading } = useNotificationSettings();
const { data: regeln = [] } = useNotificationRules();
const { data: protokoll = [] } = useNotificationLog(25);
const entfernen = useDeleteNotificationRule();
const testen = useSendTestNotification();
const laufStarten = useRunNotifications();
if (isLoading || !einstellungen) return <Skeleton className="h-64 w-full" />;
return (
<div className="space-y-4">
<section className="card p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="flex items-center gap-2 text-sm font-semibold text-ink">
<BellRing aria-hidden className="h-4 w-4" />
Zeitplan
</h2>
<p className="mt-0.5 text-xs text-muted">
{einstellungen.scheduler_enabled
? `Täglicher Lauf um ${einstellungen.run_at} Uhr (${einstellungen.timezone}).`
: "Der Scheduler ist abgeschaltet (SCHEDULER_ENABLED=false)."}
{einstellungen.next_run_at &&
` Nächster Lauf am ${formatDate(einstellungen.next_run_at.slice(0, 10))}.`}
</p>
</div>
<div className="flex gap-2">
<Button onClick={() => testen.mutate(undefined)} loading={testen.isPending}>
<Send aria-hidden className="h-4 w-4" />
Testnachricht
</Button>
<Button onClick={() => laufStarten.mutate()} loading={laufStarten.isPending}>
<Play aria-hidden className="h-4 w-4" />
Jetzt prüfen
</Button>
</div>
</div>
<ul className="mt-3 grid gap-2 sm:grid-cols-2">
{einstellungen.channels.map((kanal) => (
<li
key={kanal.channel}
className="flex items-start gap-2 rounded-lg border border-line bg-raised p-3"
>
{kanal.configured ? (
<CheckCircle2 aria-hidden className="mt-0.5 h-4 w-4 shrink-0 text-positive" />
) : (
<XCircle aria-hidden className="mt-0.5 h-4 w-4 shrink-0 text-faint" />
)}
<div className="min-w-0">
<p className="text-sm font-medium text-ink">{KANAL_NAMEN[kanal.channel]}</p>
<p className="text-xs text-muted">{kanal.detail}</p>
</div>
</li>
))}
</ul>
</section>
<section>
<div className="mb-2 flex items-center justify-between">
<h2 className="text-sm font-semibold text-ink">Regeln</h2>
<Button
size="sm"
onClick={() => {
setBearbeiten(null);
setFormularOffen(true);
}}
>
<Plus aria-hidden className="h-3.5 w-3.5" />
Regel
</Button>
</div>
{regeln.length === 0 ? (
<EmptyState
icon={BellRing}
title="Keine Regeln"
description="Ohne Regel verschickt moneyfy nichts. Lege fest, worüber du informiert werden willst."
/>
) : (
<ul className="divide-y divide-line rounded-card border border-line">
{regeln.map((regel) => (
<li key={regel.id} className="group flex items-center gap-3 px-3 py-2.5">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-sm font-medium text-ink">{TYP_NAMEN[regel.type]}</span>
<Badge>{KANAL_NAMEN[regel.channel]}</Badge>
{regel.type === "due_soon" && (
<Badge tone="accent">{regel.lead_days} Tage Vorlauf</Badge>
)}
{!regel.is_active && <Badge tone="warning">Inaktiv</Badge>}
</div>
<p className="truncate text-xs text-faint">
{TYP_HINWEISE[regel.type]}
{regel.target && ` An: ${regel.target}`}
</p>
</div>
<div className="flex shrink-0 gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => {
setBearbeiten(regel);
setFormularOffen(true);
}}
>
Ändern
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setLoeschen(regel)}
aria-label={`Regel ${TYP_NAMEN[regel.type]} löschen`}
>
<Trash2 aria-hidden className="h-3.5 w-3.5" />
</Button>
</div>
</li>
))}
</ul>
)}
</section>
{protokoll.length > 0 && (
<section>
<h2 className="mb-2 text-sm font-semibold text-ink">Versandprotokoll</h2>
<div className="overflow-x-auto rounded-card border border-line">
<table className="w-full text-xs">
<caption className="sr-only">Zuletzt versendete Benachrichtigungen</caption>
<thead className="border-b border-line text-left text-muted">
<tr>
<th scope="col" className="px-3 py-2 font-medium">
Gesendet
</th>
<th scope="col" className="px-3 py-2 font-medium">
Bezug
</th>
<th scope="col" className="px-3 py-2 font-medium">
Zieltag
</th>
<th scope="col" className="px-3 py-2 font-medium">
Status
</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{protokoll.map((eintrag) => (
<tr key={eintrag.id}>
<td className="whitespace-nowrap px-3 py-1.5 tabular text-muted">
{formatDate(eintrag.sent_at.slice(0, 10))}
</td>
<td className="px-3 py-1.5 text-muted">
{eintrag.ref_type} {eintrag.ref_id}
</td>
<td className="whitespace-nowrap px-3 py-1.5 tabular text-muted">
{formatDate(eintrag.dedupe_day)}
</td>
<td className="px-3 py-1.5">
{eintrag.status === "sent" ? (
<Badge tone="positive">zugestellt</Badge>
) : (
<Badge tone="negative" className="max-w-xs truncate">
{eintrag.error ?? "fehlgeschlagen"}
</Badge>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
<RuleDialog
rule={bearbeiten}
open={formularOffen}
onClose={() => {
setFormularOffen(false);
setBearbeiten(null);
}}
/>
<ConfirmDialog
open={loeschen !== null}
title="Regel löschen"
description="Die Regel wird samt ihrem Versandprotokoll entfernt."
loading={entfernen.isPending}
onCancel={() => setLoeschen(null)}
onConfirm={() => {
if (loeschen) entfernen.mutate(loeschen.id, { onSuccess: () => setLoeschen(null) });
}}
/>
</div>
);
}
function RuleDialog({
rule,
open,
onClose,
}: {
rule: NotificationRule | null;
open: boolean;
onClose: () => void;
}) {
const speichern = useSaveNotificationRule();
const [typ, setTyp] = useState<NotificationType>("due_soon");
const [kanal, setKanal] = useState<NotificationChannel>("smtp");
const [vorlauf, setVorlauf] = useState(7);
const [ziel, setZiel] = useState("");
const [aktiv, setAktiv] = useState(true);
const [initialisiert, setInitialisiert] = useState<number | null | undefined>(undefined);
if (open && initialisiert !== (rule?.id ?? null)) {
setTyp(rule?.type ?? "due_soon");
setKanal(rule?.channel ?? "smtp");
setVorlauf(rule?.lead_days ?? 7);
setZiel(rule?.target ?? "");
setAktiv(rule?.is_active ?? true);
setInitialisiert(rule?.id ?? null);
}
function absenden(ereignis: FormEvent) {
ereignis.preventDefault();
speichern.mutate(
{
id: rule?.id,
daten: rule
? { channel: kanal, lead_days: vorlauf, target: ziel.trim() || null, is_active: aktiv }
: {
type: typ,
channel: kanal,
lead_days: vorlauf,
target: ziel.trim() || null,
is_active: aktiv,
},
},
{
onSuccess: () => {
setInitialisiert(undefined);
onClose();
},
},
);
}
return (
<Modal
open={open}
onClose={onClose}
size="sm"
title={rule ? "Regel ändern" : "Neue Regel"}
footer={
<>
<Button onClick={onClose}>Abbrechen</Button>
<Button variant="primary" onClick={absenden} loading={speichern.isPending}>
Speichern
</Button>
</>
}
>
<form onSubmit={absenden} className="space-y-3">
<Field label="Anlass" required hint={TYP_HINWEISE[typ]}>
{(id) => (
<Select
id={id}
value={typ}
disabled={rule !== null}
onChange={(ereignis) => setTyp(ereignis.target.value as NotificationType)}
>
{(Object.keys(TYP_NAMEN) as NotificationType[]).map((wert) => (
<option key={wert} value={wert}>
{TYP_NAMEN[wert]}
</option>
))}
</Select>
)}
</Field>
<Field label="Kanal" required>
{(id) => (
<Select
id={id}
value={kanal}
onChange={(ereignis) => setKanal(ereignis.target.value as NotificationChannel)}
>
<option value="smtp">E-Mail</option>
<option value="apprise">Apprise</option>
</Select>
)}
</Field>
{typ === "due_soon" && (
<Field label="Vorlauf in Tagen" hint="Wie früh vor der Fälligkeit gemeldet wird.">
{(id) => (
<Input
id={id}
type="number"
min={0}
max={365}
value={vorlauf}
onChange={(ereignis) => setVorlauf(Number(ereignis.target.value))}
/>
)}
</Field>
)}
<Field
label="Ziel"
hint={
kanal === "smtp"
? "Mailadresse; leer lassen für SMTP_FROM."
: "Apprise-URL; leer lassen für APPRISE_URLS."
}
>
{(id) => (
<Input
id={id}
value={ziel}
placeholder={kanal === "smtp" ? "ich@example.org" : "ntfy://host/topic"}
onChange={(ereignis) => setZiel(ereignis.target.value)}
/>
)}
</Field>
<Checkbox
label="Aktiv"
checked={aktiv}
onChange={(ereignis) => setAktiv(ereignis.target.checked)}
/>
</form>
</Modal>
);
}