From c83fb31408a6ab0ffa827c333f3063bf2cca5c08 Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 23 Jun 2026 12:21:14 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20agent-11=20PR3=20=E2=80=93=20Scheduler?= =?UTF-8?q?=20+=20Erinnerungs-Mails=20+=20Notification-Prefs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/core/config.py | 5 + backend/app/core/notifications.py | 27 +++ backend/app/main.py | 9 + backend/app/models/user.py | 5 +- backend/app/routers/absences.py | 18 +- backend/app/routers/companies.py | 13 ++ backend/app/routers/users.py | 28 +++ backend/app/schemas/user.py | 10 + backend/app/services/absence_service.py | 3 + backend/app/services/email_service.py | 59 +++++ backend/app/services/scheduler_service.py | 215 ++++++++++++++++++ .../versions/0037_notification_prefs.py | 24 ++ backend/requirements.txt | 1 + backend/tests/test_reminders.py | 98 ++++++++ frontend/src/pages/ProfilePage.tsx | 36 ++- 15 files changed, 542 insertions(+), 9 deletions(-) create mode 100644 backend/app/core/notifications.py create mode 100644 backend/app/services/scheduler_service.py create mode 100644 backend/migrations/versions/0037_notification_prefs.py create mode 100644 backend/tests/test_reminders.py diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 1ecc2a6..92c75e8 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 diff --git a/backend/app/core/notifications.py b/backend/app/core/notifications.py new file mode 100644 index 0000000..c91663d --- /dev/null +++ b/backend/app/core/notifications.py @@ -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} diff --git a/backend/app/main.py b/backend/app/main.py index 2398657..b88a88f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -41,8 +41,17 @@ async def lifespan(app: FastAPI): "In Production sollte ALLOWED_HOSTS in .env konfiguriert sein " "um Host-Header-Injection zu verhindern." ) + + # Erinnerungs-Scheduler (agent-11 PR3) – nicht im Test-Kontext starten + import sys + if settings.scheduler_enabled and "pytest" not in sys.modules: + from app.services.scheduler_service import start as start_scheduler + start_scheduler() + yield # Shutdown + from app.services.scheduler_service import shutdown as shutdown_scheduler + shutdown_scheduler() await engine.dispose() diff --git a/backend/app/models/user.py b/backend/app/models/user.py index d7e4c3c..38d2470 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -4,7 +4,7 @@ from datetime import datetime, date from typing import TYPE_CHECKING from sqlalchemy import Boolean, Date, DateTime, Enum, ForeignKey, String, Text, func -from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.database import Base @@ -54,6 +54,9 @@ class User(Base): entry_date: Mapped[date | None] = mapped_column(Date) exit_date: Mapped[date | None] = mapped_column(Date) + # Pro-User-Benachrichtigungseinstellungen (agent-11 PR3). Leeres dict = alle an. + notification_prefs: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}") + # Kiosk auth kiosk_pin_hash: Mapped[str | None] = mapped_column(Text) kiosk_qr_token: Mapped[str | None] = mapped_column(Text, unique=True) diff --git a/backend/app/routers/absences.py b/backend/app/routers/absences.py index 54f5ad1..ea747c1 100644 --- a/backend/app/routers/absences.py +++ b/backend/app/routers/absences.py @@ -426,28 +426,31 @@ async def update_balance( balance = await absence_service.get_balance(user_id, year, db) # Alte Werte für AuditLog sichern - old_base = balance.base_days - old_special = balance.special_days - old_carried = balance.carried_over_days + old_value = { + "entitled_days": balance.entitled_days, + "special_days": balance.special_days, + "carried_over": balance.carried_over, + } for field, value in data.model_dump(exclude_unset=True).items(): setattr(balance, field, value) # AuditLog schreiben db.add(AuditLog( + company_id=current_user.company_id, user_id=current_user.id, action="update_vacation_balance", entity_type="vacation_balance", entity_id=balance.id, - old_value={"base_days": old_base, "special_days": old_special, "carried_over_days": old_carried}, + old_value=old_value, new_value={ - "base_days": balance.base_days, + "entitled_days": balance.entitled_days, "special_days": balance.special_days, - "carried_over_days": balance.carried_over_days, + "carried_over": balance.carried_over, "target_user_id": str(user_id), "year": year, }, - ip_address=get_client_ip(request), + ip=get_client_ip(request), )) await db.commit() @@ -456,6 +459,7 @@ async def update_balance( expires_at, expired = _carryover_expiry(company, year) if company else (None, False) return VacationBalanceOut.model_validate(balance).model_copy(update={ "pending_days": pending, + "available_days": absence_service.effective_available(balance, expired), "carried_over_expires_at": expires_at, "carried_over_expired": expired, }) diff --git a/backend/app/routers/companies.py b/backend/app/routers/companies.py index ac66969..44cec00 100644 --- a/backend/app/routers/companies.py +++ b/backend/app/routers/companies.py @@ -39,6 +39,19 @@ async def get_my_company(current_user: CurrentUser, db: AsyncSession = Depends(g return CompanyOut.model_validate(company) +@router.post("/me/run-reminders") +async def run_reminders_now( + current_user: User = require_role(*_admin_roles), + db: AsyncSession = Depends(get_db), +): + """Erinnerungs-Jobs (offene Anträge, Resturlaub-Verfall, AU fehlt) sofort für die + eigene Firma ausführen. Gleiche Logik wie der tägliche Scheduler.""" + from app.services.scheduler_service import run_all_reminders + result = await run_all_reminders(db, company_id=current_user.company_id) + await db.commit() + return {"sent": result} + + @router.patch("/me", response_model=CompanyOut) async def update_my_company( data: CompanyUpdate, diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 7e7bc2f..041c686 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -12,6 +12,8 @@ from app.schemas.auth import MessageResponse from app.schemas.user import ( InviteRequest, NextPersonnelNumberResponse, + NotificationPrefsUpdate, + NotificationTypeOut, SetKioskPinRequest, UserImportResult, UserImportRowResult, @@ -79,6 +81,32 @@ async def get_me(current_user: CurrentUser): return UserOut.model_validate(current_user) +@router.get("/me/notification-prefs", response_model=list[NotificationTypeOut]) +async def get_notification_prefs(current_user: CurrentUser): + from app.core.notifications import NOTIFICATION_TYPES, pref_enabled + return [ + NotificationTypeOut(key=k, label=label, enabled=pref_enabled(current_user, k)) + for k, (label, _default) in NOTIFICATION_TYPES.items() + ] + + +@router.patch("/me/notification-prefs", response_model=list[NotificationTypeOut]) +async def update_notification_prefs( + data: NotificationPrefsUpdate, + current_user: CurrentUser, + db: AsyncSession = Depends(get_db), +): + from app.core.notifications import NOTIFICATION_TYPES, normalize_prefs, pref_enabled + merged = dict(current_user.notification_prefs or {}) + merged.update(normalize_prefs(data.prefs)) + current_user.notification_prefs = merged # neues dict → JSONB-Änderung erkannt + await db.commit() + return [ + NotificationTypeOut(key=k, label=label, enabled=pref_enabled(current_user, k)) + for k, (label, _default) in NOTIFICATION_TYPES.items() + ] + + @router.get("/next-personnel-number", response_model=NextPersonnelNumberResponse) async def next_personnel_number( current_user: User = require_role(*_hr_roles), diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 3fc044f..7c3a327 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -32,6 +32,16 @@ class UserOut(BaseModel): exit_date: date | None = None +class NotificationTypeOut(BaseModel): + key: str + label: str + enabled: bool + + +class NotificationPrefsUpdate(BaseModel): + prefs: dict[str, bool] + + class UserUpdate(BaseModel): first_name: str | None = Field(None, min_length=1, max_length=100) last_name: str | None = Field(None, min_length=1, max_length=100) diff --git a/backend/app/services/absence_service.py b/backend/app/services/absence_service.py index 05e872d..e5a9780 100644 --- a/backend/app/services/absence_service.py +++ b/backend/app/services/absence_service.py @@ -1073,6 +1073,9 @@ class AbsenceService: requester = await db.get(User, absence.user_id) if substitute is None or requester is None or not substitute.email: return + from app.core.notifications import pref_enabled + if not pref_enabled(substitute, "substitute_assigned"): + return from app.services.email_service import email_service try: await email_service.send_substitute_notification(substitute, requester, absence, db) diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index f0dd439..9eb2532 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -163,6 +163,65 @@ class EmailService: cfg, ) + async def send_pending_approvals_digest( + self, approver: "User", items: list[dict], db: AsyncSession + ) -> None: + """Tägliche Zusammenfassung offener Anträge an eine genehmigende Person.""" + cfg = await self._load_smtp(approver.company_id, db) + rows = "".join( + f"
  • {i['user_name']} – {i['type_name']} " + f"({i['start']}{' – ' + i['end'] if i['end'] != i['start'] else ''}, " + f"{i['working_days']} Tag(e))
  • " + for i in items + ) + body = f""" +

    Offene Abwesenheitsanträge

    +

    Hallo {approver.first_name}, es warten {len(items)} Anträge auf deine Genehmigung:

    + + Anträge prüfen + """ + await self._send( + approver.email, f"{len(items)} offene Abwesenheitsanträge", + _html_wrapper("Offene Anträge", body), cfg, + ) + + async def send_carryover_expiry_reminder( + self, user: "User", remaining: int, expires_at, db: AsyncSession + ) -> None: + cfg = await self._load_smtp(user.company_id, db) + datum = expires_at.strftime("%d.%m.%Y") + body = f""" +

    Resturlaub verfällt bald

    +

    Hallo {user.first_name}, du hast noch {remaining} Urlaubstage, + die am {datum} verfallen.

    +

    Plane deinen Urlaub rechtzeitig ein, damit nichts verloren geht.

    + Urlaub planen + """ + await self._send( + user.email, f"Dein Resturlaub verfällt am {datum}", + _html_wrapper("Resturlaub", body), cfg, + ) + + async def send_certificate_overdue_digest( + self, hr_user: "User", items: list[dict], db: AsyncSession + ) -> None: + cfg = await self._load_smtp(hr_user.company_id, db) + rows = "".join( + f"
  • {i['user_name']} – krank seit {i['start']}, AU fällig seit {i['due']}
  • " + for i in items + ) + body = f""" +

    Fehlende AU-Bescheinigungen

    +

    Hallo {hr_user.first_name}, für {len(items)} Krankmeldung(en) + fehlt die Arbeitsunfähigkeitsbescheinigung:

    + + Abwesenheiten ansehen + """ + await self._send( + hr_user.email, f"{len(items)} fehlende AU-Bescheinigung(en)", + _html_wrapper("AU fehlt", body), cfg, + ) + async def send_test(self, cfg: SmtpConfig, to: str) -> None: """Test-E-Mail direkt mit übergebenem Konfigurationsobjekt.""" body = f""" diff --git a/backend/app/services/scheduler_service.py b/backend/app/services/scheduler_service.py new file mode 100644 index 0000000..8e7c24a --- /dev/null +++ b/backend/app/services/scheduler_service.py @@ -0,0 +1,215 @@ +"""Geplante Erinnerungs-Jobs (agent-11 PR3). + +AsyncIOScheduler läuft im FastAPI-Event-Loop. Jeder Job öffnet eine eigene +DB-Session mit RLS-Bypass (interner Job ohne Tenant-Kontext) und holt vor dem +Versand einen Redis-Tageslock, damit bei mehreren Prozessen nicht doppelt +gemailt wird. Die run_*-Funktionen sind ohne Scheduler aufrufbar (Tests + +manueller Trigger über die API) und können auf eine Firma eingegrenzt werden. +""" +from __future__ import annotations + +import logging +from datetime import date + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.notifications import pref_enabled +from app.models.absence import Absence, AbsenceStatus +from app.models.absence_type import AbsenceCategory, AbsenceType +from app.models.company import Company +from app.models.user import User, UserRole +from app.models.vacation_balance import VacationBalance +from app.services.absence_service import absence_service +from app.services.email_service import email_service + +log = logging.getLogger(__name__) + +_APPROVER_ROLES = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN) +_HR_ROLES = (UserRole.HR, UserRole.COMPANY_ADMIN) + +_scheduler = None + + +def _fmt(d: date) -> str: + return d.strftime("%d.%m.%Y") + + +async def _active_companies(db: AsyncSession, company_id) -> list[Company]: + q = select(Company).where(Company.is_active.is_(True)) + if company_id: + q = q.where(Company.id == company_id) + return list((await db.scalars(q)).all()) + + +async def _recipients(db: AsyncSession, company_id, roles) -> list[User]: + return list((await db.scalars( + select(User).where( + User.company_id == company_id, + User.is_active.is_(True), + User.role.in_(roles), + ) + )).all()) + + +# ── Job-Logik ──────────────────────────────────────────────────────────────── + +async def run_pending_approvals(db: AsyncSession, company_id=None) -> int: + """Zusammenfassung offener Anträge an alle Genehmiger je Firma. Liefert # Mails.""" + sent = 0 + for company in await _active_companies(db, company_id): + rows = (await db.execute( + select(Absence, User, AbsenceType) + .join(User, Absence.user_id == User.id) + .join(AbsenceType, Absence.type_id == AbsenceType.id) + .where(User.company_id == company.id, Absence.status == AbsenceStatus.PENDING) + .order_by(Absence.start_date) + )).all() + if not rows: + continue + items = [{ + "user_name": u.full_name, "type_name": t.name, + "start": _fmt(a.start_date), "end": _fmt(a.end_date), + "working_days": float(a.working_days), + } for a, u, t in rows] + + for approver in await _recipients(db, company.id, _APPROVER_ROLES): + if not approver.email or not pref_enabled(approver, "pending_approvals"): + continue + await email_service.send_pending_approvals_digest(approver, items, db) + sent += 1 + return sent + + +async def run_carryover_expiry(db: AsyncSession, company_id=None) -> int: + """Erinnert Mitarbeiter an bald verfallenden Resturlaub (Vorlauf konfigurierbar).""" + today = date.today() + year = today.year + sent = 0 + for company in await _active_companies(db, company_id): + expires_at, expired = absence_service._carryover_expired(company, year) + if expires_at is None or expired: + continue + if (expires_at - today).days > settings.carryover_reminder_days: + continue + balances = (await db.execute( + select(VacationBalance, User) + .join(User, VacationBalance.user_id == User.id) + .where( + User.company_id == company.id, User.is_active.is_(True), + VacationBalance.year == year, VacationBalance.carried_over > 0, + ) + )).all() + for bal, user in balances: + expiring = max(0, bal.carried_over - bal.used_days) # Übertrag zuerst verbraucht + if expiring <= 0 or not user.email or not pref_enabled(user, "carryover_expiry"): + continue + await email_service.send_carryover_expiry_reminder(user, expiring, expires_at, db) + sent += 1 + return sent + + +async def run_certificate_overdue(db: AsyncSession, company_id=None) -> int: + """Meldet HR fehlende AU-Bescheinigungen (knüpft an agent-05).""" + today = date.today() + sent = 0 + for company in await _active_companies(db, company_id): + rows = (await db.execute( + select(Absence, User) + .join(User, Absence.user_id == User.id) + .join(AbsenceType, Absence.type_id == AbsenceType.id) + .where( + User.company_id == company.id, + AbsenceType.category == AbsenceCategory.SICK, + Absence.certificate_required_by.isnot(None), + Absence.certificate_required_by < today, + Absence.certificate_received_at.is_(None), + Absence.status == AbsenceStatus.APPROVED, + ) + .order_by(Absence.start_date) + )).all() + if not rows: + continue + items = [{ + "user_name": u.full_name, "start": _fmt(a.start_date), + "due": _fmt(a.certificate_required_by), + } for a, u in rows] + + for hr in await _recipients(db, company.id, _HR_ROLES): + if not hr.email or not pref_enabled(hr, "certificate_overdue"): + continue + await email_service.send_certificate_overdue_digest(hr, items, db) + sent += 1 + return sent + + +async def run_all_reminders(db: AsyncSession, company_id=None) -> dict: + return { + "pending_approvals": await run_pending_approvals(db, company_id), + "carryover_expiry": await run_carryover_expiry(db, company_id), + "certificate_overdue": await run_certificate_overdue(db, company_id), + } + + +# ── Scheduler-Verdrahtung ──────────────────────────────────────────────────── + +def _acquire_daily_lock(name: str) -> bool: + """True, wenn dieser Prozess den heutigen Job ausführen darf (Redis SET NX). + Ohne Redis: immer True (Single-Prozess-Annahme).""" + from app.core.redis import get_redis_client + redis = get_redis_client() + if redis is None: + return True + key = f"reminder_lock:{name}:{date.today().isoformat()}" + try: + return bool(redis.set(key, "1", nx=True, ex=23 * 3600)) + except Exception as exc: + log.warning("Reminder-Lock fehlgeschlagen (%s) – führe trotzdem aus: %s", name, exc) + return True + + +async def _run_job(name: str, fn) -> None: + if not _acquire_daily_lock(name): + log.info("Reminder-Job %s bereits von anderem Prozess übernommen.", name) + return + from sqlalchemy import text + from app.core.database import AsyncSessionLocal + try: + async with AsyncSessionLocal() as db: + await db.execute(text("SET LOCAL app.bypass_rls = 'on'")) + count = await fn(db) + await db.commit() + log.info("Reminder-Job %s: %s Mail(s) versendet.", name, count) + except Exception as exc: + log.exception("Reminder-Job %s fehlgeschlagen: %s", name, exc) + + +def start() -> None: + global _scheduler + if _scheduler is not None: + return + try: + from apscheduler.schedulers.asyncio import AsyncIOScheduler + from apscheduler.triggers.cron import CronTrigger + except Exception as exc: + log.warning("APScheduler nicht verfügbar – Erinnerungen deaktiviert: %s", exc) + return + + _scheduler = AsyncIOScheduler() + hour = settings.reminder_hour + _scheduler.add_job(_run_job, CronTrigger(hour=hour, minute=0), + args=["pending_approvals", run_pending_approvals], id="pending_approvals") + _scheduler.add_job(_run_job, CronTrigger(hour=hour, minute=10), + args=["carryover_expiry", run_carryover_expiry], id="carryover_expiry") + _scheduler.add_job(_run_job, CronTrigger(hour=hour, minute=20), + args=["certificate_overdue", run_certificate_overdue], id="certificate_overdue") + _scheduler.start() + log.info("Reminder-Scheduler gestartet (täglich ab %02d:00 Uhr).", hour) + + +def shutdown() -> None: + global _scheduler + if _scheduler is not None: + _scheduler.shutdown(wait=False) + _scheduler = None diff --git a/backend/migrations/versions/0037_notification_prefs.py b/backend/migrations/versions/0037_notification_prefs.py new file mode 100644 index 0000000..590835a --- /dev/null +++ b/backend/migrations/versions/0037_notification_prefs.py @@ -0,0 +1,24 @@ +"""User notification preferences (agent-11 PR3) + +Revision ID: 0037 +Revises: 0036 +Create Date: 2026-06-23 + +users.notification_prefs JSONB (opt-out je Benachrichtigungstyp; '{}' = alle an). +""" +from alembic import op + +revision = "0037" +down_revision = "0036" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + "ALTER TABLE users ADD COLUMN IF NOT EXISTS notification_prefs JSONB NOT NULL DEFAULT '{}'" + ) + + +def downgrade() -> None: + op.execute("ALTER TABLE users DROP COLUMN IF EXISTS notification_prefs") diff --git a/backend/requirements.txt b/backend/requirements.txt index 3db0a0b..62febba 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -26,3 +26,4 @@ aiosqlite>=0.20.0 weasyprint>=61.0 typer>=0.12.0 rich>=13.7.0 +apscheduler>=3.10.0 diff --git a/backend/tests/test_reminders.py b/backend/tests/test_reminders.py new file mode 100644 index 0000000..0a31164 --- /dev/null +++ b/backend/tests/test_reminders.py @@ -0,0 +1,98 @@ +"""Tests für agent-11 PR3: geplante Erinnerungen + Notification-Prefs.""" +import pytest +import pytest_asyncio +from datetime import date, timedelta +from httpx import AsyncClient + + +@pytest_asyncio.fixture(scope="session", loop_scope="session") +async def rem_company(client: AsyncClient): + resp = await client.post("/api/v1/auth/register", json={ + "company_name": "Reminder AG", "first_name": "Rita", "last_name": "Admin", + "email": "admin@reminderag.de", "password": "Secret123", + }) + assert resp.status_code == 201, resp.text + admin_h = {"Authorization": f"Bearer {resp.json()['access_token']}"} + + inv = await client.post("/api/v1/users/invite", json={ + "first_name": "Emil", "last_name": "Employee", "email": "emil@reminderag.de", + "role": "EMPLOYEE", "initial_password": "Secret123", + }, headers=admin_h) + assert inv.status_code == 201, inv.text + login = await client.post("/api/v1/auth/login", json={ + "email": "emil@reminderag.de", "password": "Secret123", + }) + emp_h = {"Authorization": f"Bearer {login.json()['access_token']}"} + return {"admin": admin_h, "emp": emp_h, "emp_id": inv.json()["id"]} + + +async def _vacation_type(client, headers): + types = (await client.get("/api/v1/absence-types/", headers=headers)).json() + return next(t for t in types if t["name"] == "Urlaub")["id"] + + +@pytest.mark.asyncio(loop_scope="session") +async def test_notification_prefs_list(client: AsyncClient, rem_company): + r = await client.get("/api/v1/users/me/notification-prefs", headers=rem_company["admin"]) + assert r.status_code == 200, r.text + keys = {p["key"] for p in r.json()} + assert "pending_approvals" in keys and "carryover_expiry" in keys + assert all(p["enabled"] for p in r.json()) # Default: alle an + + +@pytest.mark.asyncio(loop_scope="session") +async def test_run_reminders_pending_approvals(client: AsyncClient, rem_company): + vt = await _vacation_type(client, rem_company["emp"]) + start = date.today() + timedelta(days=(7 - date.today().weekday()) + 70) + await client.post("/api/v1/absences/", json={ + "type_id": vt, "start_date": str(start), "end_date": str(start + timedelta(days=2)), + }, headers=rem_company["emp"]) + + run = await client.post("/api/v1/companies/me/run-reminders", headers=rem_company["admin"]) + assert run.status_code == 200, run.text + assert run.json()["sent"]["pending_approvals"] >= 1 + + +@pytest.mark.asyncio(loop_scope="session") +async def test_run_reminders_certificate_overdue(client: AsyncClient, rem_company): + # Krankmeldung in der Vergangenheit → AU längst fällig, nicht eingegangen + past = date.today() - timedelta(days=10) + sick = await client.post("/api/v1/absences/quick-sick", json={ + "start_date": str(past), "end_date": str(past), + }, headers=rem_company["emp"]) + assert sick.status_code == 201, sick.text + + run = await client.post("/api/v1/companies/me/run-reminders", headers=rem_company["admin"]) + assert run.json()["sent"]["certificate_overdue"] >= 1 + + +@pytest.mark.asyncio(loop_scope="session") +async def test_notification_prefs_disable_suppresses_mail(client: AsyncClient, rem_company): + # pending_approvals für den Admin abschalten → keine Digest-Mail mehr + patch = await client.patch("/api/v1/users/me/notification-prefs", + json={"prefs": {"pending_approvals": False}}, headers=rem_company["admin"]) + assert patch.status_code == 200 + assert any(p["key"] == "pending_approvals" and p["enabled"] is False for p in patch.json()) + + run = await client.post("/api/v1/companies/me/run-reminders", headers=rem_company["admin"]) + assert run.json()["sent"]["pending_approvals"] == 0 + + # wieder aktivieren (sauberer Zustand) + await client.patch("/api/v1/users/me/notification-prefs", + json={"prefs": {"pending_approvals": True}}, headers=rem_company["admin"]) + + +@pytest.mark.asyncio(loop_scope="session") +async def test_run_reminders_carryover_expiry(client: AsyncClient, rem_company): + # Verfall auf ~7 Tage in der Zukunft konfigurieren + target = date.today() + timedelta(days=7) + await client.patch("/api/v1/companies/me", json={ + "settings": {"carryover_expires_month": target.month, "carryover_expires_day": target.day}, + }, headers=rem_company["admin"]) + # Resturlaub fürs aktuelle Jahr beim Mitarbeiter setzen + yr = date.today().year + await client.patch(f"/api/v1/absences/balance/{rem_company['emp_id']}?year={yr}", + json={"carried_over": 5}, headers=rem_company["admin"]) + + run = await client.post("/api/v1/companies/me/run-reminders", headers=rem_company["admin"]) + assert run.json()["sent"]["carryover_expiry"] >= 1 diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index c707989..7f6e5dc 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -185,6 +185,8 @@ export function ProfilePage() { const [pinSuccess, setPinSuccess] = useState(false) const [pinError, setPinError] = useState(null) + const [notifPrefs, setNotifPrefs] = useState<{ key: string; label: string; enabled: boolean }[]>([]) + const loadMe = () => { api.get('/auth/me').then(u => { setMe(u) @@ -192,7 +194,21 @@ export function ProfilePage() { }).catch(() => {}) } - useEffect(() => { loadMe() }, []) + const loadNotifPrefs = () => { + api.get<{ key: string; label: string; enabled: boolean }[]>('/users/me/notification-prefs') + .then(setNotifPrefs).catch(() => {}) + } + + async function toggleNotif(key: string, enabled: boolean) { + setNotifPrefs(prev => prev.map(p => p.key === key ? { ...p, enabled } : p)) + try { + const updated = await api.patch<{ key: string; label: string; enabled: boolean }[]>( + '/users/me/notification-prefs', { prefs: { [key]: enabled } }) + setNotifPrefs(updated) + } catch { loadNotifPrefs() } + } + + useEffect(() => { loadMe(); loadNotifPrefs() }, []) async function changePassword(e: React.FormEvent) { e.preventDefault() @@ -328,6 +344,24 @@ export function ProfilePage() { + + {/* Benachrichtigungen */} + {notifPrefs.length > 0 && ( +
    +

    E-Mail-Benachrichtigungen

    +

    Lege fest, worüber du per E-Mail informiert werden möchtest.

    +
    + {notifPrefs.map(p => ( + + ))} +
    +
    + )} )