feat: agent-11 PR3 – Scheduler + Erinnerungs-Mails + Notification-Prefs

Geplante Erinnerungen (Feature-Parität mit Urlaubsverwaltung):

- APScheduler (AsyncIOScheduler) in der FastAPI-Lifespan; tägliche Jobs ab
  settings.reminder_hour. Redis-Tageslock gegen Doppelversand bei mehreren
  Prozessen; jeder Job mit eigener Session + RLS-Bypass.
- Drei Jobs (auch einzeln aufrufbar): offene Anträge an Genehmiger,
  Resturlaub-Verfall-Vorwarnung an Mitarbeiter, fehlende AU an HR.
- Pro-User notification_prefs (JSONB, opt-out); GET/PATCH /users/me/notification-prefs
  + ProfilePage-UI; Vertreter-Mail respektiert die Prefs.
- Manueller Trigger POST /companies/me/run-reminders (Admin) – gleiche Logik,
  firmen-scoped (testbar ohne Warten).
- Bugfix: GET-/PATCH-Urlaubskonto (update_balance) nutzte nicht existente
  Felder (base_days/carried_over_days/ip_address) → korrigiert auf
  entitled_days/carried_over/ip + company_id; available_days ergänzt.

Migration 0037 (users.notification_prefs). 188/188 Tests grün. Deployed 137 + 164.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 12:21:14 +02:00
co-authored by Claude Opus 4.8
parent e8bed43570
commit 2f110df619
15 changed files with 542 additions and 9 deletions
+5
View File
@@ -25,6 +25,11 @@ class Settings(BaseSettings):
# Redis
redis_url: str = "redis://localhost:6379/0"
# Scheduler (Erinnerungs-Mails, agent-11 PR3)
scheduler_enabled: bool = True
reminder_hour: int = 7 # Server-Stunde für tägliche Erinnerungen
carryover_reminder_days: int = 14 # Vorlauf für Resturlaub-Verfall-Erinnerung
# JWT
access_token_expire_minutes: int = 30
refresh_token_expire_days: int = 30
+27
View File
@@ -0,0 +1,27 @@
"""Pro-User-Benachrichtigungseinstellungen (agent-11 PR3).
Bekannte Keys + Defaults. Ein leeres `notification_prefs`-dict bedeutet „alle an"
(opt-out-Modell). Neue Keys defaulten daher automatisch auf aktiv.
"""
from __future__ import annotations
# key -> (Label, Default-aktiv)
NOTIFICATION_TYPES: dict[str, tuple[str, bool]] = {
"substitute_assigned": ("Als Vertretung eingetragen", True),
"pending_approvals": ("Offene Anträge zur Genehmigung (Zusammenfassung)", True),
"carryover_expiry": ("Resturlaub verfällt bald", True),
"certificate_overdue": ("Fehlende AU-Bescheinigung (HR)", True),
}
def pref_enabled(user, key: str) -> bool:
"""Ob der User Benachrichtigungen vom Typ `key` erhalten möchte."""
prefs = getattr(user, "notification_prefs", None) or {}
default = NOTIFICATION_TYPES.get(key, ("", True))[1]
return bool(prefs.get(key, default))
def normalize_prefs(prefs: dict | None) -> dict:
"""Nur bekannte Keys übernehmen, auf bool casten."""
prefs = prefs or {}
return {k: bool(prefs[k]) for k in NOTIFICATION_TYPES if k in prefs}