diff --git a/CHANGELOG.md b/CHANGELOG.md index 5aabcd2..83e22b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,21 @@ die Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/). Serienpaar aus (bei Deuteranopie nicht unterscheidbar) und bleibt den Vorzeichen im Text vorbehalten. +- Benachrichtigungen über SMTP (HTML-Mail mit eingebetteten Firmenlogos) und + Apprise, gesteuert über Regeln je Anlass und Kanal. +- Vier Anlässe: bald fällige Posten, Kündigungsfristen (30, 14 und 7 Tage vor + dem Termin), überschrittene Budgets und anstehende Vertragsverlängerungen. +- APScheduler-Job täglich um 07:00 `Europe/Berlin`, der zugleich abgelaufene + Sitzungen aufräumt; verpasste Läufe werden einmal nachgeholt. +- Duplikatsschutz über `(rule_id, ref_type, ref_id, Zieltag)`. Maßgeblich ist der + Zieltag des Ereignisses, nicht der Versandtag – ein zweiter Lauf am selben oder + am nächsten Tag erzeugt keine zweite Nachricht. Ein fehlgeschlagener Versand + wird beim nächsten Lauf erneut versucht. +- `POST /api/notifications/test` verschickt eine Testnachricht über beide Kanäle, + `POST /api/notifications/run` führt einen Lauf sofort aus. +- Benachrichtigungen sind in den Einstellungen pflegbar, samt Einrichtungsstand + der Kanäle und Versandprotokoll. + ### Geändert - `SECRET_KEY` muss mindestens 32 Zeichen lang sein (Vorgabe von HS256); in diff --git a/README.md b/README.md index 153c8cc..7339139 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,22 @@ vollständige, kommentierte Liste. Die wichtigsten: | `COOKIE_SECURE` | `true` | Hinter HTTPS `true`, für lokales HTTP `false` | | `SCHEDULER_ENABLED` | `true` | Täglicher Benachrichtigungslauf um 07:00 | +## Benachrichtigungen + +Ein Job läuft täglich um 07:00 Uhr (`Europe/Berlin`) im Anwendungsprozess – ohne +Redis, ohne Celery. Er prüft vier Anlässe: bald fällige Posten, Kündigungsfristen +30, 14 und 7 Tage vor dem Termin, überschrittene Budgets und Vertragsverlängerungen +im kommenden Monat. + +Der Duplikatsschutz hängt am **Zieltag des Ereignisses**, nicht am Versandtag: +Eine Fälligkeit am 15.03. wird genau einmal gemeldet, unabhängig davon, an +welchem Tag des Vorlaufs der Job läuft. Ein fehlgeschlagener Versand gilt als +offen und wird beim nächsten Lauf erneut versucht. + +Als Kanäle stehen SMTP (HTML-Mail mit eingebetteten Firmenlogos) und Apprise zur +Verfügung. Über *Einstellungen → Benachrichtigungen* lassen sich Regeln pflegen, +eine Testnachricht verschicken und das Versandprotokoll einsehen. + ## Diagramme Die Serienfarben stammen aus einer Palette, die gegen die hellen und dunklen diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 36c1dac..1ef6e15 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -16,6 +16,7 @@ from app.api.routes import ( logos, me, merchants, + notifications, occurrences, recurrences, reports, @@ -45,5 +46,6 @@ protected.include_router(budgets.templates) protected.include_router(savings_goals.router) protected.include_router(reports.router) protected.include_router(export.router) +protected.include_router(notifications.router) api_router.include_router(protected) diff --git a/backend/app/api/routes/notifications.py b/backend/app/api/routes/notifications.py new file mode 100644 index 0000000..8bf1ff0 --- /dev/null +++ b/backend/app/api/routes/notifications.py @@ -0,0 +1,176 @@ +"""Benachrichtigungsregeln, Versandprotokoll und Testversand.""" + +from datetime import date + +from fastapi import APIRouter, Query, status +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError + +from app.api.deps import DbSession +from app.core.config import settings +from app.core.errors import ConflictError +from app.models import NotificationLog, NotificationRule +from app.models.enums import NotificationChannel, NotificationStatus +from app.scheduler import next_run_time +from app.schemas.common import ErrorResponse, MessageResponse +from app.schemas.notification import ( + ChannelStatusOut, + NotificationLogOut, + NotificationRuleCreate, + NotificationRuleOut, + NotificationRuleUpdate, + NotificationSettingsOut, + RunResponse, + TestResultOut, + TestSendRequest, + TestSendResponse, +) +from app.services.channels import get_channel +from app.services.crud import apply_updates, get_or_404 +from app.services.notifications import run_all, send_test + +router = APIRouter(prefix="/notifications", tags=["notifications"]) + +NOT_FOUND = {status.HTTP_404_NOT_FOUND: {"model": ErrorResponse}} + +CHANNEL_HINTS = { + NotificationChannel.SMTP: "SMTP_HOST und SMTP_FROM setzen.", + NotificationChannel.APPRISE: "APPRISE_URLS setzen, komma-separiert.", +} + + +@router.get( + "/settings", + response_model=NotificationSettingsOut, + summary="Einrichtungsstand", + description="Zeigt, welche Kanäle einsatzbereit sind und wann der nächste Lauf ansteht.", +) +async def read_settings() -> NotificationSettingsOut: + kanaele = [] + for art, hinweis in CHANNEL_HINTS.items(): + eingerichtet = get_channel(art).is_configured() + kanaele.append( + ChannelStatusOut( + channel=art, + configured=eingerichtet, + detail="Einsatzbereit." if eingerichtet else hinweis, + ) + ) + + return NotificationSettingsOut( + enabled=settings.notifications_enabled, + scheduler_enabled=settings.scheduler_enabled, + run_at=f"{settings.notification_hour:02d}:{settings.notification_minute:02d}", + timezone=settings.timezone, + next_run_at=next_run_time(), + channels=kanaele, + ) + + +@router.get("/rules", response_model=list[NotificationRuleOut], summary="Regeln auflisten") +async def list_rules(session: DbSession) -> list[NotificationRule]: + stmt = select(NotificationRule).order_by(NotificationRule.type, NotificationRule.channel) + return list((await session.execute(stmt)).scalars().all()) + + +@router.post( + "/rules", + response_model=NotificationRuleOut, + status_code=status.HTTP_201_CREATED, + summary="Regel anlegen", +) +async def create_rule(payload: NotificationRuleCreate, session: DbSession) -> NotificationRule: + regel = NotificationRule(**payload.model_dump()) + session.add(regel) + try: + await session.commit() + except IntegrityError as exc: + await session.rollback() + raise ConflictError("Diese Regel existiert bereits.") from exc + await session.refresh(regel) + return regel + + +@router.patch( + "/rules/{rule_id}", + response_model=NotificationRuleOut, + responses=NOT_FOUND, + summary="Regel ändern", +) +async def update_rule( + rule_id: int, payload: NotificationRuleUpdate, session: DbSession +) -> NotificationRule: + regel = await get_or_404(session, NotificationRule, rule_id) + apply_updates(regel, payload) + await session.commit() + await session.refresh(regel) + return regel + + +@router.delete( + "/rules/{rule_id}", + response_model=MessageResponse, + responses=NOT_FOUND, + summary="Regel löschen", + description="Entfernt die Regel samt ihrem Versandprotokoll.", +) +async def delete_rule(rule_id: int, session: DbSession) -> MessageResponse: + regel = await get_or_404(session, NotificationRule, rule_id) + await session.delete(regel) + await session.commit() + return MessageResponse(detail="Regel gelöscht.") + + +@router.get( + "/log", + response_model=list[NotificationLogOut], + summary="Versandprotokoll", + description="Neueste Einträge zuerst.", +) +async def read_log( + session: DbSession, + rule_id: int | None = Query(default=None), + status_filter: NotificationStatus | None = Query(default=None, alias="status"), + limit: int = Query(default=100, ge=1, le=500), +) -> list[NotificationLog]: + stmt = select(NotificationLog).order_by(NotificationLog.sent_at.desc()).limit(limit) + if rule_id is not None: + stmt = stmt.where(NotificationLog.rule_id == rule_id) + if status_filter is not None: + stmt = stmt.where(NotificationLog.status == status_filter) + return list((await session.execute(stmt)).scalars().all()) + + +@router.post( + "/test", + response_model=TestSendResponse, + summary="Testnachricht senden", + description="Verschickt eine Testnachricht über beide Kanäle. Nicht eingerichtete " + "Kanäle werden gemeldet, gelten aber nicht als Fehler.", +) +async def send_test_notification(payload: TestSendRequest | None = None) -> TestSendResponse: + ergebnisse = await send_test(payload.target if payload else None) + return TestSendResponse( + results=[TestResultOut.model_validate(eintrag) for eintrag in ergebnisse], + any_sent=any(eintrag.sent for eintrag in ergebnisse), + ) + + +@router.post( + "/run", + response_model=RunResponse, + summary="Lauf sofort ausführen", + description="Führt alle aktiven Regeln aus, ohne auf den Zeitplan zu warten. " + "Bereits gemeldete Ereignisse werden dabei übersprungen.", +) +async def run_now( + session: DbSession, + as_of: date | None = Query(default=None, description="Stichtag; Vorgabe ist heute."), +) -> RunResponse: + ergebnis = await run_all(session, as_of) + return RunResponse( + checked=ergebnis.checked, + sent=ergebnis.sent, + skipped=ergebnis.skipped, + failed=ergebnis.failed, + ) diff --git a/backend/app/main.py b/backend/app/main.py index 427416e..b17e791 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,6 +11,7 @@ from app.api.router import api_router from app.core.config import settings from app.core.errors import register_exception_handlers from app.db.session import SessionLocal, engine +from app.scheduler import shutdown_scheduler, start_scheduler from app.services.auth import ensure_admin_user, purge_expired_refresh_tokens logging.basicConfig( @@ -25,8 +26,10 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: """Start- und Stopplogik der Anwendung.""" settings.logo_storage_dir.mkdir(parents=True, exist_ok=True) await _bootstrap() + start_scheduler() logger.info("moneyfy %s gestartet (%s)", settings.app_version, settings.environment) yield + shutdown_scheduler() await engine.dispose() logger.info("moneyfy wird beendet") diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py new file mode 100644 index 0000000..25c1ccd --- /dev/null +++ b/backend/app/scheduler.py @@ -0,0 +1,93 @@ +"""Zeitgesteuerte Aufgaben. + +APScheduler läuft im selben Prozess wie die Anwendung – kein Redis, kein Celery. +Der tägliche Lauf prüft die Benachrichtigungsregeln und räumt abgelaufene +Sitzungen auf. +""" + +import logging + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger + +from app.core.clock import tz +from app.core.config import settings +from app.db.session import SessionLocal +from app.services.auth import purge_expired_refresh_tokens +from app.services.notifications import run_all + +logger = logging.getLogger(__name__) + +DAILY_JOB_ID = "moneyfy-daily" + +_scheduler: AsyncIOScheduler | None = None + + +async def daily_job() -> None: + """Täglicher Lauf. Fehler werden protokolliert, beenden den Scheduler aber nicht.""" + try: + async with SessionLocal() as session: + await run_all(session) + + entfernt = await purge_expired_refresh_tokens(session) + await session.commit() + if entfernt: + logger.info("%d abgelaufene Sitzungen entfernt.", entfernt) + except Exception: + logger.exception("Der tägliche Lauf ist fehlgeschlagen.") + + +def start_scheduler() -> AsyncIOScheduler | None: + """Startet den Scheduler, sofern er eingeschaltet ist.""" + global _scheduler + + if not settings.scheduler_enabled: + logger.info("Scheduler ist abgeschaltet (SCHEDULER_ENABLED=false).") + return None + if _scheduler is not None: + return _scheduler + + _scheduler = AsyncIOScheduler(timezone=tz()) + _scheduler.add_job( + daily_job, + CronTrigger( + hour=settings.notification_hour, + minute=settings.notification_minute, + timezone=tz(), + ), + id=DAILY_JOB_ID, + name="Benachrichtigungen und Aufräumen", + # Verpasste Läufe (etwa nach einem Neustart) einmal nachholen. + misfire_grace_time=3600, + coalesce=True, + max_instances=1, + replace_existing=True, + ) + _scheduler.start() + + logger.info( + "Scheduler gestartet – täglicher Lauf um %02d:%02d %s.", + settings.notification_hour, + settings.notification_minute, + settings.timezone, + ) + return _scheduler + + +def shutdown_scheduler() -> None: + """Hält den Scheduler beim Beenden der Anwendung an.""" + global _scheduler + + if _scheduler is None: + return + _scheduler.shutdown(wait=False) + _scheduler = None + logger.info("Scheduler beendet.") + + +def next_run_time(): + """Nächster geplanter Lauf, für die Anzeige in den Einstellungen.""" + if _scheduler is None: + return None + job = _scheduler.get_job(DAILY_JOB_ID) + return job.next_run_time if job else None diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py new file mode 100644 index 0000000..ba3dde4 --- /dev/null +++ b/backend/app/schemas/notification.py @@ -0,0 +1,113 @@ +"""Schemata für Benachrichtigungsregeln, Protokoll und Testversand.""" + +from datetime import date, datetime + +from pydantic import Field + +from app.models.enums import ( + NotificationChannel, + NotificationStatus, + NotificationType, +) +from app.schemas.common import ApiModel, InputModel + +TYPE_HINTS = { + "due_soon": "Fälligkeiten innerhalb der Vorlaufzeit.", + "notice_deadline": "Kündigungsfristen, die in 30, 14 oder 7 Tagen ablaufen.", + "budget_exceeded": "Überschrittene Budgets, einmal je Monat und Kategorie.", + "contract_renewal": "Verträge, die sich im kommenden Monat verlängern.", +} + + +class NotificationRuleCreate(InputModel): + type: NotificationType = Field( + description="; ".join(f"{k}: {v}" for k, v in TYPE_HINTS.items()) + ) + channel: NotificationChannel + lead_days: int = Field( + default=7, ge=0, le=365, description="Vorlauf in Tagen; nur für `due_soon` maßgeblich." + ) + target: str | None = Field( + default=None, + description="Mailadresse bzw. Apprise-URL. Ohne Angabe gelten SMTP_FROM " + "beziehungsweise APPRISE_URLS.", + ) + is_active: bool = True + + +class NotificationRuleUpdate(InputModel): + lead_days: int | None = Field(default=None, ge=0, le=365) + channel: NotificationChannel | None = None + target: str | None = None + is_active: bool | None = None + + +class NotificationRuleOut(ApiModel): + id: int + type: NotificationType + channel: NotificationChannel + lead_days: int + target: str | None + is_active: bool + created_at: datetime + + +class NotificationLogOut(ApiModel): + """Ein Eintrag des Versandprotokolls.""" + + id: int + rule_id: int + ref_type: str + ref_id: str + dedupe_day: date = Field( + description="Zieltag des Ereignisses – Grundlage des Duplikatsschutzes." + ) + sent_at: datetime + status: NotificationStatus + error: str | None + + +class ChannelStatusOut(ApiModel): + """Einrichtungsstand eines Kanals.""" + + channel: NotificationChannel + configured: bool + detail: str + + +class NotificationSettingsOut(ApiModel): + """Überblick über Kanäle und Zeitplan.""" + + enabled: bool + scheduler_enabled: bool + run_at: str = Field(description="Uhrzeit des täglichen Laufs, etwa '07:00'.") + timezone: str + next_run_at: datetime | None + channels: list[ChannelStatusOut] + + +class TestSendRequest(InputModel): + target: str | None = Field( + default=None, description="Abweichendes Ziel; ohne Angabe gelten die Einstellungen." + ) + + +class TestResultOut(ApiModel): + channel: NotificationChannel + configured: bool + sent: bool + error: str | None = None + + +class TestSendResponse(ApiModel): + results: list[TestResultOut] + any_sent: bool = Field(description="True, wenn mindestens ein Kanal zugestellt hat.") + + +class RunResponse(ApiModel): + """Ergebnis eines manuell ausgelösten Laufs.""" + + checked: int + sent: int + skipped: int = Field(description="Bereits gemeldete Ereignisse.") + failed: int diff --git a/backend/app/services/channels.py b/backend/app/services/channels.py new file mode 100644 index 0000000..28a5254 --- /dev/null +++ b/backend/app/services/channels.py @@ -0,0 +1,196 @@ +"""Versandkanäle für Benachrichtigungen: SMTP und Apprise. + +Beide Bibliotheken arbeiten blockierend; die Aufrufe laufen deshalb in einem +Thread, damit der Scheduler-Job den Event-Loop nicht anhält. Ein Kanal wirft +`ChannelError`, wenn der Versand fehlschlägt – der Aufrufer protokolliert das. +""" + +import asyncio +import logging +import smtplib +from dataclasses import dataclass, field +from email.message import EmailMessage +from email.utils import make_msgid +from pathlib import Path +from typing import Protocol, runtime_checkable + +from app.core.config import settings +from app.models.enums import NotificationChannel + +logger = logging.getLogger(__name__) + +SMTP_TIMEOUT_SECONDS = 20 + + +class ChannelError(RuntimeError): + """Der Versand über einen Kanal ist fehlgeschlagen.""" + + +@dataclass(frozen=True, slots=True) +class Attachment: + """Ein eingebettetes Bild, das per Content-ID in der HTML-Mail steckt.""" + + cid: str + """Ohne spitze Klammern – im HTML als `cid:` referenziert.""" + path: Path + mime: str + + +@dataclass(slots=True) +class Notification: + """Eine fertig formulierte Nachricht.""" + + subject: str + text: str + html: str | None = None + attachments: list[Attachment] = field(default_factory=list) + + +@runtime_checkable +class Channel(Protocol): + """Ein Weg, auf dem eine Nachricht den Nutzer erreicht.""" + + kind: NotificationChannel + + def is_configured(self) -> bool: + """False, wenn die nötigen Einstellungen fehlen – der Kanal wird übersprungen.""" + ... + + async def send(self, notification: Notification, target: str | None = None) -> None: ... + + +# --- SMTP ---------------------------------------------------------------------- + + +class SmtpChannel: + """Mailversand über einen SMTP-Server.""" + + kind = NotificationChannel.SMTP + + def is_configured(self) -> bool: + return bool(settings.smtp_host and settings.smtp_from) + + async def send(self, notification: Notification, target: str | None = None) -> None: + if not self.is_configured(): + raise ChannelError("SMTP ist nicht konfiguriert (SMTP_HOST und SMTP_FROM fehlen).") + + empfaenger = target or settings.smtp_from + if not empfaenger: + raise ChannelError("Kein Empfänger angegeben.") + + nachricht = self._build(notification, empfaenger) + try: + await asyncio.to_thread(self._deliver, nachricht) + except ChannelError: + raise + except Exception as exc: + raise ChannelError(f"Mailversand fehlgeschlagen: {exc}") from exc + + def _build(self, notification: Notification, empfaenger: str) -> EmailMessage: + """Baut eine Mail mit Text- und HTML-Teil sowie eingebetteten Logos.""" + nachricht = EmailMessage() + nachricht["Subject"] = notification.subject + nachricht["From"] = settings.smtp_from or "" + nachricht["To"] = empfaenger + nachricht.set_content(notification.text) + + if notification.html is None: + return nachricht + + html = notification.html + anhaenge: list[tuple[Attachment, str]] = [] + for anhang in notification.attachments: + # Für jedes Bild eine echte Message-ID erzeugen und im HTML einsetzen. + message_id = make_msgid() + html = html.replace(f"cid:{anhang.cid}", f"cid:{message_id[1:-1]}") + anhaenge.append((anhang, message_id)) + + nachricht.add_alternative(html, subtype="html") + html_teil = nachricht.get_payload()[-1] + + for anhang, message_id in anhaenge: + try: + inhalt = anhang.path.read_bytes() + except OSError: + logger.debug("Anhang %s konnte nicht gelesen werden.", anhang.path) + continue + haupttyp, _, untertyp = anhang.mime.partition("/") + html_teil.add_related(inhalt, maintype=haupttyp, subtype=untertyp, cid=message_id) + + return nachricht + + def _deliver(self, nachricht: EmailMessage) -> None: + """Blockierender Teil des Versands.""" + host = settings.smtp_host or "" + port = settings.smtp_port + + if settings.smtp_use_ssl: + verbindung = smtplib.SMTP_SSL(host, port, timeout=SMTP_TIMEOUT_SECONDS) + else: + verbindung = smtplib.SMTP(host, port, timeout=SMTP_TIMEOUT_SECONDS) + + with verbindung as server: + if settings.smtp_use_tls and not settings.smtp_use_ssl: + server.starttls() + if settings.smtp_user and settings.smtp_password: + server.login(settings.smtp_user, settings.smtp_password) + server.send_message(nachricht) + + +# --- Apprise ------------------------------------------------------------------- + + +class AppriseChannel: + """Versand über Apprise – deckt ntfy, Gotify, Matrix, Discord und viele mehr ab.""" + + kind = NotificationChannel.APPRISE + + def is_configured(self) -> bool: + return bool(settings.apprise_url_list) + + async def send(self, notification: Notification, target: str | None = None) -> None: + # Eine Regel kann ein eigenes Ziel vorgeben, sonst gelten die globalen URLs. + ziele = [target] if target else settings.apprise_url_list + if not ziele: + raise ChannelError("Apprise ist nicht konfiguriert (APPRISE_URLS fehlt).") + + try: + erfolg = await asyncio.to_thread(self._deliver, notification, ziele) + except Exception as exc: + raise ChannelError(f"Apprise-Versand fehlgeschlagen: {exc}") from exc + + if not erfolg: + raise ChannelError("Apprise konnte die Nachricht an kein Ziel zustellen.") + + def _deliver(self, notification: Notification, ziele: list[str]) -> bool: + import apprise + + sammlung = apprise.Apprise() + for ziel in ziele: + if not sammlung.add(ziel): + logger.warning("Apprise-Ziel konnte nicht gelesen werden: %s", ziel) + + if len(sammlung) == 0: + raise ChannelError("Kein gültiges Apprise-Ziel.") + + return bool(sammlung.notify(title=notification.subject, body=notification.text)) + + +# --- Registrierung ------------------------------------------------------------- + +_CHANNELS: dict[NotificationChannel, Channel] = { + NotificationChannel.SMTP: SmtpChannel(), + NotificationChannel.APPRISE: AppriseChannel(), +} + + +def get_channel(kind: NotificationChannel) -> Channel: + kanal = _CHANNELS.get(kind) + if kanal is None: # pragma: no cover - alle Enum-Werte sind registriert + raise ChannelError(f"Unbekannter Kanal: {kind}") + return kanal + + +def configured_channels() -> list[Channel]: + """Alle Kanäle, die tatsächlich einsatzbereit sind.""" + return [kanal for kanal in _CHANNELS.values() if kanal.is_configured()] diff --git a/backend/app/services/notifications.py b/backend/app/services/notifications.py new file mode 100644 index 0000000..4a9f4ee --- /dev/null +++ b/backend/app/services/notifications.py @@ -0,0 +1,533 @@ +"""Benachrichtigungen: Ereignisse sammeln, Duplikate ausschließen, versenden. + +Der Duplikatsschutz hängt nicht am Versandtag, sondern am Zieltag des Ereignisses: +Eine Fälligkeit am 15.03. wird genau einmal gemeldet, egal an welchem Tag des +Vorlaufs der Job läuft. Ein zweiter Lauf am selben Tag erzeugt daher ebenso wenig +eine zweite Nachricht wie ein Lauf am Folgetag. +""" + +import logging +from collections import defaultdict +from dataclasses import dataclass, field +from datetime import date, timedelta +from decimal import Decimal +from html import escape +from pathlib import Path + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.clock import add_months, month_end, month_start, today, utcnow +from app.core.config import settings +from app.models import LogoAsset, Merchant, NotificationLog, NotificationRule +from app.models.enums import ( + EntryKind, + NotificationChannel, + NotificationStatus, + NotificationType, +) +from app.services.channels import Attachment, ChannelError, Notification, get_channel +from app.services.occurrences import load_recurrences +from app.services.recurrence import contract_term +from app.services.reports import budget_status, flows + +logger = logging.getLogger(__name__) + +ZERO = Decimal("0.00") + +# Vorlaufstufen für Kündigungsfristen gemäß Fachspezifikation. +NOTICE_STAGES = (30, 14, 7) + +REF_OCCURRENCE = "occurrence" +REF_RECURRENCE = "recurrence" +REF_BUDGET = "budget" + +TYPE_LABELS: dict[NotificationType, str] = { + NotificationType.DUE_SOON: "Bald fällig", + NotificationType.NOTICE_DEADLINE: "Kündigungsfrist läuft ab", + NotificationType.BUDGET_EXCEEDED: "Budget überschritten", + NotificationType.CONTRACT_RENEWAL: "Vertrag verlängert sich", +} + + +@dataclass(frozen=True, slots=True) +class Event: + """Ein meldenswertes Ereignis.""" + + ref_type: str + ref_id: str + """Stabiler Schlüssel innerhalb des ref_type.""" + dedupe_day: date + """Zieltag des Ereignisses – nicht der Versandtag.""" + headline: str + detail: str + amount: Decimal | None = None + merchant_id: int | None = None + on: date | None = None + + +@dataclass(slots=True) +class RunResult: + """Ergebnis eines Laufs.""" + + checked: int = 0 + sent: int = 0 + skipped: int = 0 + failed: int = 0 + by_rule: dict[int, int] = field(default_factory=dict) + + +# --- Ereignisse sammeln -------------------------------------------------------- + + +def _money(amount: Decimal | None) -> str: + if amount is None: + return "" + return f"{amount:,.2f} €".replace(",", "#").replace(".", ",").replace("#", ".") + + +def _day(value: date) -> str: + return value.strftime("%d.%m.%Y") + + +async def collect_due_soon( + session: AsyncSession, rule: NotificationRule, as_of: date +) -> list[Event]: + """Fälligkeiten innerhalb des Vorlaufs.""" + ende = as_of + timedelta(days=max(rule.lead_days, 0)) + + ereignisse: list[Event] = [] + for eintrag in await flows(session, as_of, ende): + # Einmalige Buchungen sind bereits erfasst und brauchen keine Erinnerung. + if eintrag.source != "recurrence" or eintrag.recurrence_id is None: + continue + if eintrag.is_confirmed: + continue + + richtung = "Einkunft" if eintrag.kind is EntryKind.INCOME else "Zahlung" + ereignisse.append( + Event( + ref_type=REF_OCCURRENCE, + ref_id=f"{eintrag.recurrence_id}:{eintrag.occurrence_date}", + dedupe_day=eintrag.on, + headline=eintrag.title, + detail=f"{richtung} am {_day(eintrag.on)} über {_money(eintrag.amount)}", + amount=eintrag.amount, + merchant_id=eintrag.merchant_id, + on=eintrag.on, + ) + ) + return ereignisse + + +async def collect_notice_deadlines( + session: AsyncSession, rule: NotificationRule, as_of: date +) -> list[Event]: + """Kündigungsfristen, die in 30, 14 oder 7 Tagen ablaufen. + + Jede Stufe wird einmal gemeldet; der Schlüssel enthält deshalb die Stufe. + """ + # Aufsteigend, damit die *engste* zutreffende Stufe gewinnt: bei 14 Resttagen + # ist es die 14er-Stufe, nicht erneut die 30er. + stufen = sorted({*NOTICE_STAGES, rule.lead_days} - {0}) + + ereignisse: list[Event] = [] + for recurrence in await load_recurrences(session): + if recurrence.contract_cancelled_at is not None: + continue + laufzeit = contract_term(recurrence, as_of) + if laufzeit is None or laufzeit.notice_deadline is None: + continue + + verbleibend = (laufzeit.notice_deadline - as_of).days + if verbleibend < 0: + continue + + # Die erste Stufe, die der Termin gerade erreicht oder unterschritten hat. + stufe = next((wert for wert in stufen if verbleibend <= wert), None) + if stufe is None: + continue + + ereignisse.append( + Event( + ref_type=REF_RECURRENCE, + ref_id=f"{recurrence.id}:{stufe}", + dedupe_day=laufzeit.notice_deadline, + headline=recurrence.title, + detail=( + f"Kündigung bis {_day(laufzeit.notice_deadline)} möglich " + f"(noch {verbleibend} Tage), Laufzeit endet am {_day(laufzeit.term_end)}" + ), + amount=recurrence.amount, + merchant_id=recurrence.merchant_id, + on=laufzeit.notice_deadline, + ) + ) + return ereignisse + + +async def collect_budget_exceeded( + session: AsyncSession, rule: NotificationRule, as_of: date +) -> list[Event]: + """Überschrittene Budgets – höchstens einmal je Monat und Kategorie.""" + monat = month_start(as_of) + + return [ + Event( + ref_type=REF_BUDGET, + ref_id=str(eintrag.category_id), + dedupe_day=monat, + headline=eintrag.category_name, + detail=( + f"{_money(eintrag.spent)} von {_money(eintrag.available)} verbraucht " + f"({round(float(eintrag.ratio) * 100)} %), " + f"{_money(abs(eintrag.remaining))} zu viel" + ), + amount=eintrag.spent, + on=month_end(monat), + ) + for eintrag in await budget_status(session, monat) + if eintrag.state == "exceeded" + ] + + +async def collect_contract_renewals( + session: AsyncSession, rule: NotificationRule, as_of: date +) -> list[Event]: + """Verträge, die sich im kommenden Monat automatisch verlängern.""" + fenster_ende = month_end(add_months(as_of, 1)) + + ereignisse: list[Event] = [] + for recurrence in await load_recurrences(session): + if recurrence.contract_cancelled_at is not None: + continue + laufzeit = contract_term(recurrence, as_of) + if laufzeit is None or laufzeit.renews_on is None: + continue + if not as_of <= laufzeit.renews_on <= fenster_ende: + continue + + verlaengerung = recurrence.contract_auto_renew_months + ereignisse.append( + Event( + ref_type=REF_RECURRENCE, + ref_id=str(recurrence.id), + dedupe_day=laufzeit.renews_on, + headline=recurrence.title, + detail=( + f"Verlängert sich am {_day(laufzeit.renews_on)}" + + (f" um {verlaengerung} Monate" if verlaengerung else "") + ), + amount=recurrence.amount, + merchant_id=recurrence.merchant_id, + on=laufzeit.renews_on, + ) + ) + return ereignisse + + +COLLECTORS = { + NotificationType.DUE_SOON: collect_due_soon, + NotificationType.NOTICE_DEADLINE: collect_notice_deadlines, + NotificationType.BUDGET_EXCEEDED: collect_budget_exceeded, + NotificationType.CONTRACT_RENEWAL: collect_contract_renewals, +} + + +# --- Nachricht bauen ----------------------------------------------------------- + + +async def _logo_attachments(session: AsyncSession, events: list[Event]) -> dict[int, Attachment]: + """Lädt die Logodateien der beteiligten Firmen für den Mailanhang.""" + firmen_ids = {ereignis.merchant_id for ereignis in events if ereignis.merchant_id} + if not firmen_ids: + return {} + + stmt = ( + select(Merchant, LogoAsset) + .join(LogoAsset, LogoAsset.id == Merchant.logo_asset_id) + .where(Merchant.id.in_(firmen_ids)) + ) + + anhaenge: dict[int, Attachment] = {} + for merchant, asset in (await session.execute(stmt)).all(): + pfad = Path(settings.logo_storage_dir) / asset.file_path + if not pfad.exists(): + continue + anhaenge[merchant.id] = Attachment(cid=f"logo-{merchant.id}", path=pfad, mime=asset.mime) + return anhaenge + + +def build_notification( + rule_type: NotificationType, + events: list[Event], + attachments: dict[int, Attachment], +) -> Notification: + """Formuliert aus den Ereignissen eine Nachricht in Text und HTML.""" + ueberschrift = TYPE_LABELS[rule_type] + betreff = ( + f"moneyfy: {ueberschrift}" + if len(events) == 1 + else f"moneyfy: {ueberschrift} ({len(events)})" + ) + + text_zeilen = [ueberschrift, ""] + for ereignis in events: + text_zeilen.append(f"• {ereignis.headline}: {ereignis.detail}") + text_zeilen.extend(["", settings.public_base_url]) + + zeilen_html: list[str] = [] + for ereignis in events: + anhang = attachments.get(ereignis.merchant_id or -1) + bild = ( + f'' + if anhang + else "" + ) + zeilen_html.append( + '' + f"{bild}" + f'{escape(ereignis.headline)}
' + f'{escape(ereignis.detail)}' + "" + ) + + html = f""" + +
+

