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 => (
+
+ ))}
+
+
+ )}
)