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
+121
View File
@@ -0,0 +1,121 @@
/** Benachrichtigungsregeln, Protokoll und Testversand. */
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { toast } from "@/store/toast";
import type {
MessageResponse,
NotificationLogEntry,
NotificationRule,
NotificationRuleInput,
NotificationRunResult,
NotificationSettings,
TestSendResponse,
} from "@/types/api";
const KANAL_NAMEN = { smtp: "E-Mail", apprise: "Apprise" } as const;
export function useNotificationSettings() {
return useQuery({
queryKey: ["notifications", "settings"],
queryFn: () => api.get<NotificationSettings>("/notifications/settings"),
});
}
export function useNotificationRules() {
return useQuery({
queryKey: ["notifications", "rules"],
queryFn: () => api.get<NotificationRule[]>("/notifications/rules"),
});
}
export function useNotificationLog(limit = 50) {
return useQuery({
queryKey: ["notifications", "log", limit],
queryFn: () => api.get<NotificationLogEntry[]>("/notifications/log", { limit }),
});
}
export function useSaveNotificationRule() {
const client = useQueryClient();
return useMutation({
mutationFn: ({
id,
daten,
}: {
id?: number;
daten: NotificationRuleInput | Partial<NotificationRuleInput>;
}) =>
id
? api.patch<NotificationRule>(`/notifications/rules/${id}`, daten)
: api.post<NotificationRule>("/notifications/rules", daten),
onSuccess: (_regel, variablen) => {
void client.invalidateQueries({ queryKey: ["notifications"] });
toast.success(variablen.id ? "Regel gespeichert." : "Regel angelegt.");
},
});
}
export function useDeleteNotificationRule() {
const client = useQueryClient();
return useMutation({
mutationFn: (id: number) => api.del<MessageResponse>(`/notifications/rules/${id}`),
onSuccess: () => {
void client.invalidateQueries({ queryKey: ["notifications"] });
toast.success("Regel gelöscht.");
},
});
}
export function useSendTestNotification() {
return useMutation({
mutationFn: (target?: string) =>
api.post<TestSendResponse>("/notifications/test", { target: target || null }),
onSuccess: (ergebnis) => {
const zugestellt = ergebnis.results
.filter((eintrag) => eintrag.sent)
.map((eintrag) => KANAL_NAMEN[eintrag.channel]);
const gescheitert = ergebnis.results.filter(
(eintrag) => eintrag.configured && !eintrag.sent,
);
if (zugestellt.length > 0) {
toast.success(
`Testnachricht versendet über ${zugestellt.join(" und ")}.`,
gescheitert.length > 0
? `Fehlgeschlagen: ${gescheitert
.map((eintrag) => `${KANAL_NAMEN[eintrag.channel]} (${eintrag.error})`)
.join(", ")}`
: undefined,
);
return;
}
if (gescheitert.length > 0) {
toast.error(
"Der Testversand ist fehlgeschlagen.",
gescheitert.map((eintrag) => eintrag.error).join(" · "),
);
return;
}
toast.info("Kein Kanal eingerichtet.", "Trage SMTP- oder Apprise-Daten in die Umgebung ein.");
},
});
}
export function useRunNotifications() {
const client = useQueryClient();
return useMutation({
mutationFn: () => api.post<NotificationRunResult>("/notifications/run"),
onSuccess: (ergebnis) => {
void client.invalidateQueries({ queryKey: ["notifications"] });
toast.success(
ergebnis.sent > 0
? `${ergebnis.sent} Benachrichtigung${ergebnis.sent === 1 ? "" : "en"} versendet.`
: "Nichts zu melden.",
`${ergebnis.checked} geprüft, ${ergebnis.skipped} bereits gemeldet.`,
);
},
});
}