{escape(ueberschrift)}

+

+ {len(events)} {"Eintrag" if len(events) == 1 else "Einträge"} +

+ {"".join(zeilen_html)}
+

+ In moneyfy öffnen +

+
+""" + + beteiligte = { + ereignis.merchant_id for ereignis in events if ereignis.merchant_id in attachments + } + return Notification( + subject=betreff, + text="\n".join(text_zeilen), + html=html, + attachments=[attachments[firma_id] for firma_id in beteiligte if firma_id], + ) + + +# --- Versand und Protokoll ----------------------------------------------------- + + +async def _existing_log( + session: AsyncSession, rule_id: int, event: Event +) -> NotificationLog | None: + stmt = select(NotificationLog).where( + NotificationLog.rule_id == rule_id, + NotificationLog.ref_type == event.ref_type, + NotificationLog.ref_id == event.ref_id, + NotificationLog.dedupe_day == event.dedupe_day, + ) + return (await session.execute(stmt)).scalar_one_or_none() + + +async def pending_events( + session: AsyncSession, rule: NotificationRule, as_of: date +) -> tuple[list[Event], list[Event], dict[str, NotificationLog]]: + """Trennt neue Ereignisse von bereits gemeldeten. + + Ein zuvor fehlgeschlagener Versand gilt als offen und wird erneut versucht; + der vorhandene Protokolleintrag wird dabei aktualisiert statt neu angelegt. + """ + sammler = COLLECTORS[rule.type] + alle = await sammler(session, rule, as_of) + + offen: list[Event] = [] + erledigt: list[Event] = [] + vorhandene: dict[str, NotificationLog] = {} + + for ereignis in alle: + eintrag = await _existing_log(session, rule.id, ereignis) + if eintrag is None: + offen.append(ereignis) + continue + if eintrag.status is NotificationStatus.FAILED: + offen.append(ereignis) + vorhandene[f"{ereignis.ref_type}:{ereignis.ref_id}"] = eintrag + continue + erledigt.append(ereignis) + + return offen, erledigt, vorhandene + + +async def _record( + session: AsyncSession, + rule: NotificationRule, + events: list[Event], + existing: dict[str, NotificationLog], + status: NotificationStatus, + error: str | None, +) -> None: + """Schreibt das Versandprotokoll für alle Ereignisse einer Nachricht.""" + zeitpunkt = utcnow() + for ereignis in events: + schluessel = f"{ereignis.ref_type}:{ereignis.ref_id}" + eintrag = existing.get(schluessel) + if eintrag is not None: + eintrag.status = status + eintrag.error = error + eintrag.sent_at = zeitpunkt + continue + + session.add( + NotificationLog( + rule_id=rule.id, + ref_type=ereignis.ref_type, + ref_id=ereignis.ref_id, + dedupe_day=ereignis.dedupe_day, + sent_at=zeitpunkt, + status=status, + error=error, + ) + ) + await session.flush() + + +async def run_rule( + session: AsyncSession, rule: NotificationRule, as_of: date | None = None +) -> tuple[int, int, int]: + """Führt eine Regel aus. Liefert (geprüft, versendet, übersprungen).""" + stichtag = as_of or today() + offen, erledigt, vorhandene = await pending_events(session, rule, stichtag) + + if not offen: + return len(offen) + len(erledigt), 0, len(erledigt) + + anhaenge = await _logo_attachments(session, offen) + nachricht = build_notification(rule.type, offen, anhaenge) + + try: + await get_channel(rule.channel).send(nachricht, rule.target) + except ChannelError as fehler: + logger.warning("Regel %s konnte nicht zugestellt werden: %s", rule.id, fehler) + await _record(session, rule, offen, vorhandene, NotificationStatus.FAILED, str(fehler)) + return len(offen) + len(erledigt), 0, len(erledigt) + + await _record(session, rule, offen, vorhandene, NotificationStatus.SENT, None) + return len(offen) + len(erledigt), len(offen), len(erledigt) + + +async def run_all(session: AsyncSession, as_of: date | None = None) -> RunResult: + """Führt alle aktiven Regeln aus – der tägliche Lauf des Schedulers.""" + if not settings.notifications_enabled: + logger.info("Benachrichtigungen sind abgeschaltet.") + return RunResult() + + stichtag = as_of or today() + stmt = select(NotificationRule).where(NotificationRule.is_active.is_(True)) + regeln = list((await session.execute(stmt)).scalars()) + + ergebnis = RunResult() + for regel in regeln: + try: + geprueft, versendet, uebersprungen = await run_rule(session, regel, stichtag) + except Exception: + # Eine fehlerhafte Regel darf die übrigen nicht verhindern. + logger.exception("Regel %s ist fehlgeschlagen.", regel.id) + ergebnis.failed += 1 + continue + + ergebnis.checked += geprueft + ergebnis.sent += versendet + ergebnis.skipped += uebersprungen + if versendet: + ergebnis.by_rule[regel.id] = versendet + + await session.commit() + logger.info( + "Benachrichtigungslauf: %d geprüft, %d versendet, %d übersprungen, %d fehlerhaft.", + ergebnis.checked, + ergebnis.sent, + ergebnis.skipped, + ergebnis.failed, + ) + return ergebnis + + +# --- Testversand --------------------------------------------------------------- + + +def test_notification() -> Notification: + """Eine kurze Nachricht, mit der sich die Einrichtung prüfen lässt.""" + zeitpunkt = utcnow().astimezone().strftime("%d.%m.%Y um %H:%M Uhr") + return Notification( + subject="moneyfy: Testnachricht", + text=( + "Das ist eine Testnachricht von moneyfy.\n" + f"Gesendet am {zeitpunkt}.\n\n" + "Wenn du sie liest, ist der Kanal richtig eingerichtet.\n\n" + f"{settings.public_base_url}" + ), + html=f""" + +
+

Testnachricht

+

+ Wenn du das liest, ist der Kanal richtig eingerichtet.
+ Gesendet am {zeitpunkt}. +

+

+ moneyfy öffnen +

+
+""", + ) + + +@dataclass(frozen=True, slots=True) +class TestResult: + """Ergebnis eines Testversands je Kanal.""" + + channel: NotificationChannel + configured: bool + sent: bool + error: str | None = None + + +async def send_test(target: str | None = None) -> list[TestResult]: + """Verschickt eine Testnachricht über beide Kanäle. + + Nicht eingerichtete Kanäle werden gemeldet, aber nicht als Fehler gewertet. + """ + nachricht = test_notification() + + ergebnisse: list[TestResult] = [] + for art in (NotificationChannel.SMTP, NotificationChannel.APPRISE): + kanal = get_channel(art) + if not kanal.is_configured(): + ergebnisse.append(TestResult(channel=art, configured=False, sent=False)) + continue + try: + await kanal.send(nachricht, target) + except ChannelError as fehler: + ergebnisse.append( + TestResult(channel=art, configured=True, sent=False, error=str(fehler)) + ) + continue + ergebnisse.append(TestResult(channel=art, configured=True, sent=True)) + + return ergebnisse + + +def group_by_type(events: list[Event]) -> dict[str, list[Event]]: + """Hilfsfunktion für Vorschauen in der Oberfläche.""" + gruppen: dict[str, list[Event]] = defaultdict(list) + for ereignis in events: + gruppen[ereignis.ref_type].append(ereignis) + return dict(gruppen) diff --git a/backend/tests/test_notifications.py b/backend/tests/test_notifications.py new file mode 100644 index 0000000..89060b9 --- /dev/null +++ b/backend/tests/test_notifications.py @@ -0,0 +1,544 @@ +"""Tests der Benachrichtigungen: Ereignisse, Duplikatsschutz und Versand.""" + +import smtplib +from datetime import date, timedelta +from email.message import EmailMessage +from typing import ClassVar + +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.models import NotificationLog, NotificationRule +from app.models.enums import NotificationChannel, NotificationStatus, NotificationType +from app.services import notifications as service +from app.services.channels import AppriseChannel, ChannelError, SmtpChannel + + +def text_of(nachricht: EmailMessage) -> str: + """Der reine Textteil einer mehrteiligen Mail.""" + teil = nachricht.get_body(preferencelist=("plain",)) + assert teil is not None, "Die Mail hat keinen Textteil." + return teil.get_content() + + +# --- Doppel für die Kanäle ------------------------------------------------------ + + +class FakeSmtp: + """Ersetzt `smtplib.SMTP` und merkt sich die versendeten Nachrichten.""" + + versendet: ClassVar[list[EmailMessage]] = [] + fehler: ClassVar[Exception | None] = None + + def __init__(self, host: str, port: int, timeout: int = 0) -> None: + self.host = host + self.port = port + + def __enter__(self) -> "FakeSmtp": + return self + + def __exit__(self, *_args: object) -> None: + return None + + def starttls(self) -> None: + return None + + def login(self, user: str, password: str) -> None: + return None + + def send_message(self, nachricht: EmailMessage) -> None: + if FakeSmtp.fehler is not None: + raise FakeSmtp.fehler + FakeSmtp.versendet.append(nachricht) + + +class FakeApprise: + """Ersetzt `apprise.Apprise`.""" + + versendet: ClassVar[list[tuple[str, str]]] = [] + erfolg: ClassVar[bool] = True + + def __init__(self) -> None: + self.ziele: list[str] = [] + + def add(self, ziel: str) -> bool: + self.ziele.append(ziel) + return True + + def __len__(self) -> int: + return len(self.ziele) + + def notify(self, title: str, body: str) -> bool: + if FakeApprise.erfolg: + FakeApprise.versendet.append((title, body)) + return FakeApprise.erfolg + + +@pytest.fixture +def smtp(monkeypatch: pytest.MonkeyPatch) -> type[FakeSmtp]: + """Richtet SMTP ein und fängt den Versand ab.""" + FakeSmtp.versendet = [] + FakeSmtp.fehler = None + monkeypatch.setattr(smtplib, "SMTP", FakeSmtp) + monkeypatch.setattr(settings, "smtp_host", "mail.example.org") + monkeypatch.setattr(settings, "smtp_from", "moneyfy@example.org") + monkeypatch.setattr(settings, "smtp_use_tls", False) + monkeypatch.setattr(settings, "smtp_use_ssl", False) + return FakeSmtp + + +@pytest.fixture +def apprise(monkeypatch: pytest.MonkeyPatch) -> type[FakeApprise]: + """Richtet Apprise ein und fängt den Versand ab.""" + import apprise as apprise_modul + + FakeApprise.versendet = [] + FakeApprise.erfolg = True + monkeypatch.setattr(apprise_modul, "Apprise", FakeApprise) + monkeypatch.setattr(settings, "apprise_urls", "ntfy://example.org/moneyfy") + return FakeApprise + + +async def regel( + session: AsyncSession, + typ: NotificationType, + *, + kanal: NotificationChannel = NotificationChannel.SMTP, + lead_days: int = 7, + target: str | None = None, +) -> NotificationRule: + eintrag = NotificationRule( + type=typ, channel=kanal, lead_days=lead_days, target=target, is_active=True + ) + session.add(eintrag) + await session.flush() + return eintrag + + +async def abo(client: AsyncClient, seeded: dict, **overrides) -> dict: + payload = { + "kind": "expense", + "title": "Netflix", + "category_id": seeded["streaming"], + "account_id": seeded["account_id"], + "amount": "13.99", + "rrule": "FREQ=MONTHLY;BYMONTHDAY=15", + "dtstart": "2026-01-15", + "business_day_shift": "none", + } + payload.update(overrides) + antwort = await client.post("/api/recurrences", json=payload) + assert antwort.status_code == 201, antwort.text + return antwort.json() + + +# --- Kanäle --------------------------------------------------------------------- + + +async def test_smtp_baut_eine_mail_mit_text_und_html(smtp: type[FakeSmtp]) -> None: + await SmtpChannel().send(service.test_notification(), "jemand@example.org") + + assert len(smtp.versendet) == 1 + nachricht = smtp.versendet[0] + assert nachricht["To"] == "jemand@example.org" + assert nachricht["From"] == "moneyfy@example.org" + assert nachricht["Subject"] == "moneyfy: Testnachricht" + # Text und HTML liegen als Alternativen nebeneinander. + typen = {teil.get_content_type() for teil in nachricht.walk()} + assert "text/plain" in typen + assert "text/html" in typen + + +async def test_smtp_ohne_konfiguration_meldet_das(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "smtp_host", None) + + assert SmtpChannel().is_configured() is False + with pytest.raises(ChannelError, match="nicht konfiguriert"): + await SmtpChannel().send(service.test_notification()) + + +async def test_smtp_reicht_serverfehler_als_channelerror_durch( + smtp: type[FakeSmtp], +) -> None: + smtp.fehler = smtplib.SMTPRecipientsRefused({}) + + with pytest.raises(ChannelError, match="Mailversand fehlgeschlagen"): + await SmtpChannel().send(service.test_notification(), "jemand@example.org") + + +async def test_apprise_versendet_titel_und_text(apprise: type[FakeApprise]) -> None: + await AppriseChannel().send(service.test_notification()) + + assert len(apprise.versendet) == 1 + titel, text = apprise.versendet[0] + assert titel == "moneyfy: Testnachricht" + assert "Testnachricht von moneyfy" in text + + +async def test_apprise_ohne_ziel_meldet_das(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "apprise_urls", None) + + assert AppriseChannel().is_configured() is False + with pytest.raises(ChannelError, match="nicht konfiguriert"): + await AppriseChannel().send(service.test_notification()) + + +async def test_apprise_meldet_fehlgeschlagene_zustellung( + apprise: type[FakeApprise], +) -> None: + apprise.erfolg = False + + with pytest.raises(ChannelError, match="kein Ziel"): + await AppriseChannel().send(service.test_notification()) + + +# --- Testversand ---------------------------------------------------------------- + + +async def test_testversand_ueber_beide_kanaele( + auth_client: AsyncClient, smtp: type[FakeSmtp], apprise: type[FakeApprise] +) -> None: + """Akzeptanzkriterium: der Testversand erreicht beide Kanäle.""" + antwort = await auth_client.post("/api/notifications/test", json={}) + + assert antwort.status_code == 200 + body = antwort.json() + assert body["any_sent"] is True + + nach_kanal = {eintrag["channel"]: eintrag for eintrag in body["results"]} + assert nach_kanal["smtp"] == { + "channel": "smtp", + "configured": True, + "sent": True, + "error": None, + } + assert nach_kanal["apprise"]["sent"] is True + + assert len(smtp.versendet) == 1 + assert len(apprise.versendet) == 1 + + +async def test_testversand_meldet_nicht_eingerichtete_kanaele( + auth_client: AsyncClient, smtp: type[FakeSmtp], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(settings, "apprise_urls", None) + + body = (await auth_client.post("/api/notifications/test", json={})).json() + + nach_kanal = {eintrag["channel"]: eintrag for eintrag in body["results"]} + assert nach_kanal["smtp"]["sent"] is True + assert nach_kanal["apprise"]["configured"] is False + assert nach_kanal["apprise"]["sent"] is False + # Ein fehlender Kanal ist kein Fehler. + assert body["any_sent"] is True + + +async def test_einrichtungsstand_wird_gemeldet( + auth_client: AsyncClient, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(settings, "smtp_host", None) + monkeypatch.setattr(settings, "apprise_urls", None) + + body = (await auth_client.get("/api/notifications/settings")).json() + + assert body["run_at"] == "07:00" + assert body["timezone"] == "Europe/Berlin" + nach_kanal = {eintrag["channel"]: eintrag for eintrag in body["channels"]} + assert nach_kanal["smtp"]["configured"] is False + assert "SMTP_HOST" in nach_kanal["smtp"]["detail"] + + +# --- Duplikatsschutz ------------------------------------------------------------ + + +async def test_zweiter_lauf_am_selben_tag_sendet_nicht_erneut( + auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp] +) -> None: + """Akzeptanzkriterium: kein Doppelversand beim zweiten Lauf.""" + await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15") + await regel(session, NotificationType.DUE_SOON, lead_days=7) + + erster = await service.run_all(session, date(2026, 3, 10)) + zweiter = await service.run_all(session, date(2026, 3, 10)) + + assert erster.sent == 1 + assert zweiter.sent == 0 + assert zweiter.skipped == 1 + assert len(smtp.versendet) == 1 + + +async def test_auch_ein_spaeterer_lauf_im_vorlauf_sendet_nicht_erneut( + auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp] +) -> None: + """Der Schlüssel ist der Zieltag, nicht der Versandtag.""" + await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15") + await regel(session, NotificationType.DUE_SOON, lead_days=7) + + await service.run_all(session, date(2026, 3, 10)) + await service.run_all(session, date(2026, 3, 11)) + await service.run_all(session, date(2026, 3, 12)) + + assert len(smtp.versendet) == 1 + + +async def test_naechster_monat_wird_wieder_gemeldet( + auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp] +) -> None: + await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15") + await regel(session, NotificationType.DUE_SOON, lead_days=7) + + await service.run_all(session, date(2026, 3, 10)) + await service.run_all(session, date(2026, 4, 10)) + + assert len(smtp.versendet) == 2 + + +async def test_protokoll_haelt_den_zieltag_fest( + auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp] +) -> None: + await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15") + await regel(session, NotificationType.DUE_SOON, lead_days=7) + + await service.run_all(session, date(2026, 3, 10)) + + eintraege = (await session.execute(select(NotificationLog))).scalars().all() + assert len(eintraege) == 1 + assert eintraege[0].status is NotificationStatus.SENT + assert eintraege[0].dedupe_day == date(2026, 3, 15) + assert eintraege[0].ref_type == "occurrence" + + +async def test_fehlgeschlagener_versand_wird_erneut_versucht( + auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp] +) -> None: + """Ein Fehlschlag darf nicht dazu führen, dass die Meldung verloren geht.""" + await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15") + await regel(session, NotificationType.DUE_SOON, lead_days=7) + + smtp.fehler = smtplib.SMTPServerDisconnected("Server weg") + erster = await service.run_all(session, date(2026, 3, 10)) + assert erster.sent == 0 + + eintrag = (await session.execute(select(NotificationLog))).scalar_one() + assert eintrag.status is NotificationStatus.FAILED + assert eintrag.error + + smtp.fehler = None + zweiter = await service.run_all(session, date(2026, 3, 11)) + + assert zweiter.sent == 1 + assert len(smtp.versendet) == 1 + # Der vorhandene Eintrag wird aktualisiert, kein zweiter angelegt. + eintraege = (await session.execute(select(NotificationLog))).scalars().all() + assert len(eintraege) == 1 + assert eintraege[0].status is NotificationStatus.SENT + assert eintraege[0].error is None + + +# --- Ereignisarten -------------------------------------------------------------- + + +async def test_faelligkeiten_ausserhalb_des_vorlaufs_bleiben_still( + auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp] +) -> None: + await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15") + await regel(session, NotificationType.DUE_SOON, lead_days=3) + + ergebnis = await service.run_all(session, date(2026, 3, 1)) + + assert ergebnis.sent == 0 + assert smtp.versendet == [] + + +async def test_bestaetigte_faelligkeit_wird_nicht_gemeldet( + auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp] +) -> None: + posten = await abo( + auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15" + ) + await auth_client.post( + "/api/occurrences/confirm", + json={"recurrence_id": posten["id"], "occurrence_date": "2026-03-15"}, + ) + await regel(session, NotificationType.DUE_SOON, lead_days=7) + + ergebnis = await service.run_all(session, date(2026, 3, 10)) + + assert ergebnis.sent == 0 + + +async def test_kuendigungsfrist_meldet_sich_in_drei_stufen( + auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp] +) -> None: + """30, 14 und 7 Tage vor dem Termin – je einmal.""" + await abo( + auth_client, + seeded, + title="Handyvertrag", + rrule="FREQ=MONTHLY;BYMONTHDAY=1", + dtstart="2026-01-01", + contract_start="2026-01-01", + contract_min_term_months=24, + contract_notice_period_days=90, + ) + await regel(session, NotificationType.NOTICE_DEADLINE, lead_days=30) + + # Laufzeitende 31.12.2027, Frist also am 02.10.2027. + frist = date(2027, 10, 2) + for versatz in (30, 14, 7): + await service.run_all(session, frist - timedelta(days=versatz)) + # Ein weiterer Lauf innerhalb derselben Stufe meldet nichts Neues. + await service.run_all(session, frist - timedelta(days=6)) + + assert len(smtp.versendet) == 3 + assert all("Handyvertrag" in text_of(nachricht) for nachricht in smtp.versendet) + + +async def test_gekuendigter_vertrag_meldet_keine_frist( + auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp] +) -> None: + posten = await abo( + auth_client, + seeded, + title="Handyvertrag", + rrule="FREQ=MONTHLY;BYMONTHDAY=1", + dtstart="2026-01-01", + contract_start="2026-01-01", + contract_min_term_months=24, + contract_notice_period_days=90, + ) + await auth_client.post(f"/api/recurrences/{posten['id']}/cancel") + await regel(session, NotificationType.NOTICE_DEADLINE) + + ergebnis = await service.run_all(session, date(2027, 9, 15)) + + assert ergebnis.sent == 0 + + +async def test_ueberschrittenes_budget_meldet_sich_einmal_im_monat( + auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp] +) -> None: + await auth_client.post( + "/api/budgets", + json={ + "category_id": seeded["lebensmittel"], + "period_month": "2026-03-01", + "limit_amount": "100.00", + }, + ) + await auth_client.post( + "/api/transactions", + json={ + "kind": "expense", + "title": "Großeinkauf", + "category_id": seeded["lebensmittel"], + "account_id": seeded["account_id"], + "amount": "150.00", + "booking_date": "2026-03-05", + }, + ) + await regel(session, NotificationType.BUDGET_EXCEEDED) + + erster = await service.run_all(session, date(2026, 3, 10)) + zweiter = await service.run_all(session, date(2026, 3, 20)) + + assert erster.sent == 1 + assert zweiter.sent == 0 + assert "Lebensmittel" in text_of(smtp.versendet[0]) + + +async def test_vertragsverlaengerung_im_kommenden_monat( + auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp] +) -> None: + await abo( + auth_client, + seeded, + title="Zeitschrift", + rrule="FREQ=MONTHLY;BYMONTHDAY=1", + dtstart="2026-01-01", + contract_start="2026-01-01", + contract_min_term_months=12, + contract_auto_renew_months=12, + ) + await regel(session, NotificationType.CONTRACT_RENEWAL) + + # Verlängerung am 01.01.2027; ein Lauf im Dezember trifft das Fenster. + ergebnis = await service.run_all(session, date(2026, 12, 5)) + + assert ergebnis.sent == 1 + assert "Zeitschrift" in text_of(smtp.versendet[0]) + + +async def test_abgeschaltete_regel_laeuft_nicht( + auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp] +) -> None: + await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15") + eintrag = await regel(session, NotificationType.DUE_SOON, lead_days=7) + eintrag.is_active = False + await session.flush() + + ergebnis = await service.run_all(session, date(2026, 3, 10)) + + assert ergebnis.sent == 0 + assert smtp.versendet == [] + + +async def test_abgeschaltete_benachrichtigungen( + session: AsyncSession, monkeypatch: pytest.MonkeyPatch, smtp: type[FakeSmtp] +) -> None: + monkeypatch.setattr(settings, "notifications_enabled", False) + + ergebnis = await service.run_all(session, date(2026, 3, 10)) + + assert ergebnis.checked == 0 + assert smtp.versendet == [] + + +# --- Regeln über die API -------------------------------------------------------- + + +async def test_regeln_pflegen(auth_client: AsyncClient) -> None: + angelegt = await auth_client.post( + "/api/notifications/rules", + json={"type": "due_soon", "channel": "apprise", "lead_days": 5}, + ) + assert angelegt.status_code == 201 + eintrag = angelegt.json() + assert eintrag["lead_days"] == 5 + + geaendert = await auth_client.patch( + f"/api/notifications/rules/{eintrag['id']}", json={"lead_days": 10, "is_active": False} + ) + assert geaendert.json()["lead_days"] == 10 + assert geaendert.json()["is_active"] is False + + liste = (await auth_client.get("/api/notifications/rules")).json() + assert any(regel["id"] == eintrag["id"] for regel in liste) + + assert ( + await auth_client.delete(f"/api/notifications/rules/{eintrag['id']}") + ).status_code == 200 + + +async def test_lauf_ueber_die_api( + auth_client: AsyncClient, session: AsyncSession, seeded: dict, smtp: type[FakeSmtp] +) -> None: + await abo(auth_client, seeded, rrule="FREQ=MONTHLY;BYMONTHDAY=15", dtstart="2026-03-15") + await regel(session, NotificationType.DUE_SOON, lead_days=7) + + antwort = await auth_client.post("/api/notifications/run", params={"as_of": "2026-03-10"}) + + assert antwort.status_code == 200 + assert antwort.json()["sent"] == 1 + + protokoll = (await auth_client.get("/api/notifications/log")).json() + assert len(protokoll) == 1 + assert protokoll[0]["status"] == "sent" + + +async def test_benachrichtigungen_verlangen_anmeldung(client: AsyncClient) -> None: + assert (await client.get("/api/notifications/rules")).status_code == 401 + assert (await client.post("/api/notifications/test", json={})).status_code == 401 diff --git a/frontend/src/components/NotificationSettings.test.tsx b/frontend/src/components/NotificationSettings.test.tsx new file mode 100644 index 0000000..290025d --- /dev/null +++ b/frontend/src/components/NotificationSettings.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + }); +}); diff --git a/frontend/src/components/NotificationSettings.tsx b/frontend/src/components/NotificationSettings.tsx new file mode 100644 index 0000000..7910587 --- /dev/null +++ b/frontend/src/components/NotificationSettings.tsx @@ -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 = { + due_soon: "Bald fällig", + notice_deadline: "Kündigungsfrist", + budget_exceeded: "Budget überschritten", + contract_renewal: "Vertragsverlängerung", +}; + +const TYP_HINWEISE: Record = { + 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 = { + smtp: "E-Mail", + apprise: "Apprise", +}; + +export function NotificationSettings() { + const [formularOffen, setFormularOffen] = useState(false); + const [bearbeiten, setBearbeiten] = useState(null); + const [loeschen, setLoeschen] = useState(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 ; + + return ( +
+
+
+
+

+ + Zeitplan +

+

+ {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))}.`} +

+
+
+ + +
+
+ +
    + {einstellungen.channels.map((kanal) => ( +
  • + {kanal.configured ? ( + + ) : ( + + )} +
    +

    {KANAL_NAMEN[kanal.channel]}

    +

    {kanal.detail}

    +
    +
  • + ))} +
+
+ +
+
+

Regeln

+ +
+ + {regeln.length === 0 ? ( + + ) : ( +
    + {regeln.map((regel) => ( +
  • +
    +
    + {TYP_NAMEN[regel.type]} + {KANAL_NAMEN[regel.channel]} + {regel.type === "due_soon" && ( + {regel.lead_days} Tage Vorlauf + )} + {!regel.is_active && Inaktiv} +
    +

    + {TYP_HINWEISE[regel.type]} + {regel.target && ` An: ${regel.target}`} +

    +
    + +
    + + +
    +
  • + ))} +
+ )} +
+ + {protokoll.length > 0 && ( +
+

Versandprotokoll

+
+ + + + + + + + + + + + {protokoll.map((eintrag) => ( + + + + + + + ))} + +
Zuletzt versendete Benachrichtigungen
+ Gesendet + + Bezug + + Zieltag + + Status +
+ {formatDate(eintrag.sent_at.slice(0, 10))} + + {eintrag.ref_type} {eintrag.ref_id} + + {formatDate(eintrag.dedupe_day)} + + {eintrag.status === "sent" ? ( + zugestellt + ) : ( + + {eintrag.error ?? "fehlgeschlagen"} + + )} +
+
+
+ )} + + { + setFormularOffen(false); + setBearbeiten(null); + }} + /> + + setLoeschen(null)} + onConfirm={() => { + if (loeschen) entfernen.mutate(loeschen.id, { onSuccess: () => setLoeschen(null) }); + }} + /> +
+ ); +} + +function RuleDialog({ + rule, + open, + onClose, +}: { + rule: NotificationRule | null; + open: boolean; + onClose: () => void; +}) { + const speichern = useSaveNotificationRule(); + const [typ, setTyp] = useState("due_soon"); + const [kanal, setKanal] = useState("smtp"); + const [vorlauf, setVorlauf] = useState(7); + const [ziel, setZiel] = useState(""); + const [aktiv, setAktiv] = useState(true); + const [initialisiert, setInitialisiert] = useState(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 ( + + + + + } + > +
+ + {(id) => ( + + )} + + + + {(id) => ( + + )} + + + {typ === "due_soon" && ( + + {(id) => ( + setVorlauf(Number(ereignis.target.value))} + /> + )} + + )} + + + {(id) => ( + setZiel(ereignis.target.value)} + /> + )} + + + setAktiv(ereignis.target.checked)} + /> + +
+ ); +} diff --git a/frontend/src/hooks/useNotifications.ts b/frontend/src/hooks/useNotifications.ts new file mode 100644 index 0000000..48efa92 --- /dev/null +++ b/frontend/src/hooks/useNotifications.ts @@ -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("/notifications/settings"), + }); +} + +export function useNotificationRules() { + return useQuery({ + queryKey: ["notifications", "rules"], + queryFn: () => api.get("/notifications/rules"), + }); +} + +export function useNotificationLog(limit = 50) { + return useQuery({ + queryKey: ["notifications", "log", limit], + queryFn: () => api.get("/notifications/log", { limit }), + }); +} + +export function useSaveNotificationRule() { + const client = useQueryClient(); + return useMutation({ + mutationFn: ({ + id, + daten, + }: { + id?: number; + daten: NotificationRuleInput | Partial; + }) => + id + ? api.patch(`/notifications/rules/${id}`, daten) + : api.post("/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(`/notifications/rules/${id}`), + onSuccess: () => { + void client.invalidateQueries({ queryKey: ["notifications"] }); + toast.success("Regel gelöscht."); + }, + }); +} + +export function useSendTestNotification() { + return useMutation({ + mutationFn: (target?: string) => + api.post("/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("/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.`, + ); + }, + }); +} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 2fd6194..4ad66be 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -4,6 +4,7 @@ import { type FormEvent, useState } from "react"; import { KeyRound, Pencil, Plus, Trash2, Wallet } from "lucide-react"; +import { NotificationSettings } from "@/components/NotificationSettings"; import { PageHeader } from "@/components/layout/AppLayout"; import { Button } from "@/components/ui/Button"; import { Badge, ConfirmDialog, EmptyState, Skeleton } from "@/components/ui/Feedback"; @@ -31,11 +32,12 @@ const KONTOARTEN: Record = { cash: "Bargeld", }; -type Reiter = "accounts" | "categories" | "account"; +type Reiter = "accounts" | "categories" | "notifications" | "account"; const REITER: { id: Reiter; label: string }[] = [ { id: "accounts", label: "Konten" }, { id: "categories", label: "Kategorien" }, + { id: "notifications", label: "Benachrichtigungen" }, { id: "account", label: "Konto & Darstellung" }, ]; @@ -67,6 +69,7 @@ export function SettingsPage() { {reiter === "accounts" && } {reiter === "categories" && } + {reiter === "notifications" && } {reiter === "account" && } ); diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index 1f82e21..e926d07 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -4,9 +4,13 @@ import "@testing-library/jest-dom/vitest"; import { cleanup } from "@testing-library/react"; import { afterEach, vi } from "vitest"; +import { useToastStore } from "@/store/toast"; + afterEach(() => { cleanup(); vi.restoreAllMocks(); + // Der Toast-Store lebt global – ohne Zurücksetzen tropfen Meldungen in den nächsten Test. + useToastStore.getState().clear(); }); // jsdom kennt matchMedia nicht; einzelne Komponenten fragen es ab. diff --git a/frontend/src/test/utils.tsx b/frontend/src/test/utils.tsx index 491c5e1..14a7355 100644 --- a/frontend/src/test/utils.tsx +++ b/frontend/src/test/utils.tsx @@ -6,6 +6,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; +import { Toaster } from "@/components/ui/Toaster"; + /** Frischer Client je Test, ohne Wiederholungen und ohne Konsolenausgabe. */ export function createTestQueryClient(): QueryClient { return new QueryClient({ @@ -27,6 +29,8 @@ export function renderWithProviders(ui: ReactElement, route = "/") { future={{ v7_startTransition: true, v7_relativeSplatPath: true }} > {children} + {/* Wie in der echten Anwendung – so lassen sich Rückmeldungen prüfen. */} + ); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index cecea46..83a9c7b 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -529,3 +529,75 @@ export interface Dashboard { upcoming: CalendarEntry[]; upcoming_deadlines: Subscription[]; } + +export type NotificationType = + | "due_soon" + | "notice_deadline" + | "budget_exceeded" + | "contract_renewal"; + +export type NotificationChannel = "smtp" | "apprise"; +export type NotificationStatus = "sent" | "failed"; + +export interface NotificationRule { + id: number; + type: NotificationType; + channel: NotificationChannel; + lead_days: number; + target: string | null; + is_active: boolean; + created_at: IsoDateTime; +} + +export interface NotificationRuleInput { + type: NotificationType; + channel: NotificationChannel; + lead_days?: number; + target?: string | null; + is_active?: boolean; +} + +export interface NotificationLogEntry { + id: number; + rule_id: number; + ref_type: string; + ref_id: string; + dedupe_day: IsoDate; + sent_at: IsoDateTime; + status: NotificationStatus; + error: string | null; +} + +export interface ChannelStatus { + channel: NotificationChannel; + configured: boolean; + detail: string; +} + +export interface NotificationSettings { + enabled: boolean; + scheduler_enabled: boolean; + run_at: string; + timezone: string; + next_run_at: IsoDateTime | null; + channels: ChannelStatus[]; +} + +export interface TestResult { + channel: NotificationChannel; + configured: boolean; + sent: boolean; + error: string | null; +} + +export interface TestSendResponse { + results: TestResult[]; + any_sent: boolean; +} + +export interface NotificationRunResult { + checked: number; + sent: number; + skipped: number; + failed: number; +}