diff --git a/.gitignore b/.gitignore
index 0a832c9..f64c69e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,13 +1,13 @@
# Config with credentials
-mitarbeiter-app/config.php
-mitarbeiter-app/config.secret.php
+**/mitarbeiter-app*/lib/config.php
+**/mitarbeiter-app*/lib/config.secret.php
# Uploaded files
-mitarbeiter-app/uploads/
-mitarbeiter-app/downloads/
-mitarbeiter-app/fortbildung-materials/
-mitarbeiter-app/assets/avatars/
-mitarbeiter-app/data/
+**/mitarbeiter-app*/uploads/
+**/mitarbeiter-app*/downloads/
+**/mitarbeiter-app*/fortbildung-materials/
+**/mitarbeiter-app*/assets/avatars/
+**/mitarbeiter-app*/data/
# OS / editor
.DS_Store
@@ -19,3 +19,7 @@ Thumbs.db
# Logs
*.log
+
+# .NET (omsorgCore)
+omsorgCore/**/bin/
+omsorgCore/**/obj/
diff --git a/CLAUDE.md b/CLAUDE.md
index 8e775c7..fc49895 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,116 +1,51 @@
-# CLAUDE.md
+# CLAUDE.md — OMSORG Business Controls Pro (Monorepo-Root)
-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+Diese Datei gilt für das gesamte Repository. Jedes Teilprojekt hat zusätzlich eine eigene `CLAUDE.md` mit projektspezifischen Details — bei Arbeit in einem Unterordner gilt dessen `CLAUDE.md` zusätzlich zu dieser hier.
-## Project Overview
+## Was das hier ist
-This is the OMSORG website plus **OMSORG Connect**, an internal employee web app ("Mitarbeiter-App") for a German nursing care company. There is no build system — everything is plain PHP, HTML, CSS, and vanilla JS deployed directly to a web server (`htdocs`).
+Ein Monorepo für die OMSORG-Plattform, einer Software für ein Pflege-Zeitarbeitsunternehmen. Vier Ebenen, drei Teilprojekte (Core+Engine bilden ein Backend, siehe unten):
-## Deployment
+| Verzeichnis | Rolle | Stack | Ist-Stand |
+|---|---|---|---|
+| `omsorgWeb/` | Öffentliche Website + **OMSORG Connect** (Mitarbeiter-App für Außendienst) | PHP, MySQL, vanilla JS, PWA | ✅ produktiv im Einsatz |
+| `omsorgapp/` | **OMSORG Desktop** — Software für Büromitarbeiter (Sabina, Malik, Sabrina, Sascha) | Electron + React + Vite | 🔶 frühes Grundgerüst (Release 0.1.1), lokale JSON-Datenhaltung |
+| `omsorgCore/` | **OMSORG Backend** — gemeinsame Datenbasis + Automatisierung (Core + Engine als eine Komponente) | C# / .NET 8, ASP.NET Core (Controller), PostgreSQL/EF Core, JWT-Auth | 🔶 Grundgerüst steht (Domain/Application/Infrastructure/Engine/Api, Rechtesystem, Auth, Employee-Endpoint end-to-end verifiziert); noch keine Anbindung von `omsorgWeb`/`omsorgapp`, DB-Migration noch nicht gegen echte Postgres getestet |
-Upload the full directory contents to the server's `htdocs` folder and overwrite existing files. After deploying, clear the browser cache or test in an incognito window.
+Leitdokumente:
+- `omsorg.md` — Kurzvision (Rohnotizen)
+- `omsorgapp/docs/OMSORG-BLUEPRINT.md` — vollständige Vision/Architektur (20 Kapitel, verbindliche Arbeitsgrundlage)
+- `REQUIREMENTS.md` — daraus abgeleitete, nummerierte funktionale/nicht-funktionale Anforderungen (FR-IDs, NFR-IDs) mit Ist-Status je Requirement
-There are no build steps, package managers, or test runners.
+**Immer zuerst `REQUIREMENTS.md` konsultieren**, bevor an einem Modul gearbeitet wird — dort steht, was bereits existiert (✅), teilweise existiert (🔶) oder noch offen ist (⬜), inklusive Verweis auf die konkreten Dateien.
-## Architecture
+## Zentrales Architekturprinzip
-### Public website (`/`)
-Static HTML with a contact form:
-- `index.html` — main landing page
-- `send-form.php` — handles the public contact form, sends email via PHP `mail()`
-- `send-status-template.php` — reusable status page template for form results
-- `datenschutz.html`, `impressum.html` — legal pages
+**Jede Information wird nur einmal gespeichert.** Die sechs Core-Objekte (Mitarbeiter, Einrichtung, Vertrag, Auftrag, Zeiterfassung, Rechnung) leben in genau einer Datenquelle — `omsorgCore` — und werden von Desktop, Connect und allen Auswertungen gemeinsam genutzt. Aktuell ist das **noch nicht der Fall**: `omsorgWeb` hat eine eigene MySQL-DB, `omsorgapp` eine eigene lokale JSON-DB. Das ist der bekannte Hauptmangel der aktuellen Architektur (siehe `REQUIREMENTS.md` Abschnitt 2 und 10) — keine neue Insellösung schaffen, sondern auf die Zusammenführung hinarbeiten.
-### Employee app (`/mitarbeiter-app/`)
-A session-based PHP app with no framework, backed by a **MySQL database** (PDO).
+OMSORG Core (Datenschicht) und OMSORG Engine (ereignisgesteuerte Automatisierung) sind **eine** Backend-Komponente mit zwei internen Schichten, kein separates Deployable für die Engine.
-#### Entry point
-- `index.php` — login page. Redirects to `pages/dashboard.php` if already logged in. Includes rate limiting: 5 failed attempts trigger a 10-minute lockout.
-- `setup.php` — one-time setup script (run once after first deploy to bootstrap the DB)
+## Rollen (gelten repoweit)
-#### Library (`lib/`)
-- `auth.php` — included at the top of every protected page. Provides `require_login()`, `require_admin()`, `is_logged_in()`, `is_admin()`, `current_user()`, `current_name()`, `current_username()`, `csrf_token()`, `csrf_field()`, `verify_csrf()`, `e()`. Users are stored in the `users` DB table. Login is case-insensitive on username.
-- `db.php` — provides `db(): PDO` (singleton). Reads credentials from `config.php`, runs pending migrations on first connection, and sets `PDO::ERRMODE_EXCEPTION`.
-- `config.php` — returns array with MySQL DSN/credentials, SMTP settings, and mail recipient addresses. Import with `$config = require __DIR__ . '/config.php';`.
-- `layout.php` — shared sidebar layout. Call `layout_start($title, $current_page)` and `layout_end()` to wrap page content. Renders the sidebar nav, user avatar, and injects the service worker.
-- `mail.php` — provides `smtp_send($cfg, $to, $subject, $body, $attach = [])`. Raw SMTP implementation (no PHPMailer), supports SSL (port 465) and STARTTLS (port 587), and optional file attachments.
+| Rolle | Zugriff |
+|---|---|
+| Sabina, Malik (Geschäftsführung) | Vollzugriff, inkl. Gewinn/Margen |
+| Sabrina (Disposition/Buchhaltung) | fast alles außer vollständigem Gewinn/Gesamtmargen |
+| Sascha (Recruiting) | Recruiting/CRM, kein Zugriff auf Rechnungen/Zahlungen/Gewinn/Controlling |
+| Außendienst | nur über OMSORG Connect, nur eigene Daten |
-#### Pages (`pages/`)
-Each page is a standalone PHP file using `layout_start`/`layout_end`:
-- `dashboard.php` — main landing page after login; shows news and quick links
-- `stundennachweis.php` — upload monthly timesheet (PDF/image)
-- `urlaubsantrag.php` — vacation request form
-- `abwesenheitsantrag.php` — absence request form (sick leave, etc.)
-- `benefitsantrag.php` — employee benefits request
-- `fortbildungsantrag.php` — training request with optional file upload
-- `einsatzbewertung.php` — rate a deployment/assignment
-- `einsatzanweisung.php` — view/download personal assignment instructions (PDF)
-- `dokumentenarchiv.php` — personal document archive (upload/view own documents)
-- `downloads.php` — company-wide file downloads (admin-managed)
-- `dienstplan.php` — view personal shift schedule
-- `werben.php` — refer a new employee
-- `settings.php` — profile settings: change password, upload avatar
-- `admin.php` — admin-only: tabs for Anträge, Nutzer, Dienstplan, Downloads, Fortbildungsmaterial, Stundennachweis, Bewertungen, News, Einsatzanweisung, Login-Versuche (failed login attempts: lists recent attempts, shows currently locked IPs, and clears the `login_attempts` table)
-- `admin-dienstplan.php` — admin shift planner view
-- Serve pages (`*-serve.php`, `dokument-serve.php`, `download-serve.php`, etc.) — stream protected files from `uploads/`, `downloads/`, `fortbildung-materials/` with auth check
+Jede neue Funktion muss serverseitig gegen diese Rechte prüfen, nicht nur im UI verstecken.
-#### Actions (`actions/`)
-POST-only endpoints, each does one thing and redirects back:
-- `submit-urlaubsantrag.php`, `submit-abwesenheitsantrag.php`, `submit-benefitsantrag.php`, `submit-fortbildungsantrag.php`, `submit-stundennachweis.php`, `submit-einsatzbewertung.php`, `submit-werben.php` — insert into the matching `requests_*` DB table, send notification email via `smtp_send()`
-- `admin-action.php` — admin status updates (accept/reject requests), user management (add/delete/reset password), news CRUD
-- `save-dienstplan.php` — admin saves shift entries
-- `downloads-action.php`, `downloads-reorder.php` — admin manages downloadable files
-- `fortbildung-material-action.php`, `fortbildung-material-reorder.php` — admin manages training materials
-- `einsatzanweisung-action.php` — admin uploads/assigns personal instruction PDFs
-- `upload-dokument.php`, `delete-dokument.php` — user document archive management
-- `upload-avatar.php` — user uploads profile picture (stored in `assets/avatars/`)
-- `save-profile.php` — user changes own password
-- `logout.php` — destroys session, redirects to `../index.php`
+## Arbeitsweise
-#### Frontend
-- `app.css` — all styles (no framework)
-- `manifest.webmanifest` + `service-worker.js` — PWA support ("Add to Home Screen")
+- Kleine, überprüfbare Schritte: eine Datei/Funktion bearbeiten, testen, Fehler beheben, committen.
+- Keine doppelt gepflegten Daten, keine isolierten Insellösungen, wiederverwendbare Komponenten.
+- Sicherheit und Datenschutz (DSGVO) sind Grundvoraussetzung, keine spätere Zusatzfunktion.
+- Branding/Design vom fachlichen Kern trennen (langfristiges White-Label-Ziel, siehe Blueprint Kap. 14) — aktuell nicht umgesetzt, aber bei neuem Code nicht zusätzlich verfestigen.
-### Database schema (MySQL)
-Managed via migrations in `migrations/`. Key tables:
-- `users` — id, username, name, role, password_hash, active, created_at, email, telefon, avatar
-- `requests_urlaubsantrag` — vacation requests (von, bis, vertretung, nachricht, status, admin_note)
-- `requests_abwesenheitsantrag` — absence requests (von, bis, grund, vertretung, nachricht, status, admin_note)
-- `requests_benefitsantrag` — benefits requests
-- `requests_fortbildungsantrag` — training requests (with optional file upload)
-- `requests_stundennachweis` — timesheet uploads (monat, filename)
-- `requests_werben` — employee referrals
-- `dienstplan` — shift schedule (user_id, date, schicht; UNIQUE on user_id+date)
-- `downloads` — company download files (title, filename, sort_order)
-- `fortbildung_materials` — training material files (title, filename, sort_order)
-- `dokumente` — user personal document archive (user_id, kategorie, filename)
-- `einsatzbewertungen` — deployment ratings
-- `einsatzanweisung` — one PDF per user (UNIQUE on user_id)
-- `news` — company news posts (title, text, date)
-- `schema_migrations` — tracks applied migrations
+## Wo was steht
-### Migration system
-`db.php` runs `_run_migrations()` on every connection. Migrations are PHP files in `migrations/` returning `['description' => ..., 'up' => [...SQL...]]`. They are applied in filename order and tracked in `schema_migrations`. Already-existing DBs without the migrations table are stamped as fully applied on first run.
-
-### File storage
-Uploaded files are stored outside webroot or protected by `.htaccess`:
-- `uploads/` — user form attachments (stundennachweis, fortbildung, einsatzanweisung). Filename pattern: `{date}_{username}_{type}_{randomhex}.{ext}`
-- `downloads/` — admin-managed company downloads (hashed filenames)
-- `fortbildung-materials/` — admin-managed training materials (hashed filenames)
-- `assets/avatars/` — user profile pictures (`{username}_{randomhex}.{ext}`)
-
-Allowed upload types: pdf, doc, docx, jpg, jpeg, png. Max size: 12 MB.
-
-### Security conventions
-- `lib/` is blocked from direct HTTP access via `lib/.htaccess`
-- `uploads/`, `downloads/`, `fortbildung-materials/`, `data/`, `migrations/` all have `.htaccess` denying direct access; files are served only through `*-serve.php` pages with auth checks
-- All output uses `e()` (alias for `htmlspecialchars`) to prevent XSS
-- Every POST form includes a CSRF token (`csrf_field()` in form, `verify_csrf()` in action)
-- Login rate-limiting: 5 failures → 10-minute session lockout
-- Uploads are renamed to random hex filenames before storage
-
-### Email (configured in `config.php`)
-- SMTP via `smtp_send()` in `lib/mail.php`; credentials in `config.php`
-- `mail_info` → `info@omsorg-pflegedienste.de` (Geschäftsführung)
-- `mail_sabrina` → `s.berggoetz@omsorg-pflegedienste.de` (Disposition)
-- `mail_from` → `no-reply@omsorg-pflegedienste.de`
+- Detaillierte technische Konventionen je Teilprojekt → `omsorgWeb/CLAUDE.md`, `omsorgapp/CLAUDE.md`, `omsorgCore/CLAUDE.md`.
+- Requirements/Akzeptanzkriterien → `REQUIREMENTS.md`.
+- Produktvision/Roadmap → `omsorgapp/docs/OMSORG-BLUEPRINT.md`.
+- Backend-Konfigurationswerte (was einstellbar ist, Defaults, Env-Var-Overrides) → `omsorgCore/CONFIGURATION.md`.
diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md
new file mode 100644
index 0000000..82a921e
--- /dev/null
+++ b/REQUIREMENTS.md
@@ -0,0 +1,261 @@
+# OMSORG Business Controls Pro — Requirements-Dokument (SRS)
+
+Status: Entwurf 1.0
+Basis: `omsorg.md` (Kurzvision) + `omsorgapp/docs/OMSORG-BLUEPRINT.md` (Vision/Architektur, verbindliche Arbeitsgrundlage)
+Zweck: Aus der Vision konkrete, testbare Anforderungen ableiten — Grundlage für Entwicklung, Abnahme und Tests.
+
+Dieses Dokument dupliziert den Blueprint nicht. Es referenziert ihn (`[Blueprint Kap. X]`) und übersetzt ihn in nummerierte Requirements mit Akzeptanzkriterium und Ist-Status.
+
+Status-Legende: ✅ vorhanden · 🔶 teilweise vorhanden · ⬜ offen
+
+---
+
+## 1. Einleitung
+
+### 1.1 Zweck
+Dieses Dokument definiert die funktionalen und nicht-funktionalen Anforderungen an OMSORG Business Controls Pro — die Plattform, mit der Omsorg (Pflege-Zeitarbeit) ihren gesamten Arbeitsalltag abwickelt.
+
+### 1.2 Geltungsbereich
+Umfasst alle drei Plattform-Ebenen: OMSORG Desktop, OMSORG Connect, OMSORG Backend (Core + Engine) [Blueprint Kap. 4].
+
+> **Architektur-Entscheidung:** Der Blueprint beschreibt Core und Engine als zwei separate Ebenen (Kap. 19/20). In diesem Requirements-Dokument werden sie als **eine Backend-Komponente** mit zwei internen Schichten geführt: Datenschicht (Core) und Event-Schicht (Engine), betrieben im selben Prozess/Service. Grund: Die Engine liest und schreibt permanent auf denselben Core-Objekten; eine Trennung in zwei Deployables würde nur Netzwerk-Roundtrips und Konsistenzrisiken erzeugen, ohne dass ein reales Skalierungsproblem das rechtfertigt. Die inhaltliche Beschreibung aus dem Blueprint bleibt unverändert — nur die Einordnung als eine statt zwei Plattform-Ebenen ändert sich.
+
+### 1.3 Begriffe
+| Begriff | Bedeutung |
+|---|---|
+| Mitarbeiter | Person im Personalbestand (Büro oder Außendienst) |
+| Einrichtung | Kunde (Pflegeeinrichtung), CRM-Objekt |
+| Vertrag | Verbindliche Vereinbarung (Arbeitsvertrag, Rahmenvertrag, Einsatzvertrag, ...) |
+| Auftrag | Personalbedarf einer Einrichtung |
+| Einsatz | Konkrete Zuweisung eines Mitarbeiters zu einem Auftrag/Zeitraum |
+| Zeiterfassung | Dokumentierte, tatsächlich geleistete Arbeitszeit |
+| Tätigkeitsnachweis | Nachweis-Dokument zu geleisteter Zeit (ggf. mit Unterschrift) |
+| Rechnung | Aus freigegebener Zeiterfassung + Konditionen erzeugtes Abrechnungsdokument |
+| Engine | Event-Schicht des OMSORG Backends: ereignisgesteuerte Automatisierung ohne eigene UI, im selben Prozess wie die Datenschicht (Core) [Blueprint Kap. 20] |
+
+---
+
+## 2. Systemüberblick & Ist-Stand
+
+| Ebene | Beschreibung | Ist-Stand im Repo |
+|---|---|---|
+| **OMSORG Desktop** | Vollständige Unternehmensmodule für Büromitarbeiter | 🔶 `omsorgapp/` — Electron/React, Release 0.1.1. Vorhanden: `HomePage`, `HomeStats`, `ContractWidget`, `EmployeesPage` mit Tabs/Detailpanel. Lokale JSON-DB (`~/Documents/Omsorg Business Controls Pro/database/omsorg-local-db.json`), noch keine SQLite/Server-Anbindung. |
+| **OMSORG Connect** | Mobile/Web-App für Außendienst | ✅ `omsorgWeb/mitarbeiter-app/` — PHP/MySQL, produktiv als PWA (Manifest + Service Worker). Deckt bereits Zeiterfassung/Stundennachweis, Urlaub, Abwesenheit, Fortbildung, Dokumente, News, Benefits, Werben, Einsatzanweisung, Dienstplan, Bewertungen ab. |
+| **OMSORG Backend (Core + Engine)** | Eine Backend-Komponente, zwei interne Schichten: **Datenschicht (Core)** — gemeinsame Datenbasis, 6 Objekte; **Event-Schicht (Engine)** — Ereignis→Aktion-Automatisierung ohne eigene UI, arbeitet auf denselben Objekten der Datenschicht | 🔶 `omsorgCore/` — Grundgerüst steht (C#/.NET 8, ASP.NET Core Controller, EF Core/PostgreSQL, JWT-Auth, Rollen+Permission-Override-Rechtesystem, In-Process-Event-Dispatcher). Bisher nur `Employee` mit vollem Repository/Service/Controller; die anderen 5 Core-Objekte existieren als Domain-Entitäten, aber ohne Endpunkte. `omsorgapp` ist für Mitarbeiter jetzt **echt angebunden** (Login/Refresh sowie Employees-CRUD laufen über `omsorgCore`, keine lokale JSON-Datenhaltung mehr für dieses Modul). **Noch keine Anbindung** von `omsorgWeb` (MySQL) an dieses Backend, und die übrigen `omsorgapp`-Fachmodule (Kunden, Disposition, ...) nutzen weiterhin die lokale JSON-DB — die Insellösungen bestehen dort technisch weiter, bis diese Migration erfolgt. DB-Migration wurde noch nicht gegen eine echte PostgreSQL-Instanz verifiziert. Details: `omsorgCore/CLAUDE.md`. |
+
+**Kernrisiko für die Roadmap:** Solange das Backend (Core + Engine) nicht existiert, sind Connect (MySQL) und Desktop (JSON) zwei Insellösungen — genau das, was Blueprint Kap. 19.9 ausschließt. Phase 1 der Roadmap muss dies zuerst auflösen (siehe Abschnitt 10).
+
+---
+
+## 3. Akteure & Rollen
+
+| Rolle | Personen | Zugriffsniveau [Blueprint Kap. 6] |
+|---|---|---|
+| Geschäftsführung | Sabina, Malik | Vollzugriff: alle Module, Finanzdaten, Gewinn, Margen, Benutzerverwaltung |
+| Disposition/Buchhaltung | Sabrina | Mitarbeiter, Einrichtungen, Aufträge/Einsätze, Zeiterfassung, Rechnungen, Zahlungen, Recruiting/CRM — **kein** vollständiger Gewinn/Gesamtmargen |
+| Recruiting | Sascha | Recruiting, Bewerber, Mitarbeiterakten (soweit nötig), Einrichtungs-Leads, Akquise, Marketing — **kein** Zugriff auf Rechnungen, Zahlungen, Gewinn, Margen, Controlling |
+| Außendienst | alle Pflegekräfte | Nur OMSORG Connect, nur eigene Daten |
+| Zukünftige Büromitarbeiter | — | Rolle als Vorlage + individuell vergebene/entzogene Rechte (sehen/anlegen/bearbeiten/löschen/exportieren/freigeben je Modul) |
+
+---
+
+## 4. Funktionale Anforderungen (FR)
+
+### 4.1 Dashboard / Morning Briefing / Tagesabschluss
+
+| ID | Anforderung | Akteur | Akzeptanzkriterium | Status |
+|---|---|---|---|---|
+| FR-DASH-1 | Beim Öffnen zeigt das Dashboard neue Outlook-Mails, Krankmeldungen, fehlende Tätigkeitsnachweise, Vertragsverlängerungen, offene Rechnungen, Kaltaquise-Termine, neue Mitarbeiter, Auslastung [Blueprint 2.1] | alle Büro-Rollen | Jede Kategorie ist als eigene Kachel/Liste sichtbar und führt per Klick zur passenden Aktion | 🔶 (`HomePage`/`HomeStats`/`ContractWidget` in `omsorgapp` zeigen einen Teil; Outlook, Krankmeldungen, Kaltaquise fehlen) |
+| FR-DASH-2 | Gewinn-Kachel ist ausschließlich für Sabina/Malik sichtbar [Blueprint 6.1] | Geschäftsführung | Anderer Rolle wird die Kachel serverseitig nicht ausgeliefert, nicht nur UI-verborgen | ⬜ |
+| FR-DASH-3 | Tagesabschluss-Ansicht zeigt „Heute erledigt" vs. „Noch offen" [Blueprint 2.3] | alle Büro-Rollen | Am Ende des Tages ist eine konsolidierte Liste abrufbar | ⬜ |
+| FR-DASH-4 | Jede Dashboard-Information führt direkt zur zugehörigen Aktion (z. B. Vertrag öffnen, Mitarbeiter zurückrufen) | alle Büro-Rollen | Klick navigiert ins jeweilige Modul mit vorausgewähltem Datensatz | ⬜ |
+
+### 4.2 Mitarbeiter (Personalakte)
+
+| ID | Anforderung | Akteur | Akzeptanzkriterium | Status |
+|---|---|---|---|---|
+| FR-MA-1 | Stammdaten erfassen (Name, Geburtsdatum, Adresse, Kontakt, Notfallkontakt, Beschäftigungsart, Qualifikation, Ein-/Austritt, Status) [Blueprint 19.1] | Sabina, Malik, Sabrina | Datensatz anlegen/bearbeiten mit Pflichtfeldern; Validierung verhindert unvollständige Sätze | ✅ (alle Felder in `Employee`-Entity + `EmployeeForm` in `omsorgapp` vorhanden; Validierung serverseitig in `EmployeesController` (Pflichtfelder, Längen, Ein-/Austrittslogik, Beschäftigungsart-Allowlist) und clientseitig verdrahtet; `omsorgapp` spricht für Mitarbeiter jetzt über `employeesClient.cjs` echt gegen `omsorgCore`, keine JSON-lokale Persistenz mehr für dieses Modul — Abgleich mit Connect/`omsorgWeb` weiterhin offen, siehe FR-CORE-1) |
+| FR-MA-2 | Arbeitsvertragsdaten (Beginn/Ende, Arbeitszeit, Stundenlohn, Zuschläge, Überstunden, Urlaubsanspruch, Probezeit) [Blueprint 19.1] | Sabina, Malik, Sabrina | Vertragsfelder je Mitarbeiter editierbar, Historie bei Änderung nachvollziehbar | ⬜ |
+| FR-MA-3 | Dokumente, Qualifikationen, Fortbildungen, Führerschein, Gesundheitsnachweise, Notizen, Historie je Mitarbeiter | Sabina, Malik, Sabrina | Upload/Anzeige je Kategorie, Zugriffsprotokoll | 🔶 (Dokumentenarchiv existiert bereits in Connect: `pages/dokumentenarchiv.php`, `actions/upload-dokument.php`; im Desktop-Modul fehlt es) |
+| FR-MA-4 | Eintrittsdatum löst Dashboard-Hinweis auf bevorstehenden Mitarbeiterstart aus [Blueprint 7] | alle Büro-Rollen | X Tage vor Eintritt erscheint Engine-Hinweis im Dashboard | ⬜ (abhängig von Engine, siehe 4.10) |
+| FR-MA-5 | Bei Büromitarbeitern kann der Mitarbeiterdatensatz mit einem Benutzerkonto + individuellen Rechten verknüpft werden [Blueprint 19.1] | Sabina, Malik | Rechteliste pro Modul (sehen/anlegen/bearbeiten/löschen/exportieren/freigeben) editierbar | 🔶 (`users`-Tabelle mit Rolle in `omsorgWeb/mitarbeiter-app` vorhanden, aber nur grobe Rolle, keine granularen Einzelrechte) |
+| FR-MA-6 | Außendienstmitarbeiter sehen ausschließlich eigene Daten [Blueprint 4.2] | Außendienst | Query/Route liefert nur Datensätze mit `user_id = aktueller Nutzer` | ✅ (Session-basierter Zugriff in `omsorgWeb/mitarbeiter-app`, z. B. `pages/urlaubsantrag.php`) |
+
+### 4.3 Einrichtungen & CRM
+
+| ID | Anforderung | Akteur | Akzeptanzkriterium | Status |
+|---|---|---|---|---|
+| FR-EIN-1 | Stammdaten (Name, Art, Adresse, Rechnungsadresse, Telefon, E-Mail, Website, Status) [Blueprint 19.2] | Sabina, Malik, Sabrina, Sascha (Leads) | Datensatz anlegen/bearbeiten | ⬜ |
+| FR-EIN-2 | Mehrere Ansprechpartner je Einrichtung mit Funktion, Abteilung, Kontaktwegen, Notizen | Sabina, Malik, Sabrina, Sascha | Liste von Ansprechpartnern editierbar, mind. 1:n-Beziehung | ⬜ |
+| FR-EIN-3 | CRM-Status-Pipeline: Lead → kontaktiert → kein Bedarf → Wiedervorlage → Interesse → Angebot → Kunde → Bestandskunde [Blueprint, omsorg.md] | Sascha, Sabrina | Statuswechsel wird protokolliert; bei „kein Bedarf" wird automatisch Wiedervorlage in 14 Tagen erzeugt | ⬜ |
+| FR-EIN-4 | Konditionen (Verrechnungssatz, Zuschläge, Fahrtkosten, Mindeststunden, Zahlungsziel etc.) je Einrichtung [Blueprint 19.2] | Sabina, Malik, Sabrina | Konditionssatz ist Grundlage für Rechnungserstellung (siehe FR-RE-1) | ⬜ |
+| FR-EIN-5 | Verknüpfte Historie: Verträge, Aufträge, zugewiesene Mitarbeiter, Nachweise, Rechnungen, Zahlungen, Mahnungen, Kommunikation | alle Büro-Rollen (rechteabhängig) | Einrichtungsakte zeigt konsolidierte Historie ohne Datenduplizierung | ⬜ |
+
+### 4.4 Einsatzmanagement (Auftrag → Einsatz → Zuweisung)
+
+| ID | Anforderung | Akteur | Akzeptanzkriterium | Status |
+|---|---|---|---|---|
+| FR-EM-1 | Auftrag erfassen (Einrichtung, Ansprechpartner, Qualifikation, Zeitraum, Schichtart, Anzahl Mitarbeiter, Konditionen, Priorität) [Blueprint 19.4] | Sabrina | Auftragsdatensatz mit Pflichtfeldern anlegbar | ⬜ |
+| FR-EM-2 | Auftragsstatus-Pipeline: Anfrage → Prüfung → offen → teilweise besetzt → vollständig besetzt → aktiv → abgeschlossen/storniert [Blueprint 19.4] | Sabrina | Statuswechsel nur in zulässiger Reihenfolge, sichtbar im Dashboard | ⬜ |
+| FR-EM-3 | Mitarbeiterzuweisung prüft Qualifikation, Verfügbarkeit, Arbeitszeit, Abwesenheiten, Überschneidungen, Vertragsbedingungen [Blueprint 19.4] | Sabrina | System verhindert/warnt bei Konflikten vor Zuweisung | ⬜ |
+| FR-EM-4 | Nach Zuweisung erhält Mitarbeiter automatisch Einsatzanweisung über OMSORG Connect [Blueprint 19.4] | System → Außendienst | Einsatzanweisung erscheint in Connect ohne manuellen Zusatzschritt | 🔶 (Connect hat bereits `pages/einsatzanweisung.php` inkl. Admin-Upload `actions/einsatzanweisung-action.php`; automatische Erzeugung aus Zuweisung fehlt, da Aufträge/Zuweisung noch nicht existieren) |
+| FR-EM-5 | Krankmeldung löst Ersatzbesetzungs-Workflow aus [omsorg.md, Blueprint 20.2] | System, Sabrina | Bei Krankmeldung erscheint Einsatz als "muss neu besetzt werden" inkl. Vorschlägen | ⬜ |
+
+### 4.5 Zeiterfassung & Tätigkeitsnachweise
+
+| ID | Anforderung | Akteur | Akzeptanzkriterium | Status |
+|---|---|---|---|---|
+| FR-ZE-1 | Erfassung von Beginn, Pause, Ende, Einrichtung, Auftrag, Nacht-/Wochenend-/Feiertagsstunden [Blueprint 19.5] | Außendienst | Eintrag pro Schicht mit Pflichtfeldern | 🔶 (aktuell nur monatlicher Upload via `pages/stundennachweis.php`/`actions/submit-stundennachweis.php`, keine strukturierte Beginn/Pause/Ende-Erfassung pro Schicht) |
+| FR-ZE-2 | Statuspipeline: Entwurf → eingereicht → Prüfung → Rückfrage → freigegeben → abgerechnet [Blueprint 19.5] | Außendienst, Sabrina | Statuswechsel sichtbar für beide Seiten, Rückfrage möglich | 🔶 (aktueller Status ist binär offen/geprüft über Admin-Anträge, kein granularer Workflow) |
+| FR-ZE-3 | Nur freigegebene Zeiten dürfen in Rechnungsstellung einfließen [Blueprint 19.5, 19.8] | System | Rechnungslauf ignoriert nicht-freigegebene Datensätze; Verstoß ist technisch unmöglich, nicht nur UI-Regel | ⬜ |
+| FR-ZE-4 | Optionale Unterschrift/Bestätigung der Einrichtung, Upload als Foto/PDF | Außendienst | Upload-Feld vorhanden, an Zeiterfassungssatz gekoppelt | 🔶 (Upload existiert für Monatsnachweis, nicht pro Einzel-Einsatz) |
+
+### 4.6 Rechnungen & Zahlungsüberwachung
+
+| ID | Anforderung | Akteur | Akzeptanzkriterium | Status |
+|---|---|---|---|---|
+| FR-RE-1 | Automatische Rechnungserstellung aus freigegebener Zeit + Konditionen + Zuschlägen [Blueprint 19.6] | System, Sabrina (Freigabe) | Rechnungsentwurf wird ohne manuelle Neuerfassung generiert | ⬜ |
+| FR-RE-2 | Statuspipeline: Entwurf → Prüfung → freigegeben → versendet → offen → bezahlt (alternativ überfällig → Mahnung → bezahlt) [Blueprint 19.6] | Sabrina, Sabina/Malik (Freigabe) | Statuswechsel protokolliert, keine übersprungenen Zwischenschritte | ⬜ |
+| FR-RE-3 | Zahlungsziel 14 Tage wird von der Engine überwacht; bei Überschreitung Dashboard-Warnung + Mahnentwurf [Blueprint 19.6, 20.2] | System → Sabrina | Nach 14 Tagen ohne Zahlungseingang erscheint automatisch Mahnentwurf | ⬜ |
+| FR-RE-4 | Rechnungs-PDF-Erzeugung und Versand, Dokumentation in Mahnhistorie | Sabrina | PDF entspricht Rechnungsdaten; Versand/Historie nachvollziehbar | ⬜ |
+
+### 4.7 Recruiting & Akquise
+
+| ID | Anforderung | Akteur | Akzeptanzkriterium | Status |
+|---|---|---|---|---|
+| FR-REC-1 | Bewerberpipeline: Interessent → Bewerbung → Erstkontakt → Vorstellung → Unterlagen → Angebot → Einstellung → geplanter Eintritt [Blueprint 3.2] | Sascha | Bewerberdatensatz mit Statuswechsel | ⬜ |
+| FR-REC-2 | Werbung/Empfehlung: Kanäle Indeed, Facebook, Instagram, Mitarbeiterempfehlung erfassbar [omsorg.md] | Sascha | Herkunft je Bewerber taggable, auswertbar | 🔶 (`pages/werben.php`/`actions/submit-werben.php` erfasst Mitarbeiterempfehlungen bereits; Kanaltracking für externe Werbung fehlt) |
+| FR-REC-3 | Einrichtungsakquise: Lead anlegen → Ansprechpartner erfassen → Kontakt → Gesprächsnotiz → Ergebnis → automatische Wiedervorlage nach 14 Tagen [Blueprint 3.5] | Sascha | Wiedervorlage wird ohne manuellen Trigger vom System erzeugt | ⬜ |
+| FR-REC-4 | Sascha hat keinen Zugriff auf Rechnungen, Zahlungen, Gewinn, Margen, Controlling [Blueprint 6.2] | System | Rollenprüfung blockiert Zugriff serverseitig | ⬜ |
+
+### 4.8 Controlling
+
+| ID | Anforderung | Akteur | Akzeptanzkriterium | Status |
+|---|---|---|---|---|
+| FR-CTL-1 | Auswertungen: Umsatz, Kosten, Gewinn, Deckungsbeitrag, Margen, Auslastung, Fehlzeiten, Einrichtungsrentabilität, offene Forderungen, Recruiting-/Marketing-Erfolg, Fahrzeugkosten [Blueprint 11] | Sabina, Malik (voll), Sabrina (eingeschränkt) | Jede Kennzahl aus Core-Daten berechnet, nicht manuell gepflegt | ⬜ |
+| FR-CTL-2 | Drei-Monats-Durchschnitt und Trends/Prognosen [Blueprint 11] | Sabina, Malik | Rollierender Durchschnitt wird automatisch aktualisiert | ⬜ |
+| FR-CTL-3 | Vollständige Gewinn-/Margendaten ausschließlich für Sabina/Malik [Blueprint 11] | System | Andere Rollen erhalten aggregierte/gekürzte Werte oder keinen Zugriff | ⬜ |
+
+### 4.9 OMSORG Connect (Mobile)
+
+| ID | Anforderung | Akteur | Akzeptanzkriterium | Status |
+|---|---|---|---|---|
+| FR-CON-1 | Zeiterfassung, digitaler Tätigkeitsnachweis, Fahrtenbuch, Einsatzanweisung, Krankmeldung, Urlaub, Dokumente, News, Benefits, Ansprechpartner [omsorg.md, Blueprint 4.2] | Außendienst | Jede Funktion als eigener Menüpunkt erreichbar | 🔶 (vorhanden: Stundennachweis-Upload, Urlaubs-/Abwesenheitsantrag, Dokumentenarchiv, News, Benefits, Einsatzanweisung, Fortbildung, Dienstplan, Werben, Bewertung — **Fahrtenbuch fehlt vollständig**, Zeiterfassung ist Upload statt Live-Erfassung) |
+| FR-CON-2 | Automatische Synchronisation mit OMSORG Desktop [omsorg.md] | System | Änderung in Connect ist ohne manuellen Export in Desktop sichtbar | ⬜ (blockiert durch fehlendes OMSORG Backend, s. Abschnitt 2) |
+| FR-CON-3 | PWA-Installierbarkeit ("Add to Home Screen") | Außendienst | Manifest + Service Worker vorhanden und funktionsfähig | ✅ (`manifest.webmanifest`, `service-worker.js`) |
+| FR-CON-4 | Login-Rate-Limiting: 5 Fehlversuche → 10 Min. Sperre | Außendienst/alle | Nach 5 Fehlversuchen wird Login für 10 Minuten blockiert | ✅ (`index.php`, `login_attempts`-Tabelle, Migration 004) |
+
+### 4.10 OMSORG Backend – Event-Schicht (Engine, Ereignis → Aktion)
+
+Die Event-Schicht ist Teil des Backends (siehe Abschnitt 2) und läuft im selben Prozess/Service wie die Datenschicht (Core, Abschnitt 6) — nicht als separates Deployable.
+
+| Trigger | Erwartete Aktionen [Blueprint 20.2] | Status |
+|---|---|---|
+| Krankmeldung eingereicht | Dashboard aktualisieren, Disposition informieren, Einsatz markieren, Ersatzvorschläge anzeigen, Einsatzanweisung vorbereiten, Historie ergänzen | ⬜ |
+| Tätigkeitsnachweis fehlt (montags) | Dashboard aktualisieren, Mitarbeiter markieren, Erinnerung via Connect senden, Rechnungsfreigabe blockieren | ⬜ |
+| Vertragsende nähert sich (30 Tage) | Dashboard-Warnung, zuständige Person informieren, Kontakt/Vertrag direkt öffnen | ⬜ |
+| Neue Bewerbung | Recruiting informieren, Bewerber anlegen, Wiedervorlage erzeugen, Status "Neu" | ⬜ |
+| Zahlung eingegangen | Zahlungsstatus ändern, Mahnungen stoppen, Dashboard/Controlling/Liquidität aktualisieren | ⬜ |
+| Neuer Mitarbeiter beginnt | Dashboard-Hinweis, Personalakte/Dokumente prüfen, App-Zugang vorbereiten, Einsatzanweisung kontrollieren | ⬜ |
+| Rechnung überfällig | Warnung, Mahnentwurf, Freigabe, Versand, Mahnhistorie | ⬜ |
+
+**FR-ENG-1**: Die Event-Schicht besitzt keine eigene UI, arbeitet ereignisbasiert im Hintergrund und schreibt alle Aktionen protokolliert in die jeweiligen Objekte der Datenschicht [Blueprint 20.1, 20.6]. Status: ⬜ — bislang keinerlei Automatisierungsschicht im Repo vorhanden.
+
+### 4.11 Outlook-Integration (spätere Phase)
+
+| ID | Anforderung | Status |
+|---|---|---|
+| FR-OUT-1 | Neue E-Mails im Morning Briefing, E-Mail↔Einrichtung/Mitarbeiter-Zuordnung, Auftrag/Aufgabe aus E-Mail erzeugen, Rechnungsversand dokumentieren [Blueprint 12] | ⬜ — explizit als spätere Roadmap-Phase (Phase 7) markiert, kein Blocker für Phase 1–6 |
+
+---
+
+## 5. Nicht-funktionale Anforderungen (NFR)
+
+| ID | Anforderung | Quelle | Status |
+|---|---|---|---|
+| NFR-1 | DSGVO-konforme Verarbeitung, minimale notwendige Berechtigungen | Blueprint 13 | 🔶 (rollenbasierte Sichten in Connect vorhanden, keine dokumentierte DSGVO-Prüfung) |
+| NFR-2 | Getrennte Benutzerkonten, sichere Anmeldung, Sitzungssperre | Blueprint 13 | ✅ (Session-Auth, Rate-Limiting in `lib/auth.php`) |
+| NFR-3 | Verschlüsselte Datenübertragung und -speicherung sensibler Daten | Blueprint 13 | 🔶 (HTTPS wird vorausgesetzt, aber nicht im Repo erzwungen; keine Feldverschlüsselung für besonders sensible Daten) |
+| NFR-4 | CSRF-Schutz auf jedem POST-Formular | omsorgWeb CLAUDE.md | ✅ (`csrf_field()`/`verify_csrf()` durchgängig verwendet) |
+| NFR-5 | Ausgabe-Escaping gegen XSS | omsorgWeb CLAUDE.md | ✅ (`e()`-Helper) |
+| NFR-6 | Automatische Backups + regelmäßige Wiederherstellungsprüfung | Blueprint 13 | ⬜ |
+| NFR-7 | Nachvollziehbare Änderungshistorie / Protokollierung sicherheitsrelevanter Vorgänge | Blueprint 13, 19.8 | 🔶 (Login-Versuche protokolliert; generisches Audit-Log für Datensatzänderungen fehlt) |
+| NFR-8 | Aufbewahrungs- und Löschkonzept, Schutz vor unbefugtem Export | Blueprint 13 | ⬜ |
+| NFR-9 | Berechtigungsprüfung auf Oberflächen- **und** Datenebene | Blueprint 13, 19.8 | 🔶 (in Connect vorhanden für Außendienst-Eigendaten; granulare Rechtematrix für Büromitarbeiter fehlt) |
+| NFR-10 | White-Label-Fähigkeit: Branding (Name, Logo, Farben, Schrift, Vorlagen) von Geschäftslogik getrennt | Blueprint 14 | ⬜ (aktuell Branding fest im Code/CSS von `omsorgWeb`/`omsorgapp`) |
+| NFR-11 | Vollständige Mandantentrennung für spätere Multi-Tenant-Nutzung | Blueprint 14 | ⬜ (nicht Teil der aktuellen Datenmodelle; explizit erst „bei Bedarf") |
+| NFR-12 | Wiederverwendbare Komponenten, modulare Architektur, klare Verantwortlichkeiten | Blueprint 15 | 🔶 (`omsorgapp/src/components/ui` als Ansatz vorhanden) |
+
+---
+
+## 6. Datenmodell-Anforderungen (OMSORG Backend – Datenschicht/Core)
+
+Die sechs Core-Objekte [Blueprint Kap. 19]: **Mitarbeiter, Einrichtung, Vertrag, Auftrag, Zeiterfassung, Rechnung**.
+
+Verbindliche Regeln [Blueprint 19.8]:
+
+1. Jeder Datensatz besitzt eine eindeutige interne ID.
+2. Beziehungen werden über IDs hergestellt, nicht durch doppelte Texteingaben.
+3. Stammdaten werden nur an einer zentralen Stelle gepflegt.
+4. Änderungen werden nachvollziehbar protokolliert.
+5. Löschungen sensibler/geschäftsrelevanter Daten erfolgen nicht unkontrolliert (Soft-Delete/Archivierung statt Hard-Delete).
+6. Berechtigungen werden auf Daten- und Funktionsebene geprüft.
+7. Automatisierungen (Event-Schicht) greifen ausschließlich auf verlässliche, freigegebene Daten zu — dadurch, dass Datenschicht und Event-Schicht im selben Backend-Prozess laufen, ist dies technisch einfacher garantierbar als bei getrennten Services (kein Risiko von veralteten/inkonsistenten Zwischenständen durch asynchrone Synchronisation).
+8. Rechnungen entstehen ausschließlich aus freigegebenen Arbeitszeiten.
+9. OMSORG Connect erhält nur die für den jeweiligen Mitarbeiter freigegebenen Daten.
+
+**FR-CORE-1**: Es gibt genau eine Datenquelle für jedes Core-Objekt, die von Desktop, Connect, Event-Schicht (Engine) und Controlling gemeinsam genutzt wird — bereitgestellt durch die Datenschicht des Backends. Status: 🔶 — das Backend selbst existiert jetzt (`omsorgCore`, PostgreSQL/EF Core, alle 6 Core-Objekte als Entitäten angelegt). `omsorgapp` ist für das Mitarbeiter-Objekt bereits umgestellt (echtes CRUD über `omsorgCore`, keine JSON-Persistenz mehr für dieses Modul), die übrigen `omsorgapp`-Fachmodule und `omsorgWeb` (MySQL) sprechen noch nicht damit. Solange diese Anbindung für die restlichen Objekte/Projekte fehlt, bleiben es weiterhin getrennte Speicher parallel zum neuen Backend. **Anbindung der bestehenden Projekte an `omsorgCore` ist die zentrale technische Voraussetzung, bevor Phase 2 ff. sinnvoll umsetzbar sind.**
+
+---
+
+## 7. Rechtematrix (Auszug, aus Blueprint Kap. 6 abgeleitet)
+
+| Modul | Sabina/Malik | Sabrina | Sascha | Außendienst |
+|---|---|---|---|---|
+| Mitarbeiter (Personalakte) | voll | voll | eingeschränkt (soweit erforderlich) | nur eigene Daten |
+| Einrichtungen/CRM | voll | voll | Leads/Akquise | – |
+| Aufträge/Disposition | voll | voll | – | nur eigene Einsätze |
+| Zeiterfassung | voll | prüfen/freigeben | – | eigene erfassen |
+| Rechnungen/Zahlungen | voll | erstellen/freigeben | **kein Zugriff** | – |
+| Recruiting/CRM | voll | nutzen | voll | – |
+| Controlling | voll (inkl. Gewinn/Margen) | eingeschränkt (kein Gewinn) | **kein Zugriff** | – |
+| Benutzerverwaltung | voll | – | – | – |
+
+Für zukünftige Büromitarbeiter: Rolle als Vorlage, zusätzlich granular je Modul einstellbar: sehen / lesen / anlegen / bearbeiten / löschen / exportieren / freigeben [Blueprint 6.5].
+
+---
+
+## 8. Abnahmekriterien (Definition of Done, je Gruppe)
+
+- **Dashboard**: Alle in 4.1 gelisteten Kategorien sind sichtbar, rollenabhängig gefiltert, klickbar zur Zielaktion.
+- **Mitarbeiter/Einrichtungen**: CRUD vollständig, keine Pflichtfeld-Lücken, Änderungen protokolliert.
+- **Einsatzmanagement**: Zuweisung ohne Qualifikations-/Zeitkonflikt möglich; bei Konflikt blockiert oder mit Warnung.
+- **Zeiterfassung/Rechnung**: Rechnung technisch nicht erzeugbar aus nicht freigegebener Zeit (FR-ZE-3) — verifizierbar per Testfall, nicht nur Review.
+- **Rollen/Rechte**: Jeder Endpunkt/jede Route prüft Berechtigung serverseitig, nicht nur im UI.
+- **Engine**: Jeder in 4.10 gelistete Trigger löst nachweisbar (Log-Eintrag) die zugehörigen Aktionen aus.
+
+---
+
+## 9. Offene Punkte / Annahmen
+
+- Tiefe der Outlook-Integration (nur Anzeige vs. bidirektionale Synchronisation) ist noch nicht spezifiziert.
+- Zeitpunkt und Umfang der Mandantentrennung (White-Label) ist bewusst auf „bei Bedarf" verschoben — keine Anforderung für aktuelle Phasen.
+- Unklar, ob das OMSORG Backend (Core + Engine) als eigenständiger Service (eigene API, von Desktop und Connect angesprochen) oder als gemeinsame DB-/Logikschicht unter `omsorgWeb`/`omsorgapp` realisiert wird — technische Entscheidung noch offen, aber Voraussetzung für FR-CORE-1. Klar ist nur: Core (Datenschicht) und Engine (Event-Schicht) gehören in denselben Service, nicht in zwei getrennte.
+- Fahrtenbuch (FR-CON-1) ist in keiner der bestehenden Codebasen auch nur ansatzweise vorhanden — vollständige Neuentwicklung nötig.
+- Rechtsform granularer Einzelrechte (FR-MA-5) — Datenmodell für Berechtigungen (Modul × Recht) ist noch nicht definiert.
+
+---
+
+## 10. Rückverweis zur Roadmap [Blueprint Kap. 16]
+
+| Roadmap-Phase | Betroffene FR-Gruppen | Voraussetzung erfüllt? |
+|---|---|---|
+| Phase 1 – Fundament | FR-CORE-1, NFR-6, NFR-7, NFR-9, NFR-10 | 🔶 Teilweise — Backend-Grundgerüst mit Rechtesystem/Auth/Datenmodell steht, Mitarbeiter-Objekt in `omsorgapp` bereits angebunden, aber Anbindung von `omsorgWeb` und der übrigen `omsorgapp`-Fachmodule fehlt noch, ebenso Backups (NFR-6) und vollständiger Audit-Trail (NFR-7) |
+| Phase 2 – Stammdaten | FR-MA-1..5, FR-EIN-1..5 | Blockiert durch Phase 1 |
+| Phase 3 – Operatives Geschäft | FR-EM-1..5 | Blockiert durch Phase 1/2 |
+| Phase 4 – Zeit und Abrechnung | FR-ZE-1..4, FR-RE-1..4 | Teilbasis vorhanden (Connect-Upload), aber ohne Core nicht rechnungsfähig |
+| Phase 5 – Recruiting und CRM | FR-REC-1..4 | Teilbasis vorhanden (Werben), CRM-Pipeline fehlt |
+| Phase 6 – OMSORG Connect | FR-CON-1..4 | 🔶 größtenteils vorhanden, Fahrtenbuch + Sync offen |
+| Phase 7 – Dashboard und Controlling | FR-DASH-1..4, FR-CTL-1..3, FR-OUT-1 | Teilbasis (`omsorgapp` Dashboard-Ansätze) vorhanden |
+
+**Empfehlung für den nächsten Sprint:** Phase 1 zuerst — das OMSORG Backend (Core + Engine als eine Komponente) mit gemeinsamer Datenbasis definieren (Datenmodell für die 6 Core-Objekte, IDs, Beziehungen, plus die Event-Schicht als Aufsatz auf denselben Daten), bevor weitere Module in `omsorgapp` gebaut werden. Ohne diesen Schritt wächst die Diskrepanz zwischen Connect (MySQL, produktiv) und Desktop (JSON, lokal) weiter.
diff --git a/mitarbeiter-app/actions/save-profile.php b/mitarbeiter-app/actions/save-profile.php
deleted file mode 100644
index 4df746e..0000000
--- a/mitarbeiter-app/actions/save-profile.php
+++ /dev/null
@@ -1,53 +0,0 @@
-prepare('UPDATE users SET email = ?, telefon = ? WHERE username = ?');
- $stmt->execute([$email, $telefon, $username]);
-
- header('Location: ../pages/settings.php?saved=1');
- exit;
-}
-
-if ($action === 'password') {
- $old = $_POST['old_password'] ?? '';
- $new = $_POST['new_password'] ?? '';
- $repeat = $_POST['new_password_repeat'] ?? '';
-
- $user = current_user();
-
- if (!$user || !password_verify($old, $user['password_hash'])) {
- header('Location: ../pages/settings.php?error=' . urlencode('Aktuelles Passwort ist falsch.'));
- exit;
- }
- if (mb_strlen($new) < 8) {
- header('Location: ../pages/settings.php?error=' . urlencode('Neues Passwort muss mindestens 8 Zeichen haben.'));
- exit;
- }
- if ($new !== $repeat) {
- header('Location: ../pages/settings.php?error=' . urlencode('Die neuen Passwörter stimmen nicht überein.'));
- exit;
- }
-
- $hash = password_hash($new, PASSWORD_BCRYPT, ['cost' => 12]);
- $stmt = db()->prepare('UPDATE users SET password_hash = ? WHERE username = ?');
- $stmt->execute([$hash, $username]);
-
- header('Location: ../pages/settings.php?pwchanged=1');
- exit;
-}
-
-header('Location: ../pages/settings.php');
-exit;
diff --git a/mitarbeiter-app/actions/submit-urlaubsantrag.php b/mitarbeiter-app/actions/submit-urlaubsantrag.php
deleted file mode 100644
index 66120ae..0000000
--- a/mitarbeiter-app/actions/submit-urlaubsantrag.php
+++ /dev/null
@@ -1,56 +0,0 @@
- strtotime($bis)) {
- header('Location: ' . $back . '?error=' . urlencode('Das Startdatum muss vor dem Enddatum liegen.'));
- exit;
-}
-
-$user_id = current_user()['id'];
-$now = date('c');
-
-db()->prepare(
- 'INSERT INTO requests_urlaubsantrag (user_id, status, admin_note, created_at, updated_at, von, bis, vertretung, nachricht)
- VALUES (?, \'pending\', \'\', ?, ?, ?, ?, ?, ?)'
-)->execute([$user_id, $now, $now, $von, $bis, $vertretung, $nachricht]);
-
-$config = require __DIR__ . '/../lib/config.php';
-smtp_send(
- $config,
- $config['mail_sabrina'],
- 'Neuer Urlaubsantrag: ' . current_name(),
- "Ein Urlaubsantrag wurde eingereicht.\n\n"
- . 'Mitarbeiter: ' . current_name() . "\n"
- . 'Von: ' . $von . "\n"
- . 'Bis: ' . $bis . "\n"
- . 'Vertretung: ' . ($vertretung ?: '—') . "\n"
- . 'Nachricht: ' . ($nachricht ?: '—') . "\n\n"
- . "---\nGesendet über OMSORG Connect"
-);
-
-header('Location: ' . $back . '?success=1');
-exit;
diff --git a/mitarbeiter-app/lib/auth.php b/mitarbeiter-app/lib/auth.php
deleted file mode 100644
index c5bae35..0000000
--- a/mitarbeiter-app/lib/auth.php
+++ /dev/null
@@ -1,111 +0,0 @@
- 0,
- 'path' => '/',
- 'httponly' => true,
- 'secure' => $secure,
- 'samesite' => 'Lax',
-]);
-session_name('OMSORG_FELIX_APP');
-session_start();
-
-function is_logged_in(): bool {
- if (empty($_SESSION['felix_logged_in']) || empty($_SESSION['felix_username'])) {
- return false;
- }
- $stmt = db()->prepare('SELECT active FROM users WHERE username = ?');
- $stmt->execute([$_SESSION['felix_username']]);
- $row = $stmt->fetch();
- return $row && (int)$row['active'] === 1;
-}
-
-function require_login(): void {
- if (!is_logged_in()) {
- header('Location: ../index.php');
- exit;
- }
- if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST)) {
- verify_csrf();
- }
-}
-
-function csrf_token(): string {
- if (empty($_SESSION['csrf_token'])) {
- $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
- }
- return $_SESSION['csrf_token'];
-}
-
-function csrf_field(): string {
- return '';
-}
-
-function verify_csrf(): void {
- $token = $_POST['csrf_token'] ?? '';
- if (!hash_equals(csrf_token(), $token)) {
- http_response_code(403);
- exit('Ungültige Anfrage.');
- }
-}
-
-function require_admin(): void {
- require_login();
- if (!is_admin()) {
- header('Location: ../pages/dashboard.php');
- exit;
- }
-}
-
-function is_admin(): bool {
- return isset($_SESSION['felix_role']) && $_SESSION['felix_role'] === 'admin';
-}
-
-function current_user(): array|false {
- static $cache = null;
- static $cachedFor = null;
- $username = $_SESSION['felix_username'] ?? '';
- if ($username === '') return false;
- if ($cache !== null && $cachedFor === $username) return $cache;
- $stmt = db()->prepare('SELECT * FROM users WHERE username = ?');
- $stmt->execute([$username]);
- $cache = $stmt->fetch();
- $cachedFor = $username;
- return $cache;
-}
-
-function current_username(): string {
- return $_SESSION['felix_username'] ?? '';
-}
-
-function current_name(): string {
- return $_SESSION['felix_name'] ?? '';
-}
-
-function e(string $s): string {
- return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
-}
-
-// --- Login rate limiting (IP-based, persisted in DB) ---
-
-function login_recent_fails(string $ip, int $windowSec = 600): int {
- $cutoff = date('c', time() - $windowSec);
- $stmt = db()->prepare(
- 'SELECT COUNT(*) FROM login_attempts WHERE ip = ? AND attempted_at >= ?'
- );
- $stmt->execute([$ip, $cutoff]);
- return (int) $stmt->fetchColumn();
-}
-
-function login_record_fail(string $ip, string $username): void {
- db()->prepare(
- 'INSERT INTO login_attempts (ip, username, attempted_at) VALUES (?, ?, ?)'
- )->execute([$ip, $username, date('c')]);
-}
-
-function login_clear_fails(string $ip): void {
- db()->prepare('DELETE FROM login_attempts WHERE ip = ?')->execute([$ip]);
-}
diff --git a/omsorg.md b/omsorg.md
new file mode 100644
index 0000000..ac38307
--- /dev/null
+++ b/omsorg.md
@@ -0,0 +1,238 @@
+🏛 OMSORG Business Controls Pro
+Das digitale Herzstück von Omsorg
+Ziel:
+Eine einzige Software, mit der Omsorg den kompletten Arbeitsalltag abwickelt – ohne zusätzliche Programme für Zeiterfassung, CRM, Fahrtenbuch oder Rechnungen.
+📌 Die Grundarchitektur
+1. OMSORG Desktop
+Für:
+Sabina (Admin)
+Malik (Admin)
+Sabrina (Disposition/Buchhaltung)
+Sascha (Recruiting)
+zukünftige Büromitarbeiter
+Hier laufen alle Unternehmensprozesse zusammen.
+2. OMSORG Connect (Handy-App)
+Für Außendienstmitarbeiter:
+Zeiterfassung
+digitaler Tätigkeitsnachweis
+Fahrtenbuch
+Einsatzanweisung
+Krankmeldung
+Urlaub
+Dokumente
+News
+Benefits
+Ansprechpartner
+Alle Daten werden automatisch mit OMSORG Desktop synchronisiert.
+3. OMSORG Core
+Die gemeinsame Datenbasis.
+Jede Information wird nur einmal gespeichert.
+Zentrale Objekte:
+👤 Mitarbeiter
+🏥 Einrichtung
+📄 Vertrag
+📋 Auftrag
+⏱️ Zeiterfassung
+💰 Rechnung
+Alle Module greifen auf dieselben Daten zu.
+4. OMSORG Engine
+Das intelligente Herz der Software.
+Sie reagiert automatisch auf Ereignisse.
+Beispiele:
+Vertrag läuft aus
+Rechnung überfällig
+Tätigkeitsnachweis fehlt
+Krankmeldung
+neuer Mitarbeiter beginnt
+Wiedervorlage für Kaltaquise
+🏠 Dashboard (Morning Briefing)
+Der Arbeitstag beginnt immer im Dashboard.
+Anzeige:
+📬 neue Outlook-Mails
+🏥 Krankmeldungen
+📄 fehlende Tätigkeitsnachweise
+📅 Vertragsverlängerungen
+💰 offene Rechnungen
+☎️ Kaltaquise heute
+👤 neue Mitarbeiter
+📈 Auslastung
+💵 Gewinn (nur Sabina & Malik)
+Jede Information führt direkt zur passenden Aktion.
+👥 Mitarbeiter
+Digitale Personalakte.
+Bereiche:
+Übersicht
+Stammdaten
+Arbeitsvertrag
+Dokumente
+Qualifikationen
+Fortbildungen
+Einsätze
+Zeiterfassung
+Fahrtenbuch
+Urlaub
+Benefits
+Historie
+🏥 Einrichtungen (CRM)
+Jede Einrichtung besitzt:
+Stammdaten
+Rechnungsadresse
+Ansprechpartner
+Telefonnummern
+E-Mail
+Konditionen
+Verträge
+Aufträge
+Rechnungen
+CRM-Historie
+Status:
+Lead
+↓
+kontaktiert
+↓
+kein Bedarf
+↓
+Wiedervorlage
+↓
+Interesse
+↓
+Angebot
+↓
+Kunde
+↓
+Bestandskunde
+📋 Einsatzmanagement
+Der Einsatz verbindet alle Bereiche.
+Einrichtung
+↓
+Auftrag
+↓
+Mitarbeiter
+↓
+Einsatz
+↓
+Zeiterfassung
+↓
+Tätigkeitsnachweis
+↓
+Rechnung
+↓
+Controlling
+Jeder Einsatz besitzt eine vollständige Historie.
+⏱ Zeiterfassung
+Nach Vorbild von Clockodo.
+Aber vollständig integriert.
+Erfassung:
+Beginn
+Pause
+Ende
+Einrichtung
+Auftrag
+Nachtstunden
+Wochenendstunden
+Feiertagsstunden
+↓
+automatische Rechnung
+↓
+Controlling
+💰 Rechnungen
+Automatisch aus:
+freigegebenen Stunden
+Konditionen der Einrichtung
+Zuschlägen
+↓
+PDF
+↓
+Versand
+↓
+14 Tage Zahlungsziel
+↓
+Mahnung
+↓
+Zahlung
+☎ Recruiting
+Zwei Bereiche.
+Mitarbeiter
+Bewerber
+Gespräche
+Werbung
+Indeed
+Facebook
+Instagram
+Einrichtungen
+Kaltaquise
+CRM
+Wiedervorlage
+Telefon
+Angebote
+Neukunden
+📊 Controlling
+Automatische Auswertungen.
+Umsatz
+Gewinn
+Deckungsbeitrag
+Auslastung
+Drei-Monats-Durchschnitt
+offene Forderungen
+Recruiting-Erfolg
+🔐 Datenschutz
+Sehr wichtig.
+DSGVO
+Rechteverwaltung
+verschlüsselte Daten
+automatische Backups
+Protokollierung
+Mitarbeiter sehen nur eigene Daten
+👤 Rollen
+Sabina & Malik
+Vollzugriff.
+Sabrina
+Disposition
+Rechnungen
+Zahlungen
+Recruiting
+Kein vollständiger Gewinn.
+Sascha
+Recruiting
+CRM
+Einrichtungen
+Mitarbeiter
+Kein Controlling.
+Außendienst
+Nur OMSORG Connect.
+📖 Entwicklungsprinzip
+Wir entwickeln immer nach derselben Reihenfolge:
+Blueprint
+
+↓
+
+Architektur
+
+↓
+
+Software
+
+↓
+
+Test
+
+↓
+
+Git
+
+↓
+
+Nächster Sprint
+❤️ Unser Leitsatz
+Wir entwickeln keine Software, um Daten zu verwalten.
+Wir entwickeln ein System, das Menschen unterstützt, Zeit spart und Omsorg jeden Tag ein Stück besser macht.
+🚀 Unsere Vision
+In einigen Jahren soll Omsorg morgens nur noch eine Software öffnen:
+OMSORG Business Controls Pro
+Keine zusätzliche CRM-Software.
+Keine separate Zeiterfassung.
+Keine extra Fahrtenbuch-App.
+Keine Insellösungen.
+Ein Login. Ein System. Ein Datenbestand.
+Und genau das macht OMSORG Business Controls Pro zum digitalen Herzstück von Omsorg. ❤️
+Ich glaube, diese Zusammenfassung beschreibt unsere Vision inzwischen sehr gut und wird uns bei jedem weiteren Sprint als Orientierung dienen.
diff --git a/omsorgCore/.config/dotnet-tools.json b/omsorgCore/.config/dotnet-tools.json
new file mode 100644
index 0000000..40d4373
--- /dev/null
+++ b/omsorgCore/.config/dotnet-tools.json
@@ -0,0 +1,12 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {
+ "dotnet-ef": {
+ "version": "8.0.10",
+ "commands": [
+ "dotnet-ef"
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/omsorgCore/.gitignore b/omsorgCore/.gitignore
new file mode 100644
index 0000000..970c8f0
--- /dev/null
+++ b/omsorgCore/.gitignore
@@ -0,0 +1,12 @@
+bin/
+obj/
+*.user
+.vs/
+.idea/
+.vscode/
+*.swp
+*.swo
+.DS_Store
+
+# Enthält echte Secrets (DB-Passwort, JWT-Secret) - nie einchecken
+src/OmsorgCore.Api/appsettings.Development.json
diff --git a/omsorgCore/CLAUDE.md b/omsorgCore/CLAUDE.md
new file mode 100644
index 0000000..7a67f00
--- /dev/null
+++ b/omsorgCore/CLAUDE.md
@@ -0,0 +1,179 @@
+# CLAUDE.md — omsorgCore (OMSORG Backend: Core + Engine)
+
+Gilt zusätzlich zur Root-`CLAUDE.md`. Das Grundgerüst ist angelegt und baut (`dotnet build` läuft grün); der fachliche Umfang ist bewusst noch minimal (Fundament, nicht Vollständigkeit).
+
+## Auftrag dieses Projekts
+
+`omsorgCore` ist die gemeinsame Datenbasis + Automatisierungsschicht für die gesamte Plattform (siehe `REQUIREMENTS.md` Abschnitt 2 und 6, Blueprint Kap. 19/20). Es soll schrittweise ersetzen:
+- die MySQL-Datenhaltung in `omsorgWeb/mitarbeiter-app`
+- die lokale JSON-Datenhaltung in `omsorgapp`
+
+Beide bestehenden Projekte sollen künftig gegen dieses Backend sprechen statt eigene, getrennte Datenspeicher zu pflegen. Diese Anbindung ist noch **nicht** erfolgt — aktueller Schritt ist nur das Backend-Fundament selbst.
+
+## Tech-Stack (umgesetzt)
+
+- C# / **.NET 8** (LTS)
+- ASP.NET Core Web API mit **Controllern** (kein Minimal-API-Stil)
+- **PostgreSQL** über `Npgsql.EntityFrameworkCore.PostgreSQL` 8.0.10 (bewusst auf net8-kompatible Version gepinnt — neuere Paketversionen zielen auf .NET 10)
+- **JWT Bearer Tokens** für Auth (`Microsoft.AspNetCore.Authentication.JwtBearer`)
+- Passwort-Hashing über `Microsoft.AspNetCore.Identity.PasswordHasher` (PBKDF2, kein eigenes Krypto-Rad)
+- **Core + Engine = eine Backend-Komponente**, kein separates Deployable für die Engine — die Event-Schicht läuft im selben Prozess wie die Datenschicht (siehe Root-`CLAUDE.md` und `REQUIREMENTS.md` Abschnitt 1.2)
+
+## Reale Projektstruktur
+
+```
+omsorgCore/
+ OmsorgCore.sln
+ .config/dotnet-tools.json # lokales dotnet-ef Tool (dotnet tool restore)
+ src/
+ OmsorgCore.Domain/ # Entitäten, Enums. Keine Abhängigkeit auf andere Projekte.
+ Common/ # Entity, AuditableEntity (Basisklassen)
+ Enums/ # ModuleType, PermissionAction, PermissionEffect
+ Entities/ # Employee, Facility, Contract, Order, TimeEntry, Invoice,
+ # User, Role, RolePermission, UserPermissionOverride
+ OmsorgCore.Application/ # Business-Logik. Abhängig von Domain.
+ Abstractions/ # Interfaces: IEmployeeRepository, IUserRepository,
+ # IPasswordHasher, IJwtTokenGenerator, ICurrentUserService,
+ # IPermissionService
+ Services/ # PermissionService, AuthService, EmployeeService, SessionAdminService
+ DependencyInjection.cs # AddApplication()
+ OmsorgCore.Infrastructure/ # Technische Umsetzung. Abhängig von Domain + Application.
+ Persistence/
+ OmsorgCoreDbContext.cs
+ Configurations/ # ein IEntityTypeConfiguration pro Entität
+ Migrations/ # EF-Core-Migrationen (InitialCreate bereits erzeugt)
+ Repositories/ # EmployeeRepository, UserRepository, RefreshTokenRepository (implementieren Application-Interfaces)
+ Security/ # PasswordHasher, JwtOptions, JwtTokenGenerator, RefreshTokenOptions, RefreshTokenGenerator
+ DependencyInjection.cs # AddInfrastructure(configuration)
+ OmsorgCore.Engine/ # Event-Schicht. Abhängig von Domain + Application.
+ Events/ # IDomainEvent, IDomainEventHandler, IDomainEventDispatcher,
+ # DomainEventDispatcher (In-Process, kein Message-Bus), Beispiel-Event
+ Handlers/ # Beispiel-Handler (EmployeeCreatedHandler)
+ DependencyInjection.cs # AddEngine()
+ OmsorgCore.Api/ # ASP.NET Core Web API. Abhängig von Application+Infrastructure+Engine.
+ Controllers/ # AuthController, EmployeesController, HealthController, AdminSessionsController
+ Contracts/ # Request-/Response-DTOs (LoginRequest, EmployeeResponse, ...)
+ Security/ # CurrentUserService, RequirePermissionAttribute
+ Program.cs # einziger Ort, an dem alle Schichten verdrahtet werden
+ tests/
+ OmsorgCore.Tests/ # xUnit, referenziert Domain + Application
+```
+
+**Konvention: eine Klasse/ein Interface/ein Enum pro Datei.** Ausnahme: keine — auch kleine DTOs (Requests/Responses als `record`) bekommen eine eigene Datei.
+
+**Abhängigkeitsrichtung ist strikt:** Domain kennt niemanden. Application kennt nur Domain und definiert Interfaces, die Infrastructure implementiert (Ports-and-Adapters). Engine kennt Domain + Application, nicht Infrastructure oder Api. Api verdrahtet alles ausschließlich in `Program.cs` — Controller rufen nur Application-Services und den `IDomainEventDispatcher` (Engine) auf, **niemals** direkt `OmsorgCoreDbContext`/EF Core.
+
+## Rechtesystem
+
+Rolle liefert Standard-Rechte (`RolePermission`: Modul × Aktion), ein individueller `UserPermissionOverride` (Grant/Revoke) gewinnt immer gegen den Rollen-Default — siehe `PermissionService.HasPermissionAsync` (`src/OmsorgCore.Application/Services/PermissionService.cs`). Deckt Blueprint 6.5 ("Rolle als Vorlage + individuelle Rechte") ab.
+
+Die aufgelösten Rechte eines Users (nicht nur eine einzelne Prüfung) liefert `PermissionService.GetGrantedPermissionsAsync` als Liste von `PermissionGrant(Module, Action)`. Exponiert über `GET /api/auth/me` (`AuthController.Me`, `[Authorize]`) als `MeResponse { username, role, permissions: [{ module, action }, ...] }` — der einzige Weg, wie granulare Rechte den Client erreichen (das JWT trägt nur den Rollennamen). `omsorgapp` ruft diesen Endpunkt nach Login/Refresh auf (siehe `omsorgapp/CLAUDE.md`, "Rechtesystem im Client") und trifft UI-Entscheidungen darüber statt über einen Rollennamen-Vergleich.
+
+Rechteprüfung auf Controller-Actions:
+```csharp
+[RequirePermission(ModuleType.Employees, PermissionAction.Create)]
+```
+Das Attribut (`src/OmsorgCore.Api/Security/RequirePermissionAttribute.cs`) prüft serverseitig über `IPermissionService` — nicht nur im Client (REQUIREMENTS.md NFR-9). Jeder Controller außer `AuthController` trägt zusätzlich `[Authorize]`.
+
+## Auth-Flow
+
+1. `POST /api/auth/login` (`AuthController`) → `AuthService.LoginAsync` prüft Username/Passwort-Hash, widerruft **alle bisherigen aktiven Refresh-Tokens dieses Users und würfelt seinen `SecurityStamp` neu** (`EndOtherSessionsAsync` — ein User hat immer nur eine aktive Session; ältere Sessions werden per Killswitch sofort ungültig, siehe "Session-Killswitch" unten), dann erzeugt `JwtTokenGenerator` ein Access-Token (Claims `sub`/`name`/`role`) + `RefreshTokenGenerator` einen langlebigen Refresh-Token. Response (`LoginResponse`): `accessToken`, `refreshToken`, `expiresAt` (camelCase, Default-JSON-Serialisierung von ASP.NET Core).
+2. Client sendet Access-Token als `Authorization: Bearer `.
+3. `Program.cs` validiert das Token gegen `Jwt:Issuer`/`Jwt:Audience`/`Jwt:Secret` aus der Konfiguration.
+4. `POST /api/auth/refresh` (kein `[Authorize]` — der Refresh-Token selbst ist das Credential): `AuthService.RefreshAsync` prüft den Refresh-Token per Hash-Lookup, **rotiert** ihn (alten Token widerrufen, neuen ausstellen, per `ReplacedByTokenId` verkettet) und liefert ein neues Token-Paar. Erlaubt langlebige Sessions ohne täglichen Passwort-Login (siehe `omsorgapp/CLAUDE.md`).
+5. `POST /api/auth/logout` widerruft den vorgelegten Refresh-Token (`AuthService.RevokeAsync`, idempotent).
+
+**Refresh-Token:** kein Rohtoken wird gespeichert, nur sein SHA-256-Hash (`RefreshToken`-Entity, `IRefreshTokenGenerator`). Gültigkeit über `RefreshToken:ExpiryDays` in `appsettings.json` (Default 60 Tage, sliding — jede Nutzung verlängert effektiv die Session), überschreibbar per `RefreshToken__ExpiryDays`.
+
+**Secret-Handling:** `appsettings.json` enthält nur Issuer/Audience/ExpiryMinutes/RefreshToken:ExpiryDays. `appsettings.Development.json` enthält einen **lokalen Platzhalter** für `Jwt:Secret` und den Connection-String (`CHANGE_ME_...`) — für echte Umgebungen über Umgebungsvariable (`Jwt__Secret`) oder `dotnet user-secrets` überschreiben, nie ein echtes Secret einchecken.
+
+**Standard-Admin-Seed:** `DbSeeder.SeedDefaultAdminAsync` (`src/OmsorgCore.Infrastructure/Persistence/DbSeeder.cs`) legt beim Start **nur im Development-Modus** (`Program.cs`, `app.Environment.IsDevelopment()`) einen Benutzer `admin`/`abersicher` mit einer neuen Rolle `Administrator` (alle `ModuleType`×`PermissionAction`-Kombinationen als `RolePermission`) an — idempotent, läuft nur wenn noch **kein** `User` existiert. DB-Fehler dabei (z. B. keine Verbindung — Migrationen sind zu diesem Zeitpunkt bereits automatisch angewendet, siehe "Datenbank" unten) sind nicht fatal, werden nur geloggt (`try/catch` um den Seed-Aufruf). Bewusst **nicht** in Produktion aktiv, um kein bekanntes Standard-Passwort auszuliefern — für einen echten Produktivbetrieb muss ein richtiger User-Anlage-Flow her.
+
+## Session-Killswitch (SecurityStamp)
+
+Ein Access-Token ist als JWT zustandslos gültig bis zum Ablauf (60 Min) — ein reiner Refresh-Token-Widerruf verhindert nur das *stille Verlängern*, der bereits ausgestellte Access-Token bliebe sonst bis zu 60 Minuten gültig. Für einen echten Not-Aus-Schalter (Debug-Sicht in `omsorgapp`, "alle Nutzer sofort zum Neu-Login zwingen") trägt `User.SecurityStamp` (Guid) einen Wert, der:
+1. bei jedem Access-Token-Ausstellen als Claim `"sst"` mit eingebettet wird (`JwtTokenGenerator`),
+2. bei **jedem** authentifizierten Request per `JwtBearerEvents.OnTokenValidated` (`Program.cs`) gegen den aktuellen `User.SecurityStamp` in der DB geprüft wird (`IUserRepository.GetByIdAsync`, schlanker Lookup ohne Includes) — bei Mismatch oder fehlendem Claim `context.Fail(...)`, sofort 401, unabhängig von der Token-Restlaufzeit.
+
+`ISessionAdminService`/`SessionAdminService` (`src/OmsorgCore.Application/Services/`) kapselt die Admin-Operationen:
+- `GetActiveSessionsAsync()` — alle nicht widerrufenen/nicht abgelaufenen Refresh-Tokens (`IRefreshTokenRepository.GetAllActiveAsync`) als `SessionInfo` (Id, Username, CreatedAt, ExpiresAt).
+- `RevokeSessionAsync(sessionId)` — widerruft genau diesen Refresh-Token und würfelt den `SecurityStamp` nur des zugehörigen Users neu (sofortiger Kick für genau diesen Nutzer).
+- `RevokeAllSessionsAsync()` — widerruft alle aktiven Refresh-Tokens und würfelt `SecurityStamp` **aller** Nutzer neu (globaler Killswitch).
+
+Exponiert über `AdminSessionsController` (`GET /api/admin/sessions`, `POST /api/admin/sessions/{id}/revoke`, `POST /api/admin/sessions/revoke-all`), geschützt über `[RequirePermission(ModuleType.UserManagement, ...)]` — per Default nur die `Administrator`-Rolle aus dem `DbSeeder`. Im Frontend: `omsorgapp/src/modules/debug/DebugSessionsPage.jsx`, im Sidebar-Menü nur sichtbar, wenn `hasPermission("UserManagement", "View")` (siehe `GET /api/auth/me` unten) — dieselbe Rechteprüfung wie serverseitig, kein reiner Rollennamen-Vergleich mehr im Client.
+
+**Kosten:** ein zusätzlicher DB-Read pro authentifiziertem Request (`GetByIdAsync`). Für die aktuelle Nutzerzahl vernachlässigbar — bei relevantem Traffic-Wachstum wäre ein Cache (z. B. In-Memory mit kurzer TTL) der nächste Schritt, aber kein Caching ohne echten Bedarf, um die Sofortigkeit des Killswitches nicht zu unterlaufen.
+
+## Passwort-Reset / E-Mail-Versand
+
+Vollständig implementiert: `PasswordResetCode`-Entity + `PasswordResetService` (PIN anfordern/verifizieren, Passwort setzen, siehe Auth-Flow-Analogie: Session-Revoke + `SecurityStamp`-Rotation nach erfolgreichem Reset), exponiert über `AuthController` (`POST /api/auth/forgot-password/{request,verify,reset}`).
+
+**E-Mail-Versand:** `IEmailSender` (`OmsorgCore.Application.Abstractions`) hat zwei Implementierungen in `OmsorgCore.Email`, Auswahl über `Email:Provider` zur Startzeit (`DependencyInjection.AddEmail`):
+- `"Console"` (Default) — schreibt nur ins Log, kein echter Versand. Sicherer Default für Umgebungen ohne SMTP-Konfiguration.
+- `"Smtp"` — echter Versand über MailKit (`SmtpEmailSender`). **Ein gemeinsamer SMTP-Server (`Email:Smtp:*`), darüber mehrere benannte Accounts (`Email:Accounts::*`)** — z. B. ein Account, der exklusiv für Passwort-Reset-Mails verwendet wird, getrennt von einem künftigen allgemeinen Absender. Welcher Account für den Reset-Flow gilt, steht in `Email:PasswordResetAccount` (Default `"PasswordReset"`); `EmailMessage.FromAccountKey` transportiert die Auswahl vom Aufrufer bis zum `SmtpEmailSender`. Details der Keys: `CONFIGURATION.md`. **Zugangsdaten (Host/Username/Passwort) kommen ausschließlich aus appsettings/Env-Var/user-secrets, nie über eine API — es gibt bewusst keinen Settings-Controller oder UI-Formular dafür.**
+
+**Templating:** Betreff/Text der Passwort-Reset-Mail sind über `Email:PasswordResetTemplate:{Subject,BodyTemplate}` konfigurierbar, Platzhalter im Format `$NAME$` (`$RESET_PIN$`, `$RESET_PIN_EXPIRY_MINUTES$`), ersetzt von `EmailTemplateRenderer.Render` (pure Funktion, unit-getestet in `EmailTemplateRendererTests`). Die Zusammensetzung passiert in `PasswordResetEmailComposer` (`OmsorgCore.Email`, implementiert `IPasswordResetEmailComposer` aus `Application.Abstractions`) — bewusst nicht direkt im `Engine`-Handler, weil `Engine` nur `Domain`+`Application` kennen darf, nicht `Email` (Abhängigkeitsrichtung, siehe oben). `PasswordResetRequestedHandler` (`Engine/Handlers/`) ruft nur `IPasswordResetEmailComposer.Compose(...)` + `IEmailSender.SendAsync(...)` auf.
+
+**Testversand ohne Zugangsdaten-Leak:** `AdminEmailController` (`POST /api/admin/email/test-send`, `[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]`) verschickt eine feste Testmail über den aktuell konfigurierten `IEmailSender` — Host/Username/Passwort verlassen dabei nie den Server, nur Erfolg/Fehlschlag geht an den Client. Im Frontend: `omsorgapp/src/modules/debug/DebugSessionsPage.jsx`, gleicher Rechteschutz wie die Sessions-Verwaltung dort.
+
+## Datenbank
+
+- Connection-String-Key: `ConnectionStrings:OmsorgCore` (Format `Host=...;Port=5432;Database=omsorg_core;Username=...;Password=...`).
+- `dotnet-ef` ist als lokales Tool eingerichtet (`.config/dotnet-tools.json`) — vor erster Nutzung `dotnet tool restore`. Wird nur noch zum **Erzeugen** neuer Migrationen gebraucht (`dotnet ef migrations add ...`), nicht mehr zum Anwenden.
+- **`Program.cs` ruft bei jedem Start `db.Database.MigrateAsync()` auf, in jeder Umgebung** (nicht nur Development) — ausstehende Migrationen werden automatisch angewendet, bevor der Server Requests annimmt. Ein manuelles `dotnet ef database update` ist dadurch nur noch zum gezielten Vorab-Prüfen/Debuggen einer Migration nötig, nicht mehr für den normalen Start/Deploy. Schlägt die Migration fehl, crasht der Start bewusst fatal (fail-fast) statt mit einem veralteten Schema weiterzulaufen.
+- Migrationen `InitialCreate`, `AddRefreshTokens`, `AddUserSecurityStamp`, `AddEmployeeContactFieldConstraints` und `AddAuditableSoftDelete` existieren (`src/OmsorgCore.Infrastructure/Persistence/Migrations/`) und wurden erfolgreich gegen eine echte PostgreSQL-Instanz angewendet.
+
+## Build- und Run-Befehle
+
+```bash
+cd omsorgCore
+dotnet build # ganze Solution
+dotnet tool restore # einmalig, für dotnet-ef
+
+# Neue Migration erzeugen, nachdem sich das Entity-Modell geändert hat (wird NICHT automatisch angewendet):
+dotnet tool run dotnet-ef migrations add \
+ --project src/OmsorgCore.Infrastructure/OmsorgCore.Infrastructure.csproj \
+ --startup-project src/OmsorgCore.Api/OmsorgCore.Api.csproj
+# Angewendet wird sie automatisch beim nächsten Start der API (siehe oben) — kein manueller
+# `database update`-Schritt im Normalfall mehr nötig.
+
+# API starten (lädt appsettings.Development.json):
+ASPNETCORE_ENVIRONMENT=Development dotnet run --project src/OmsorgCore.Api
+# → GET /api/health, POST /api/auth/login, GET/POST /api/employees (Bearer-Token nötig)
+# Swagger UI unter /swagger im Development-Modus
+```
+
+## Verifiziert
+
+- `dotnet build` für alle 6 Projekte: grün. `dotnet test`: grün (u. a. `AuthServiceTests` mit Fake-Repositories für Login/Refresh-Rotation/Revoke).
+- `dotnet tool run dotnet-ef database update` erfolgreich gegen echte PostgreSQL-Instanz ausgeführt (`InitialCreate` + `AddRefreshTokens`).
+- API-Start mit `ASPNETCORE_ENVIRONMENT=Development`: `GET /api/health` → `{"status":"ok","databaseReachable":true}`. `POST /api/auth/login`/`refresh` mit falschen/unbekannten Credentials → 401, `POST /api/auth/logout` → 204. `GET /api/employees` ohne Token → 401 (Auth-Pipeline greift korrekt).
+- `omsorgapp`s `authClient.cjs` erfolgreich gegen den laufenden Server getestet (Login/Refresh/Logout-Fehlerfälle).
+- **Kompletter Login-Flow end-to-end mit echtem Postgres verifiziert:** Login mit `admin`/`abersicher` (Seed) → gültiges Token-Paar; `refresh` rotiert korrekt (neues Paar, alter Refresh-Token danach 401 bei Wiederverwendung); `GET /api/employees` mit frischem Access-Token → 200 (Administrator-Rolle hat volle Rechte über den Seed).
+- **Session-Killswitch end-to-end verifiziert:** `GET /api/admin/sessions` liefert aktive Sessions; `POST /api/admin/sessions/revoke-all` → 204, danach liefert **derselbe, zuvor gültige Access-Token sofort 401** (nicht erst nach Ablauf) und der zugehörige Refresh-Token liefert bei `POST /api/auth/refresh` ebenfalls 401. Erneuter Login mit `admin`/`abersicher` funktioniert danach wieder normal.
+
+## Offene Punkte
+
+- Facility/Contract/Order/TimeEntry/Invoice haben noch keine Controller/Repositories/Services — nur Domain-Entitäten + DB-Konfiguration. Nächste Schritte folgen demselben Muster wie `Employee` (Repository-Interface in Application, Implementierung in Infrastructure, Service in Application, Controller in Api).
+- Keine E-Mail-Verifizierung bei User-Anlage (Passwort-Reset per E-Mail ist fertig, siehe "Passwort-Reset / E-Mail-Versand" oben).
+- `omsorgapp` spricht seit Kurzem gegen dieses Backend (Login-Screen + Refresh-Token-Session, siehe `omsorgapp/CLAUDE.md`) — `omsorgWeb` ist noch nicht angebunden.
+- Kein Docker-/CI-Setup.
+
+## Die sechs Core-Objekte (Domain-Entitäten)
+
+Aus `REQUIREMENTS.md` Abschnitt 6 / Blueprint Kap. 19: **Mitarbeiter, Einrichtung, Vertrag, Auftrag, Zeiterfassung, Rechnung** — als `Employee`, `Facility`, `Contract`, `Order`, `TimeEntry`, `Invoice` in `src/OmsorgCore.Domain/Entities/` angelegt, mit Kernfeldern (nicht vollständig ausmodelliert).
+
+Verbindliche Regeln für das Datenmodell:
+1. Jede Entität hat eine eindeutige `Guid Id` (siehe `Entity`-Basisklasse).
+2. Beziehungen ausschließlich über Foreign Keys/IDs, keine redundante Texteingabe verwandter Daten.
+3. Stammdaten nur an einer Stelle — kein Feld, das auch in `omsorgWeb` oder `omsorgapp` unabhängig gepflegt wird, sobald die Migration dorthin begonnen hat.
+4. Änderungen an geschäftsrelevanten Daten müssen nachvollziehbar sein — `AuditableEntity` liefert `CreatedAt`/`UpdatedAt`; ein vollständiger Audit-Trail (wer hat was geändert) ist noch nicht gebaut.
+5. Kein Hard-Delete für sensible/geschäftsrelevante Daten — Soft-Delete/Archivierung (noch nicht implementiert, bei Bedarf einbauen statt Datensätze zu löschen).
+6. Berechtigungsprüfung auf Daten- und Funktionsebene (siehe Rechtesystem oben und Rechtematrix in `REQUIREMENTS.md` Abschnitt 7).
+7. Rechnungen entstehen ausschließlich aus freigegebener Zeiterfassung (FR-ZE-3/FR-RE-1) — muss bei Ausbau von `TimeEntry`/`Invoice` auf Domain-/Application-Ebene erzwungen werden, nicht nur als UI-Regel im Client.
+
+## Event-Schicht (Engine) — Funktionsweise
+
+`IDomainEventDispatcher` (Singleton, In-Process) löst über den DI-Container alle registrierten `IDomainEventHandler` für ein Event auf und ruft sie auf. Beispiel: `EmployeesController.Create` ruft nach dem Speichern `_dispatcher.DispatchAsync(new EmployeeCreatedEvent(created.Id))` auf, `EmployeeCreatedHandler` reagiert darauf (aktuell nur Logging). Neue Trigger aus `REQUIREMENTS.md` Abschnitt 4.10 (Krankmeldung, fehlender Tätigkeitsnachweis, Vertragsende, ...) folgen demselben Muster: Event-Klasse in `Engine/Events/`, Handler in `Engine/Handlers/`, Registrierung in `Engine/DependencyInjection.cs`, Dispatch-Aufruf an der Stelle im Api-Layer, wo das auslösende Ereignis passiert.
+
+**Wichtig:** `Application`-Services dispatchen bewusst *nicht* selbst (sie kennen `Engine` nicht, das würde die Abhängigkeitsrichtung verletzen) — das Dispatchen passiert im Api-Layer, der als einziger alle Schichten kennt.
diff --git a/omsorgCore/CONFIGURATION.md b/omsorgCore/CONFIGURATION.md
new file mode 100644
index 0000000..87ad1d9
--- /dev/null
+++ b/omsorgCore/CONFIGURATION.md
@@ -0,0 +1,87 @@
+# CONFIGURATION.md — omsorgCore
+
+Übersicht aller Konfigurationswerte des Backends: was ist einstellbar, was ist der Default, wie überschreibt man ihn. Ziel: **kein Wert, den ein Betreiber sinnvoll ändern könnte, steckt fest im Code.**
+
+## Wie Konfiguration geladen wird
+
+ASP.NET Core liest Konfiguration in dieser Reihenfolge (später gewinnt):
+
+1. `appsettings.json` — Defaults, die für jede Umgebung gelten.
+2. `appsettings.{ASPNETCORE_ENVIRONMENT}.json` (z. B. `appsettings.Development.json`) — Overrides pro Umgebung. **Enthält keine echten Secrets**, nur `CHANGE_ME_...`-Platzhalter.
+3. Umgebungsvariablen — verschachtelte Keys wie `Jwt:Secret` werden zu `Jwt__Secret` (doppelter Unterstrich statt Doppelpunkt).
+4. In Development zusätzlich: `dotnet user-secrets` (lokal, nicht eingecheckt) — `dotnet user-secrets init` im `OmsorgCore.Api`-Projekt einmalig ausführen, dann z. B. `dotnet user-secrets set "ConnectionStrings:OmsorgCore" "..."`.
+
+**Secrets nie einchecken.** `Jwt:Secret`, `ConnectionStrings:OmsorgCore` und (sobald produktiv genutzt) `Seed:AdminPassword` haben in den eingecheckten `appsettings*.json`-Dateien nur Platzhalter — echte Werte kommen aus Umgebungsvariablen oder User-Secrets.
+
+## Auth / JWT
+
+| Key | Default | Env-Var | Beschreibung |
+|---|---|---|---|
+| `Jwt:Secret` | *(kein Default, Platzhalter in Dev)* | `Jwt__Secret` | Signaturschlüssel für Access-Tokens. Muss mind. 32 Zeichen, produktiv nur per Env-Var/Secret-Store. |
+| `Jwt:Issuer` | `"OmsorgCore"` | `Jwt__Issuer` | JWT-`iss`-Claim, wird beim Validieren geprüft. |
+| `Jwt:Audience` | `"OmsorgClients"` | `Jwt__Audience` | JWT-`aud`-Claim, wird beim Validieren geprüft. |
+| `Jwt:ExpiryMinutes` | `60` | `Jwt__ExpiryMinutes` | Gültigkeitsdauer eines Access-Tokens. |
+| `RefreshToken:ExpiryDays` | `60` | `RefreshToken__ExpiryDays` | Gültigkeitsdauer eines Refresh-Tokens (sliding — jede Nutzung/Rotation verlängert effektiv die Session). |
+| `Auth:MaxLoginFailures` | `5` | `Auth__MaxLoginFailures` | Fehlversuche pro IP innerhalb von `Auth:LoginLockoutMinutes`, ab denen `POST /api/auth/login` mit `429` sperrt (zentral für alle Clients, siehe `AuthService.LoginAsync`/`LoginAttempt`). |
+| `Auth:LoginLockoutMinutes` | `10` | `Auth__LoginLockoutMinutes` | Zeitfenster, in dem Fehlversuche gezählt werden, bevor die Sperre wieder abläuft. |
+
+## Passwort-Reset / E-Mail
+
+| Key | Default | Env-Var | Beschreibung |
+|---|---|---|---|
+| `Email:Provider` | `"Console"` | `Email__Provider` | Mail-Versandweg. `Console` schreibt Mails nur ins Log (sicherer Default). `Smtp` versendet echt über die Werte unten (implementiert über MailKit). |
+| `Email:PinExpiryMinutes` | `5` | `Email__PinExpiryMinutes` | Gültigkeitsdauer des 6-stelligen PINs beim "Passwort vergessen"-Flow. |
+| `Email:MaxAttempts` | `3` | `Email__MaxAttempts` | Maximale Anzahl Fehlversuche bei der PIN-Eingabe, bevor der Code invalidiert wird. |
+| `Email:ResetTokenExpiryMinutes` | `10` | `Email__ResetTokenExpiryMinutes` | Gültigkeitsdauer des Reset-Tokens nach erfolgreicher PIN-Verifikation. |
+| `Email:RequestCooldownSeconds` | `60` | `Email__RequestCooldownSeconds` | Mindestabstand zwischen zwei Reset-Anfragen desselben Users (Spam-Schutz). |
+
+**Ein SMTP-Server, mehrere Accounts:** die Verbindungsdaten (`Email:Smtp:*`) sind einmal gemeinsam konfiguriert, Login + Absenderidentität liegen pro benanntem Account unter `Email:Accounts::*` — so lässt sich z. B. ein Account exklusiv für Passwort-Reset-Mails führen, getrennt von einem künftigen allgemeinen Absender, ohne einen zweiten Server zu brauchen.
+
+| Key | Default | Env-Var | Beschreibung |
+|---|---|---|---|
+| `Email:Smtp:Host` | *(leer)* | `Email__Smtp__Host` | SMTP-Server-Adresse. Nur wirksam, wenn `Email:Provider` = `"Smtp"`. |
+| `Email:Smtp:Port` | `587` | `Email__Smtp__Port` | SMTP-Port. |
+| `Email:Smtp:EnableSsl` | `true` | `Email__Smtp__EnableSsl` | Ob StartTLS/SSL beim Verbindungsaufbau verwendet wird. |
+| `Email:Accounts::Username` | *(leer, Platzhalter in Dev)* | `Email__Accounts____Username` | SMTP-Login-Username dieses Accounts. Secret — nie echten Wert einchecken. |
+| `Email:Accounts::Password` | *(leer, Platzhalter in Dev)* | `Email__Accounts____Password` | SMTP-Login-Passwort dieses Accounts. Secret — produktiv nur per Env-Var/user-secrets, **nie** in `appsettings*.json` einchecken. |
+| `Email:Accounts::SenderAddress` | *(leer)* | `Email__Accounts____SenderAddress` | Absenderadresse dieses Accounts. |
+| `Email:Accounts::SenderDisplayName` | `"OMSORG"` | `Email__Accounts____SenderDisplayName` | Absendername dieses Accounts. |
+| `Email:PasswordResetAccount` | `"PasswordReset"` | `Email__PasswordResetAccount` | Welcher Account-Name aus `Email:Accounts` für Passwort-Reset-Mails verwendet wird. Standardmäßig ist `"PasswordReset"` bereits als Account in `appsettings.json` angelegt (leer, muss per Env-Var/user-secrets befüllt werden). |
+| `Email:PasswordResetTemplate:Subject` | `"Ihr OMSORG Passwort-Reset-Code"` | `Email__PasswordResetTemplate__Subject` | Betreff der Passwort-Reset-Mail. |
+| `Email:PasswordResetTemplate:BodyTemplate` | siehe `appsettings.json` | `Email__PasswordResetTemplate__BodyTemplate` | Text der Passwort-Reset-Mail. Platzhalter im Format `$NAME$`: `$RESET_PIN$` (der 6-stellige Code) und `$RESET_PIN_EXPIRY_MINUTES$` (aus `Email:PinExpiryMinutes`). Ersetzung über `OmsorgCore.Email.EmailTemplateRenderer`. |
+| `Email:UserInviteAccount` | `"UserInvite"` | `Email__UserInviteAccount` | Welcher Account-Name aus `Email:Accounts` für Account-Einladungsmails (neuer User-Account für einen Mitarbeiter) verwendet wird. |
+| `Email:UserInviteTemplate:Subject` | `"Ihr OMSORG-Zugang"` | `Email__UserInviteTemplate__Subject` | Betreff der Einladungsmail. |
+| `Email:UserInviteTemplate:BodyTemplate` | siehe `appsettings.json` | `Email__UserInviteTemplate__BodyTemplate` | Text der Einladungsmail. Platzhalter: `$INVITE_PIN$`, `$INVITE_PIN_EXPIRY_DAYS$`. Die Gültigkeitsdauer wird beim Anlegen des Accounts pro Einladung gewählt (`POST /api/users`, `PinValidityDays`, Default 7), nicht global konfiguriert — der Einladungs-PIN läuft über dieselbe `PasswordResetCode`-Tabelle wie der Passwort-vergessen-Flow, nur mit individuell längerer Gültigkeit statt der 5 Minuten aus `Email:PinExpiryMinutes`. |
+
+**Kein API-Endpoint zum Setzen dieser Werte:** SMTP-Zugangsdaten und Mail-Texte werden bewusst nie über eine vom Client erreichbare Route entgegengenommen oder ausgeliefert — nur über die üblichen serverseitigen Konfigurationswege oben. Ein `POST /api/admin/email/test-send` (siehe `AdminEmailController`) löst lediglich den Versand einer festen Testmail über die bereits konfigurierte Verbindung aus, liefert aber nie Host/Username/Passwort an den Client zurück.
+
+## Seed (nur Development)
+
+| Key | Default | Env-Var | Beschreibung |
+|---|---|---|---|
+| `Seed:AdminUsername` | `"admin"` | `Seed__AdminUsername` | Username des automatisch angelegten Standard-Admin-Users. |
+| `Seed:AdminPassword` | `"abersicher"` | `Seed__AdminPassword` | Passwort des Standard-Admin-Users. |
+
+Der Seed läuft nur, wenn `ASPNETCORE_ENVIRONMENT=Development` **und** noch kein `User` in der DB existiert (idempotent, siehe `src/OmsorgCore.Infrastructure/Persistence/DbSeeder.cs`). In Produktion läuft er nicht — dort braucht es einen echten User-Anlage-Flow (noch nicht gebaut, siehe `omsorgCore/CLAUDE.md`, "Offene Punkte").
+
+## Datenbank
+
+| Key | Default | Env-Var | Beschreibung |
+|---|---|---|---|
+| `ConnectionStrings:OmsorgCore` | *(kein Default, Platzhalter in Dev)* | `ConnectionStrings__OmsorgCore` | Postgres-Connection-String (`Host=...;Port=5432;Database=omsorg_core;Username=...;Password=...`). |
+
+## Logging / Hosting
+
+| Key | Default | Env-Var | Beschreibung |
+|---|---|---|---|
+| `Logging:LogLevel:Default` | `"Information"` | `Logging__LogLevel__Default` | Standard-Log-Level. |
+| `Logging:LogLevel:Microsoft.AspNetCore` | `"Warning"` | `Logging__LogLevel__Microsoft.AspNetCore` | Log-Level für das ASP.NET-Core-Framework selbst. |
+| `AllowedHosts` | `"*"` | `AllowedHosts` | Host-Header-Filter von ASP.NET Core. |
+
+## Bewusst nicht konfigurierbar
+
+Diese Werte sind absichtlich fest im Code und **keine** Konfigurationslücke:
+
+- **`ModuleType`, `PermissionAction`, `PermissionEffect`** (`src/OmsorgCore.Domain/Enums/`) — die Rechtematrix-Bausteine. Compile-Time-Domänenkonzepte: ein neues Modul oder eine neue Aktion braucht ohnehin neuen Controller-Code, ein Config-Wert würde hier nur Scheinflexibilität vortäuschen.
+- **JWT-Validierungs-Invarianten** in `Program.cs` (`ValidateIssuer/Audience/Lifetime/IssuerSigningKey = true`) — Sicherheits-Grundannahmen, kein Betriebsparameter.
+- **Passwort-Hashing-Algorithmus** (`PasswordHasher`, ASP.NET-Identity-Standard-PBKDF2-Iterationszahl) — bewusst der Framework-Default, kein eigenes Krypto-Rad.
\ No newline at end of file
diff --git a/omsorgCore/OmsorgCore.sln b/omsorgCore/OmsorgCore.sln
new file mode 100644
index 0000000..947aed0
--- /dev/null
+++ b/omsorgCore/OmsorgCore.sln
@@ -0,0 +1,71 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{56212531-1A9C-478F-AE76-FF2EBF4739A2}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OmsorgCore.Domain", "src\OmsorgCore.Domain\OmsorgCore.Domain.csproj", "{6609F28E-F062-4A28-B161-D9A76A498987}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OmsorgCore.Application", "src\OmsorgCore.Application\OmsorgCore.Application.csproj", "{979A8C32-B0C6-461A-8BB4-D7EB247CAAB9}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OmsorgCore.Infrastructure", "src\OmsorgCore.Infrastructure\OmsorgCore.Infrastructure.csproj", "{3972F31A-A0F7-4A12-882B-FB60BE774936}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OmsorgCore.Engine", "src\OmsorgCore.Engine\OmsorgCore.Engine.csproj", "{4D108EE6-C808-4543-AC6B-1B20D0900ED3}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OmsorgCore.Api", "src\OmsorgCore.Api\OmsorgCore.Api.csproj", "{8DB4399D-945C-4064-AF9D-881B6BD229B4}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{CBBB0229-FAD9-4443-BA91-DFC04FDA8913}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OmsorgCore.Tests", "tests\OmsorgCore.Tests\OmsorgCore.Tests.csproj", "{5BB394B3-7350-4A6E-808F-F300742F2EEC}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OmsorgCore.Email", "src\OmsorgCore.Email\OmsorgCore.Email.csproj", "{6B58D108-8D89-4D8F-A262-309D0BCF674F}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {6609F28E-F062-4A28-B161-D9A76A498987}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6609F28E-F062-4A28-B161-D9A76A498987}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6609F28E-F062-4A28-B161-D9A76A498987}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6609F28E-F062-4A28-B161-D9A76A498987}.Release|Any CPU.Build.0 = Release|Any CPU
+ {979A8C32-B0C6-461A-8BB4-D7EB247CAAB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {979A8C32-B0C6-461A-8BB4-D7EB247CAAB9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {979A8C32-B0C6-461A-8BB4-D7EB247CAAB9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {979A8C32-B0C6-461A-8BB4-D7EB247CAAB9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3972F31A-A0F7-4A12-882B-FB60BE774936}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3972F31A-A0F7-4A12-882B-FB60BE774936}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3972F31A-A0F7-4A12-882B-FB60BE774936}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3972F31A-A0F7-4A12-882B-FB60BE774936}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4D108EE6-C808-4543-AC6B-1B20D0900ED3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {4D108EE6-C808-4543-AC6B-1B20D0900ED3}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4D108EE6-C808-4543-AC6B-1B20D0900ED3}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {4D108EE6-C808-4543-AC6B-1B20D0900ED3}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8DB4399D-945C-4064-AF9D-881B6BD229B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8DB4399D-945C-4064-AF9D-881B6BD229B4}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8DB4399D-945C-4064-AF9D-881B6BD229B4}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8DB4399D-945C-4064-AF9D-881B6BD229B4}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5BB394B3-7350-4A6E-808F-F300742F2EEC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5BB394B3-7350-4A6E-808F-F300742F2EEC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5BB394B3-7350-4A6E-808F-F300742F2EEC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5BB394B3-7350-4A6E-808F-F300742F2EEC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6B58D108-8D89-4D8F-A262-309D0BCF674F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6B58D108-8D89-4D8F-A262-309D0BCF674F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6B58D108-8D89-4D8F-A262-309D0BCF674F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6B58D108-8D89-4D8F-A262-309D0BCF674F}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {6609F28E-F062-4A28-B161-D9A76A498987} = {56212531-1A9C-478F-AE76-FF2EBF4739A2}
+ {979A8C32-B0C6-461A-8BB4-D7EB247CAAB9} = {56212531-1A9C-478F-AE76-FF2EBF4739A2}
+ {3972F31A-A0F7-4A12-882B-FB60BE774936} = {56212531-1A9C-478F-AE76-FF2EBF4739A2}
+ {4D108EE6-C808-4543-AC6B-1B20D0900ED3} = {56212531-1A9C-478F-AE76-FF2EBF4739A2}
+ {8DB4399D-945C-4064-AF9D-881B6BD229B4} = {56212531-1A9C-478F-AE76-FF2EBF4739A2}
+ {5BB394B3-7350-4A6E-808F-F300742F2EEC} = {CBBB0229-FAD9-4443-BA91-DFC04FDA8913}
+ {6B58D108-8D89-4D8F-A262-309D0BCF674F} = {56212531-1A9C-478F-AE76-FF2EBF4739A2}
+ EndGlobalSection
+EndGlobal
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/ChangePasswordRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/ChangePasswordRequest.cs
new file mode 100644
index 0000000..4432530
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/ChangePasswordRequest.cs
@@ -0,0 +1,3 @@
+namespace OmsorgCore.Api.Contracts;
+
+public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/CreateEmployeeRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/CreateEmployeeRequest.cs
new file mode 100644
index 0000000..b0e9142
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/CreateEmployeeRequest.cs
@@ -0,0 +1,19 @@
+namespace OmsorgCore.Api.Contracts;
+
+public record CreateEmployeeRequest(
+ string FirstName,
+ string LastName,
+ DateOnly? DateOfBirth,
+ string? Street,
+ string? PostalCode,
+ string? City,
+ string? Country,
+ string? PhoneNumber,
+ string? Email,
+ DateOnly? EntryDate,
+ DateOnly? ExitDate,
+ string? EmergencyContactName,
+ string? EmergencyContactPhone,
+ string? EmergencyContactRelation,
+ string? EmploymentType,
+ string? Qualification);
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/CreateRoleRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/CreateRoleRequest.cs
new file mode 100644
index 0000000..7e3f35d
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/CreateRoleRequest.cs
@@ -0,0 +1,3 @@
+namespace OmsorgCore.Api.Contracts;
+
+public record CreateRoleRequest(string Name);
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/CreateUserRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/CreateUserRequest.cs
new file mode 100644
index 0000000..fb49e16
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/CreateUserRequest.cs
@@ -0,0 +1,10 @@
+namespace OmsorgCore.Api.Contracts;
+
+/// Mode: "Invite" (Einladungsmail mit PIN, Mitarbeiter setzt eigenes Passwort) oder "Direct" (Admin vergibt InitialPassword direkt, MustChangePassword erzwingt Wechsel).
+public record CreateUserRequest(
+ Guid EmployeeId,
+ string Username,
+ Guid RoleId,
+ string Mode,
+ string? InitialPassword,
+ int? PinValidityDays);
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/EmployeeResponse.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/EmployeeResponse.cs
new file mode 100644
index 0000000..da43759
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/EmployeeResponse.cs
@@ -0,0 +1,22 @@
+namespace OmsorgCore.Api.Contracts;
+
+public record EmployeeResponse(
+ Guid Id,
+ string FirstName,
+ string LastName,
+ string Status,
+ DateOnly? DateOfBirth,
+ string? Street,
+ string? PostalCode,
+ string? City,
+ string? Country,
+ string? PhoneNumber,
+ string? Email,
+ DateOnly? EntryDate,
+ DateOnly? ExitDate,
+ string? EmergencyContactName,
+ string? EmergencyContactPhone,
+ string? EmergencyContactRelation,
+ string? EmploymentType,
+ string? Qualification,
+ string? AvatarFileName);
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordRequestRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordRequestRequest.cs
new file mode 100644
index 0000000..1966c7b
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordRequestRequest.cs
@@ -0,0 +1,3 @@
+namespace OmsorgCore.Api.Contracts;
+
+public record ForgotPasswordRequestRequest(string Username);
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordRequestResponse.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordRequestResponse.cs
new file mode 100644
index 0000000..f74578a
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordRequestResponse.cs
@@ -0,0 +1,4 @@
+namespace OmsorgCore.Api.Contracts;
+
+/// "sent" | "cannot_reset" - siehe AuthController.ForgotPasswordRequest für die Anti-Enumeration-Abwägung.
+public record ForgotPasswordRequestResponse(string Status);
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordResetRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordResetRequest.cs
new file mode 100644
index 0000000..66752ab
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordResetRequest.cs
@@ -0,0 +1,3 @@
+namespace OmsorgCore.Api.Contracts;
+
+public record ForgotPasswordResetRequest(string ResetToken, string NewPassword);
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordVerifyRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordVerifyRequest.cs
new file mode 100644
index 0000000..96b8d27
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordVerifyRequest.cs
@@ -0,0 +1,3 @@
+namespace OmsorgCore.Api.Contracts;
+
+public record ForgotPasswordVerifyRequest(string Username, string Pin);
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordVerifyResponse.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordVerifyResponse.cs
new file mode 100644
index 0000000..49542fb
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordVerifyResponse.cs
@@ -0,0 +1,3 @@
+namespace OmsorgCore.Api.Contracts;
+
+public record ForgotPasswordVerifyResponse(string ResetToken);
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/LoginRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/LoginRequest.cs
new file mode 100644
index 0000000..51ff6df
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/LoginRequest.cs
@@ -0,0 +1,3 @@
+namespace OmsorgCore.Api.Contracts;
+
+public record LoginRequest(string Username, string Password);
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/LoginResponse.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/LoginResponse.cs
new file mode 100644
index 0000000..f817bc7
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/LoginResponse.cs
@@ -0,0 +1,3 @@
+namespace OmsorgCore.Api.Contracts;
+
+public record LoginResponse(string AccessToken, string RefreshToken, DateTime ExpiresAt, bool MustChangePassword);
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/LogoutRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/LogoutRequest.cs
new file mode 100644
index 0000000..8a3a554
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/LogoutRequest.cs
@@ -0,0 +1,3 @@
+namespace OmsorgCore.Api.Contracts;
+
+public record LogoutRequest(string RefreshToken);
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/MeResponse.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/MeResponse.cs
new file mode 100644
index 0000000..d3a5aed
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/MeResponse.cs
@@ -0,0 +1,13 @@
+namespace OmsorgCore.Api.Contracts;
+
+public record MeResponse(
+ Guid UserId,
+ string Username,
+ string Role,
+ IReadOnlyList Permissions,
+ Guid? EmployeeId,
+ string? FirstName,
+ string? LastName,
+ string? Email,
+ string? PhoneNumber,
+ string? AvatarFileName);
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/PagedResponse.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/PagedResponse.cs
new file mode 100644
index 0000000..22c37de
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/PagedResponse.cs
@@ -0,0 +1,3 @@
+namespace OmsorgCore.Api.Contracts;
+
+public record PagedResponse(IReadOnlyList Items, int TotalCount, int Page, int PageSize);
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/PasswordResetTemplateResponse.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/PasswordResetTemplateResponse.cs
new file mode 100644
index 0000000..9b63bcd
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/PasswordResetTemplateResponse.cs
@@ -0,0 +1,3 @@
+namespace OmsorgCore.Api.Contracts;
+
+public record PasswordResetTemplateResponse(string Subject, string BodyTemplate);
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/PermissionDto.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/PermissionDto.cs
new file mode 100644
index 0000000..120e639
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/PermissionDto.cs
@@ -0,0 +1,3 @@
+namespace OmsorgCore.Api.Contracts;
+
+public record PermissionDto(string Module, string Action);
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/RefreshRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/RefreshRequest.cs
new file mode 100644
index 0000000..c580648
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/RefreshRequest.cs
@@ -0,0 +1,3 @@
+namespace OmsorgCore.Api.Contracts;
+
+public record RefreshRequest(string RefreshToken);
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/ResetUserPasswordRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/ResetUserPasswordRequest.cs
new file mode 100644
index 0000000..3a263a0
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/ResetUserPasswordRequest.cs
@@ -0,0 +1,4 @@
+namespace OmsorgCore.Api.Contracts;
+
+/// Mode: "Invite" (neuer Einladungs-PIN) oder "Direct" (Admin vergibt InitialPassword direkt, MustChangePassword erzwingt Wechsel).
+public record ResetUserPasswordRequest(string Mode, string? InitialPassword, int? PinValidityDays);
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/RoleResponse.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/RoleResponse.cs
new file mode 100644
index 0000000..16c036a
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/RoleResponse.cs
@@ -0,0 +1,3 @@
+namespace OmsorgCore.Api.Contracts;
+
+public record RoleResponse(Guid Id, string Name);
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/SendTestEmailRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/SendTestEmailRequest.cs
new file mode 100644
index 0000000..fc288b2
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/SendTestEmailRequest.cs
@@ -0,0 +1,3 @@
+namespace OmsorgCore.Api.Contracts;
+
+public record SendTestEmailRequest(string ToAddress, string? Subject, string? Body);
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/SessionResponse.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/SessionResponse.cs
new file mode 100644
index 0000000..83379e8
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/SessionResponse.cs
@@ -0,0 +1,3 @@
+namespace OmsorgCore.Api.Contracts;
+
+public record SessionResponse(Guid Id, string Username, DateTime CreatedAt, DateTime ExpiresAt);
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/UpdateEmployeeRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/UpdateEmployeeRequest.cs
new file mode 100644
index 0000000..70267c2
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/UpdateEmployeeRequest.cs
@@ -0,0 +1,21 @@
+namespace OmsorgCore.Api.Contracts;
+
+public record UpdateEmployeeRequest(
+ string FirstName,
+ string LastName,
+ string Status,
+ DateOnly? DateOfBirth,
+ string? Street,
+ string? PostalCode,
+ string? City,
+ string? Country,
+ string? PhoneNumber,
+ string? Email,
+ DateOnly? EntryDate,
+ DateOnly? ExitDate,
+ string? EmergencyContactName,
+ string? EmergencyContactPhone,
+ string? EmergencyContactRelation,
+ string? EmploymentType,
+ string? Qualification,
+ string? AvatarFileName);
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/UpdateUserRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/UpdateUserRequest.cs
new file mode 100644
index 0000000..8f840c2
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/UpdateUserRequest.cs
@@ -0,0 +1,3 @@
+namespace OmsorgCore.Api.Contracts;
+
+public record UpdateUserRequest(Guid RoleId, bool IsActive);
diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/UserResponse.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/UserResponse.cs
new file mode 100644
index 0000000..c3712f5
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Contracts/UserResponse.cs
@@ -0,0 +1,3 @@
+namespace OmsorgCore.Api.Contracts;
+
+public record UserResponse(Guid Id, string Username, Guid? EmployeeId, string RoleName, bool IsActive, bool MustChangePassword);
diff --git a/omsorgCore/src/OmsorgCore.Api/Controllers/AdminEmailController.cs b/omsorgCore/src/OmsorgCore.Api/Controllers/AdminEmailController.cs
new file mode 100644
index 0000000..726d1a5
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Controllers/AdminEmailController.cs
@@ -0,0 +1,67 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
+using OmsorgCore.Api.Contracts;
+using OmsorgCore.Api.Security;
+using OmsorgCore.Application.Abstractions;
+using OmsorgCore.Domain.Enums;
+using OmsorgCore.Email;
+
+namespace OmsorgCore.Api.Controllers;
+
+///
+/// Debug-/Admin-Funktion, um den konfigurierten Mail-Versand zu testen (siehe omsorgapp Debug-Seite).
+/// Sendet nur eine Testmail über den registrierten IEmailSender, mit vom Admin editierbarem Betreff/Text
+/// (vorbefüllt aus der echten Passwort-Reset-Vorlage) - SMTP-Zugangsdaten selbst verlassen den Server
+/// dabei zu keinem Zeitpunkt.
+///
+[ApiController]
+[Authorize]
+[Route("api/admin/email")]
+public class AdminEmailController : ControllerBase
+{
+ private readonly IEmailSender _emailSender;
+ private readonly EmailOptions _emailOptions;
+
+ public AdminEmailController(IEmailSender emailSender, IOptions emailOptions)
+ {
+ _emailSender = emailSender;
+ _emailOptions = emailOptions.Value;
+ }
+
+ [HttpGet("password-reset-template")]
+ [RequirePermission(ModuleType.UserManagement, PermissionAction.View)]
+ public ActionResult GetPasswordResetTemplate()
+ {
+ var template = _emailOptions.PasswordResetTemplate;
+ return Ok(new PasswordResetTemplateResponse(template.Subject, template.BodyTemplate));
+ }
+
+ [HttpPost("test-send")]
+ [RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
+ public async Task TestSend(SendTestEmailRequest request, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(request.ToAddress))
+ {
+ return BadRequest(new { error = "to_address_required" });
+ }
+
+ var template = _emailOptions.PasswordResetTemplate;
+ var subject = string.IsNullOrWhiteSpace(request.Subject) ? template.Subject : request.Subject;
+ var body = string.IsNullOrWhiteSpace(request.Body) ? template.BodyTemplate : request.Body;
+
+ // Testmail nutzt echte Platzhalter-Ersetzung (mit Beispiel-Werten), damit sie genau zeigt,
+ // was ein Nutzer im echten Reset-Flow bekommen würde.
+ var values = new Dictionary
+ {
+ ["RESET_PIN"] = "123456",
+ ["RESET_PIN_EXPIRY_MINUTES"] = _emailOptions.PinExpiryMinutes.ToString()
+ };
+ subject = EmailTemplateRenderer.Render(subject, values);
+ body = EmailTemplateRenderer.Render(body, values);
+
+ await _emailSender.SendAsync(new EmailMessage(request.ToAddress, subject, body), cancellationToken);
+
+ return NoContent();
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Api/Controllers/AdminSessionsController.cs b/omsorgCore/src/OmsorgCore.Api/Controllers/AdminSessionsController.cs
new file mode 100644
index 0000000..a655442
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Controllers/AdminSessionsController.cs
@@ -0,0 +1,49 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using OmsorgCore.Api.Contracts;
+using OmsorgCore.Api.Security;
+using OmsorgCore.Application.Services;
+using OmsorgCore.Domain.Enums;
+
+namespace OmsorgCore.Api.Controllers;
+
+///
+/// Debug-/Admin-Sicht auf aktive Sessions (siehe omsorgapp Debug-Seite). Nur für Rollen mit
+/// UserManagement-Rechten (per Default: Administrator, siehe DbSeeder).
+///
+[ApiController]
+[Authorize]
+[Route("api/admin/sessions")]
+public class AdminSessionsController : ControllerBase
+{
+ private readonly ISessionAdminService _sessionAdminService;
+
+ public AdminSessionsController(ISessionAdminService sessionAdminService)
+ {
+ _sessionAdminService = sessionAdminService;
+ }
+
+ [HttpGet]
+ [RequirePermission(ModuleType.UserManagement, PermissionAction.View)]
+ public async Task>> GetAll(CancellationToken cancellationToken)
+ {
+ var sessions = await _sessionAdminService.GetActiveSessionsAsync(cancellationToken);
+ return Ok(sessions.Select(s => new SessionResponse(s.Id, s.Username, s.CreatedAt, s.ExpiresAt)));
+ }
+
+ [HttpPost("{id:guid}/revoke")]
+ [RequirePermission(ModuleType.UserManagement, PermissionAction.Delete)]
+ public async Task Revoke(Guid id, CancellationToken cancellationToken)
+ {
+ await _sessionAdminService.RevokeSessionAsync(id, cancellationToken);
+ return NoContent();
+ }
+
+ [HttpPost("revoke-all")]
+ [RequirePermission(ModuleType.UserManagement, PermissionAction.Delete)]
+ public async Task RevokeAll(CancellationToken cancellationToken)
+ {
+ await _sessionAdminService.RevokeAllSessionsAsync(cancellationToken);
+ return NoContent();
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Api/Controllers/AuthController.cs b/omsorgCore/src/OmsorgCore.Api/Controllers/AuthController.cs
new file mode 100644
index 0000000..aae9631
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Controllers/AuthController.cs
@@ -0,0 +1,163 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using OmsorgCore.Api.Contracts;
+using OmsorgCore.Application.Abstractions;
+using OmsorgCore.Application.Services;
+using OmsorgCore.Engine.Events;
+
+namespace OmsorgCore.Api.Controllers;
+
+[ApiController]
+[Route("api/auth")]
+public class AuthController : ControllerBase
+{
+ private readonly IAuthService _authService;
+ private readonly ICurrentUserService _currentUserService;
+ private readonly IPasswordResetService _passwordResetService;
+ private readonly IUserService _userService;
+ private readonly IDomainEventDispatcher _dispatcher;
+
+ public AuthController(
+ IAuthService authService,
+ ICurrentUserService currentUserService,
+ IPasswordResetService passwordResetService,
+ IUserService userService,
+ IDomainEventDispatcher dispatcher)
+ {
+ _authService = authService;
+ _currentUserService = currentUserService;
+ _passwordResetService = passwordResetService;
+ _userService = userService;
+ _dispatcher = dispatcher;
+ }
+
+ [HttpPost("login")]
+ public async Task> Login(LoginRequest request, CancellationToken cancellationToken)
+ {
+ var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
+ var result = await _authService.LoginAsync(request.Username, request.Password, ipAddress, cancellationToken);
+
+ if (result.IsLockedOut)
+ {
+ return StatusCode(StatusCodes.Status429TooManyRequests, new { error = "too_many_attempts" });
+ }
+
+ if (!result.Success || result.Token is null || result.RefreshToken is null || result.ExpiresAt is null)
+ {
+ return Unauthorized();
+ }
+
+ return Ok(new LoginResponse(result.Token, result.RefreshToken, result.ExpiresAt.Value, result.MustChangePassword));
+ }
+
+ [HttpPost("refresh")]
+ public async Task> Refresh(RefreshRequest request, CancellationToken cancellationToken)
+ {
+ var result = await _authService.RefreshAsync(request.RefreshToken, cancellationToken);
+ if (!result.Success || result.Token is null || result.RefreshToken is null || result.ExpiresAt is null)
+ {
+ return Unauthorized();
+ }
+
+ return Ok(new LoginResponse(result.Token, result.RefreshToken, result.ExpiresAt.Value, result.MustChangePassword));
+ }
+
+ [HttpPost("logout")]
+ public async Task Logout(LogoutRequest request, CancellationToken cancellationToken)
+ {
+ await _authService.RevokeAsync(request.RefreshToken, cancellationToken);
+ return NoContent();
+ }
+
+ [HttpGet("me")]
+ [Authorize]
+ public async Task> Me(CancellationToken cancellationToken)
+ {
+ if (_currentUserService.UserId is not { } userId)
+ {
+ return Unauthorized();
+ }
+
+ var profile = await _authService.GetProfileAsync(userId, cancellationToken);
+ if (profile is null)
+ {
+ return Unauthorized();
+ }
+
+ var permissions = profile.Permissions
+ .Select(p => new PermissionDto(p.Module.ToString(), p.Action.ToString()))
+ .ToList();
+
+ return Ok(new MeResponse(
+ profile.UserId,
+ profile.Username,
+ profile.RoleName,
+ permissions,
+ profile.EmployeeId,
+ profile.FirstName,
+ profile.LastName,
+ profile.Email,
+ profile.PhoneNumber,
+ profile.AvatarFileName));
+ }
+
+ [HttpPost("change-password")]
+ [Authorize]
+ public async Task ChangePassword(ChangePasswordRequest request, CancellationToken cancellationToken)
+ {
+ if (_currentUserService.UserId is not { } userId)
+ {
+ return Unauthorized();
+ }
+
+ if (string.IsNullOrWhiteSpace(request.NewPassword) || request.NewPassword.Length < 8)
+ {
+ return BadRequest("NewPassword muss mindestens 8 Zeichen lang sein.");
+ }
+
+ var success = await _userService.ChangeOwnPasswordAsync(userId, request.CurrentPassword, request.NewPassword, cancellationToken);
+ return success ? NoContent() : Unauthorized();
+ }
+
+ [HttpPost("forgot-password/request")]
+ public async Task> ForgotPasswordRequest(
+ ForgotPasswordRequestRequest request, CancellationToken cancellationToken)
+ {
+ var result = await _passwordResetService.RequestResetAsync(request.Username, cancellationToken);
+ if (result.Status == PasswordResetRequestStatus.Sent && result.Email is not null && result.RawPin is not null)
+ {
+ await _dispatcher.DispatchAsync(
+ new PasswordResetRequestedEvent(result.Email, result.RawPin), cancellationToken);
+ }
+
+ var status = result.Status == PasswordResetRequestStatus.Sent ? "sent" : "cannot_reset";
+ return Ok(new ForgotPasswordRequestResponse(status));
+ }
+
+ [HttpPost("forgot-password/verify")]
+ public async Task> ForgotPasswordVerify(
+ ForgotPasswordVerifyRequest request, CancellationToken cancellationToken)
+ {
+ var result = await _passwordResetService.VerifyCodeAsync(request.Username, request.Pin, cancellationToken);
+ if (!result.Success || result.ResetToken is null)
+ {
+ return Unauthorized(new { error = result.FailureReason ?? "invalid_or_expired" });
+ }
+
+ return Ok(new ForgotPasswordVerifyResponse(result.ResetToken));
+ }
+
+ [HttpPost("forgot-password/reset")]
+ public async Task ForgotPasswordReset(
+ ForgotPasswordResetRequest request, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(request.NewPassword))
+ {
+ return Unauthorized();
+ }
+
+ var success = await _passwordResetService.ResetPasswordAsync(
+ request.ResetToken, request.NewPassword, cancellationToken);
+ return success ? NoContent() : Unauthorized();
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Api/Controllers/EmployeesController.cs b/omsorgCore/src/OmsorgCore.Api/Controllers/EmployeesController.cs
new file mode 100644
index 0000000..a06104e
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Controllers/EmployeesController.cs
@@ -0,0 +1,289 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using OmsorgCore.Api.Contracts;
+using OmsorgCore.Api.Security;
+using OmsorgCore.Application.Services;
+using OmsorgCore.Domain.Entities;
+using OmsorgCore.Domain.Enums;
+using OmsorgCore.Engine.Events;
+
+namespace OmsorgCore.Api.Controllers;
+
+[ApiController]
+[Authorize]
+[Route("api/employees")]
+public class EmployeesController : ControllerBase
+{
+ private static readonly string[] AllowedEmploymentTypes =
+ { "Vollzeit", "Teilzeit", "Minijob", "Aushilfe", "Praktikant", "Freiberuflich" };
+
+ private readonly IEmployeeService _employeeService;
+ private readonly IDomainEventDispatcher _dispatcher;
+
+ public EmployeesController(IEmployeeService employeeService, IDomainEventDispatcher dispatcher)
+ {
+ _employeeService = employeeService;
+ _dispatcher = dispatcher;
+ }
+
+ [HttpGet]
+ [RequirePermission(ModuleType.Employees, PermissionAction.View)]
+ public async Task>> GetAll(
+ [FromQuery] string? search,
+ [FromQuery] string? status,
+ [FromQuery] string? employmentType,
+ [FromQuery] int page = 1,
+ [FromQuery] int pageSize = 20,
+ CancellationToken cancellationToken = default)
+ {
+ if (employmentType is not null && !AllowedEmploymentTypes.Contains(employmentType))
+ {
+ return BadRequest($"employmentType muss einer der folgenden Werte sein: {string.Join(", ", AllowedEmploymentTypes)}.");
+ }
+
+ page = Math.Max(page, 1);
+ pageSize = Math.Clamp(pageSize, 1, 100);
+
+ var (items, totalCount) = await _employeeService.GetPagedAsync(search, status, employmentType, page, pageSize, cancellationToken);
+ return Ok(new PagedResponse(items.Select(ToResponse).ToList(), totalCount, page, pageSize));
+ }
+
+ [HttpGet("{id:guid}")]
+ [RequirePermission(ModuleType.Employees, PermissionAction.View)]
+ public async Task> GetById(Guid id, CancellationToken cancellationToken)
+ {
+ var employee = await _employeeService.GetByIdAsync(id, cancellationToken);
+ return employee is null ? NotFound() : Ok(ToResponse(employee));
+ }
+
+ [HttpPost]
+ [RequirePermission(ModuleType.Employees, PermissionAction.Create)]
+ public async Task> Create(CreateEmployeeRequest request, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(request.FirstName) || request.FirstName.Length > 200)
+ {
+ return BadRequest("FirstName ist erforderlich und darf maximal 200 Zeichen lang sein.");
+ }
+
+ if (string.IsNullOrWhiteSpace(request.LastName) || request.LastName.Length > 200)
+ {
+ return BadRequest("LastName ist erforderlich und darf maximal 200 Zeichen lang sein.");
+ }
+
+ if (request.PhoneNumber is { Length: > 50 })
+ {
+ return BadRequest("PhoneNumber darf maximal 50 Zeichen lang sein.");
+ }
+
+ if (request.Email is { Length: > 200 })
+ {
+ return BadRequest("Email darf maximal 200 Zeichen lang sein.");
+ }
+
+ if (request.EntryDate is not null && request.ExitDate is not null && request.ExitDate < request.EntryDate)
+ {
+ return BadRequest("ExitDate darf nicht vor EntryDate liegen.");
+ }
+
+ var addressError = ValidateAddressFields(request.Street, request.PostalCode, request.City, request.Country);
+ if (addressError is not null)
+ {
+ return BadRequest(addressError);
+ }
+
+ var stammdatenError = ValidateStammdatenFields(
+ request.EmergencyContactName,
+ request.EmergencyContactPhone,
+ request.EmergencyContactRelation,
+ request.EmploymentType,
+ request.Qualification);
+ if (stammdatenError is not null)
+ {
+ return BadRequest(stammdatenError);
+ }
+
+ var employee = new Employee
+ {
+ FirstName = request.FirstName,
+ LastName = request.LastName,
+ DateOfBirth = request.DateOfBirth,
+ Street = request.Street,
+ PostalCode = request.PostalCode,
+ City = request.City,
+ Country = request.Country,
+ PhoneNumber = request.PhoneNumber,
+ Email = request.Email,
+ EntryDate = request.EntryDate,
+ ExitDate = request.ExitDate,
+ EmergencyContactName = request.EmergencyContactName,
+ EmergencyContactPhone = request.EmergencyContactPhone,
+ EmergencyContactRelation = request.EmergencyContactRelation,
+ EmploymentType = request.EmploymentType,
+ Qualification = request.Qualification
+ };
+
+ var created = await _employeeService.CreateAsync(employee, cancellationToken);
+ await _dispatcher.DispatchAsync(new EmployeeCreatedEvent(created.Id), cancellationToken);
+
+ return CreatedAtAction(nameof(GetById), new { id = created.Id }, ToResponse(created));
+ }
+
+ [HttpPut("{id:guid}")]
+ [RequirePermission(ModuleType.Employees, PermissionAction.Edit)]
+ public async Task> Update(Guid id, UpdateEmployeeRequest request, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(request.FirstName) || request.FirstName.Length > 200)
+ {
+ return BadRequest("FirstName ist erforderlich und darf maximal 200 Zeichen lang sein.");
+ }
+
+ if (string.IsNullOrWhiteSpace(request.LastName) || request.LastName.Length > 200)
+ {
+ return BadRequest("LastName ist erforderlich und darf maximal 200 Zeichen lang sein.");
+ }
+
+ if (string.IsNullOrWhiteSpace(request.Status) || request.Status.Length > 50)
+ {
+ return BadRequest("Status ist erforderlich und darf maximal 50 Zeichen lang sein.");
+ }
+
+ if (request.PhoneNumber is { Length: > 50 })
+ {
+ return BadRequest("PhoneNumber darf maximal 50 Zeichen lang sein.");
+ }
+
+ if (request.Email is { Length: > 200 })
+ {
+ return BadRequest("Email darf maximal 200 Zeichen lang sein.");
+ }
+
+ if (request.EntryDate is not null && request.ExitDate is not null && request.ExitDate < request.EntryDate)
+ {
+ return BadRequest("ExitDate darf nicht vor EntryDate liegen.");
+ }
+
+ var addressError = ValidateAddressFields(request.Street, request.PostalCode, request.City, request.Country);
+ if (addressError is not null)
+ {
+ return BadRequest(addressError);
+ }
+
+ var stammdatenError = ValidateStammdatenFields(
+ request.EmergencyContactName,
+ request.EmergencyContactPhone,
+ request.EmergencyContactRelation,
+ request.EmploymentType,
+ request.Qualification);
+ if (stammdatenError is not null)
+ {
+ return BadRequest(stammdatenError);
+ }
+
+ var updates = new Employee
+ {
+ FirstName = request.FirstName,
+ LastName = request.LastName,
+ Status = request.Status,
+ DateOfBirth = request.DateOfBirth,
+ Street = request.Street,
+ PostalCode = request.PostalCode,
+ City = request.City,
+ Country = request.Country,
+ PhoneNumber = request.PhoneNumber,
+ Email = request.Email,
+ EntryDate = request.EntryDate,
+ ExitDate = request.ExitDate,
+ EmergencyContactName = request.EmergencyContactName,
+ EmergencyContactPhone = request.EmergencyContactPhone,
+ EmergencyContactRelation = request.EmergencyContactRelation,
+ EmploymentType = request.EmploymentType,
+ Qualification = request.Qualification,
+ AvatarFileName = request.AvatarFileName
+ };
+
+ var updated = await _employeeService.UpdateAsync(id, updates, cancellationToken);
+ return updated is null ? NotFound() : Ok(ToResponse(updated));
+ }
+
+ private static string? ValidateAddressFields(string? street, string? postalCode, string? city, string? country)
+ {
+ if (street is { Length: > 200 })
+ {
+ return "Street darf maximal 200 Zeichen lang sein.";
+ }
+
+ if (postalCode is { Length: > 10 })
+ {
+ return "PostalCode darf maximal 10 Zeichen lang sein.";
+ }
+
+ if (city is { Length: > 100 })
+ {
+ return "City darf maximal 100 Zeichen lang sein.";
+ }
+
+ if (country is { Length: > 100 })
+ {
+ return "Country darf maximal 100 Zeichen lang sein.";
+ }
+
+ return null;
+ }
+
+ private static string? ValidateStammdatenFields(
+ string? emergencyContactName,
+ string? emergencyContactPhone,
+ string? emergencyContactRelation,
+ string? employmentType,
+ string? qualification)
+ {
+ if (emergencyContactName is { Length: > 200 })
+ {
+ return "EmergencyContactName darf maximal 200 Zeichen lang sein.";
+ }
+
+ if (emergencyContactPhone is { Length: > 50 })
+ {
+ return "EmergencyContactPhone darf maximal 50 Zeichen lang sein.";
+ }
+
+ if (emergencyContactRelation is { Length: > 100 })
+ {
+ return "EmergencyContactRelation darf maximal 100 Zeichen lang sein.";
+ }
+
+ if (employmentType is not null && !AllowedEmploymentTypes.Contains(employmentType))
+ {
+ return $"EmploymentType muss einer der folgenden Werte sein: {string.Join(", ", AllowedEmploymentTypes)}.";
+ }
+
+ if (qualification is { Length: > 500 })
+ {
+ return "Qualification darf maximal 500 Zeichen lang sein.";
+ }
+
+ return null;
+ }
+
+ private static EmployeeResponse ToResponse(Employee employee)
+ => new(
+ employee.Id,
+ employee.FirstName,
+ employee.LastName,
+ employee.Status,
+ employee.DateOfBirth,
+ employee.Street,
+ employee.PostalCode,
+ employee.City,
+ employee.Country,
+ employee.PhoneNumber,
+ employee.Email,
+ employee.EntryDate,
+ employee.ExitDate,
+ employee.EmergencyContactName,
+ employee.EmergencyContactPhone,
+ employee.EmergencyContactRelation,
+ employee.EmploymentType,
+ employee.Qualification,
+ employee.AvatarFileName);
+}
diff --git a/omsorgCore/src/OmsorgCore.Api/Controllers/HealthController.cs b/omsorgCore/src/OmsorgCore.Api/Controllers/HealthController.cs
new file mode 100644
index 0000000..09d3d06
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Controllers/HealthController.cs
@@ -0,0 +1,33 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using OmsorgCore.Infrastructure.Persistence;
+
+namespace OmsorgCore.Api.Controllers;
+
+[ApiController]
+[Route("api/health")]
+public class HealthController : ControllerBase
+{
+ private readonly OmsorgCoreDbContext _db;
+
+ public HealthController(OmsorgCoreDbContext db)
+ {
+ _db = db;
+ }
+
+ [HttpGet]
+ public async Task Get(CancellationToken cancellationToken)
+ {
+ bool databaseReachable;
+ try
+ {
+ databaseReachable = await _db.Database.CanConnectAsync(cancellationToken);
+ }
+ catch
+ {
+ databaseReachable = false;
+ }
+
+ return Ok(new { status = "ok", databaseReachable });
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Api/Controllers/RolesController.cs b/omsorgCore/src/OmsorgCore.Api/Controllers/RolesController.cs
new file mode 100644
index 0000000..b733744
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Controllers/RolesController.cs
@@ -0,0 +1,51 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using OmsorgCore.Api.Contracts;
+using OmsorgCore.Api.Security;
+using OmsorgCore.Application.Services;
+using OmsorgCore.Domain.Enums;
+
+namespace OmsorgCore.Api.Controllers;
+
+[ApiController]
+[Authorize]
+[Route("api/roles")]
+public class RolesController : ControllerBase
+{
+ private readonly IRoleService _roleService;
+
+ public RolesController(IRoleService roleService)
+ {
+ _roleService = roleService;
+ }
+
+ [HttpGet]
+ [RequirePermission(ModuleType.UserManagement, PermissionAction.View)]
+ public async Task>> GetAll(CancellationToken cancellationToken)
+ {
+ var roles = await _roleService.GetAllAsync(cancellationToken);
+ return Ok(roles.Select(r => new RoleResponse(r.Id, r.Name)).ToList());
+ }
+
+ [HttpPost]
+ [RequirePermission(ModuleType.UserManagement, PermissionAction.Create)]
+ public async Task> Create(CreateRoleRequest request, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 100)
+ {
+ return BadRequest("Name ist erforderlich und darf maximal 100 Zeichen lang sein.");
+ }
+
+ var result = await _roleService.CreateAsync(request.Name, cancellationToken);
+ if (!result.Success)
+ {
+ return result.FailureReason switch
+ {
+ CreateRoleFailureReason.NameTaken => Conflict("Diese Rolle existiert bereits."),
+ _ => BadRequest("Name ist erforderlich.")
+ };
+ }
+
+ return CreatedAtAction(nameof(GetAll), new RoleResponse(result.Role!.Id, result.Role.Name));
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Api/Controllers/UsersController.cs b/omsorgCore/src/OmsorgCore.Api/Controllers/UsersController.cs
new file mode 100644
index 0000000..a7af347
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Controllers/UsersController.cs
@@ -0,0 +1,167 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using OmsorgCore.Api.Contracts;
+using OmsorgCore.Api.Security;
+using OmsorgCore.Application.Abstractions;
+using OmsorgCore.Application.Services;
+using OmsorgCore.Domain.Enums;
+using OmsorgCore.Engine.Events;
+
+namespace OmsorgCore.Api.Controllers;
+
+[ApiController]
+[Authorize]
+[Route("api/users")]
+public class UsersController : ControllerBase
+{
+ private const int MinInitialPasswordLength = 8;
+ private const int DefaultPinValidityDays = 7;
+ private const int MinPinValidityDays = 1;
+ private const int MaxPinValidityDays = 30;
+
+ private readonly IUserService _userService;
+ private readonly IDomainEventDispatcher _dispatcher;
+
+ public UsersController(IUserService userService, IDomainEventDispatcher dispatcher)
+ {
+ _userService = userService;
+ _dispatcher = dispatcher;
+ }
+
+ [HttpGet]
+ [RequirePermission(ModuleType.UserManagement, PermissionAction.View)]
+ public async Task>> GetAll(CancellationToken cancellationToken)
+ {
+ var users = await _userService.GetAllAsync(cancellationToken);
+ return Ok(users.Select(ToResponse).ToList());
+ }
+
+ [HttpPost]
+ [RequirePermission(ModuleType.UserManagement, PermissionAction.Create)]
+ public async Task> Create(CreateUserRequest request, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(request.Username) || request.Username.Length > 100)
+ {
+ return BadRequest("Username ist erforderlich und darf maximal 100 Zeichen lang sein.");
+ }
+
+ var (parseError, mode, pinValidity) = ParseModeAndPinValidity(request.Mode, request.PinValidityDays, request.InitialPassword);
+ if (parseError is not null)
+ {
+ return BadRequest(parseError);
+ }
+
+ var result = await _userService.CreateForEmployeeAsync(
+ request.EmployeeId, request.Username, request.RoleId, mode,
+ request.InitialPassword, pinValidity, cancellationToken);
+
+ if (!result.Success)
+ {
+ return result.FailureReason switch
+ {
+ CreateUserFailureReason.EmployeeNotFound => NotFound("Mitarbeiter nicht gefunden."),
+ CreateUserFailureReason.RoleNotFound => NotFound("Rolle nicht gefunden."),
+ CreateUserFailureReason.EmployeeAlreadyHasAccount => Conflict("Dieser Mitarbeiter hat bereits ein Konto."),
+ CreateUserFailureReason.UsernameTaken => Conflict("Dieser Username ist bereits vergeben."),
+ CreateUserFailureReason.EmployeeEmailMissing => BadRequest("Für den Einladungs-Modus braucht der Mitarbeiter eine hinterlegte E-Mail-Adresse."),
+ CreateUserFailureReason.InitialPasswordRequired => BadRequest("InitialPassword ist im Direct-Modus erforderlich."),
+ _ => BadRequest()
+ };
+ }
+
+ if (mode == UserCreationMode.Invite && result.Email is not null && result.RawInvitePin is not null)
+ {
+ await _dispatcher.DispatchAsync(
+ new UserInvitedEvent(result.Email, result.RawInvitePin, (int)pinValidity!.Value.TotalDays),
+ cancellationToken);
+ }
+
+ return CreatedAtAction(nameof(GetAll), new UserResponse(
+ result.UserId!.Value, request.Username, request.EmployeeId,
+ result.RoleName!, IsActive: true, MustChangePassword: mode == UserCreationMode.Direct));
+ }
+
+ [HttpPost("{id:guid}/reset-password")]
+ [RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
+ public async Task ResetPassword(Guid id, ResetUserPasswordRequest request, CancellationToken cancellationToken)
+ {
+ var (parseError, mode, pinValidity) = ParseModeAndPinValidity(request.Mode, request.PinValidityDays, request.InitialPassword);
+ if (parseError is not null)
+ {
+ return BadRequest(parseError);
+ }
+
+ var result = await _userService.AdminResetPasswordAsync(id, mode, request.InitialPassword, pinValidity, cancellationToken);
+
+ if (!result.Success)
+ {
+ return result.FailureReason switch
+ {
+ AdminResetPasswordFailureReason.UserNotFound => NotFound("User nicht gefunden."),
+ AdminResetPasswordFailureReason.EmployeeEmailMissing => BadRequest("Für den Einladungs-Modus braucht der Mitarbeiter eine hinterlegte E-Mail-Adresse."),
+ AdminResetPasswordFailureReason.InitialPasswordRequired => BadRequest("InitialPassword ist im Direct-Modus erforderlich."),
+ _ => BadRequest()
+ };
+ }
+
+ if (mode == UserCreationMode.Invite && result.Email is not null && result.RawInvitePin is not null)
+ {
+ await _dispatcher.DispatchAsync(
+ new UserInvitedEvent(result.Email, result.RawInvitePin, (int)pinValidity!.Value.TotalDays),
+ cancellationToken);
+ }
+
+ return NoContent();
+ }
+
+ [HttpPut("{id:guid}")]
+ [RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
+ public async Task Update(Guid id, UpdateUserRequest request, CancellationToken cancellationToken)
+ {
+ var result = await _userService.UpdateAsync(id, request.RoleId, request.IsActive, cancellationToken);
+
+ if (!result.Success)
+ {
+ return result.FailureReason switch
+ {
+ UpdateUserFailureReason.UserNotFound => NotFound("User nicht gefunden."),
+ UpdateUserFailureReason.RoleNotFound => NotFound("Rolle nicht gefunden."),
+ _ => BadRequest()
+ };
+ }
+
+ return NoContent();
+ }
+
+ private static (string? Error, UserCreationMode Mode, TimeSpan? PinValidity) ParseModeAndPinValidity(
+ string modeRaw, int? pinValidityDays, string? initialPassword)
+ {
+ if (!Enum.TryParse(modeRaw, ignoreCase: true, out var mode))
+ {
+ return ("Mode muss 'Invite' oder 'Direct' sein.", default, null);
+ }
+
+ TimeSpan? pinValidity = null;
+ if (mode == UserCreationMode.Invite)
+ {
+ var days = pinValidityDays ?? DefaultPinValidityDays;
+ if (days < MinPinValidityDays || days > MaxPinValidityDays)
+ {
+ return ($"PinValidityDays muss zwischen {MinPinValidityDays} und {MaxPinValidityDays} liegen.", mode, null);
+ }
+
+ pinValidity = TimeSpan.FromDays(days);
+ }
+
+ if (mode == UserCreationMode.Direct
+ && (initialPassword is null || initialPassword.Length < MinInitialPasswordLength))
+ {
+ return ($"InitialPassword ist im Direct-Modus erforderlich und muss mindestens {MinInitialPasswordLength} Zeichen lang sein.", mode, null);
+ }
+
+ return (null, mode, pinValidity);
+ }
+
+ private static UserResponse ToResponse(UserSummary summary)
+ => new(summary.Id, summary.Username, summary.EmployeeId, summary.RoleName, summary.IsActive, summary.MustChangePassword);
+}
diff --git a/omsorgCore/src/OmsorgCore.Api/OmsorgCore.Api.csproj b/omsorgCore/src/OmsorgCore.Api/OmsorgCore.Api.csproj
new file mode 100644
index 0000000..9e2fc86
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/OmsorgCore.Api.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/omsorgCore/src/OmsorgCore.Api/OmsorgCore.Api.http b/omsorgCore/src/OmsorgCore.Api/OmsorgCore.Api.http
new file mode 100644
index 0000000..49c856d
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/OmsorgCore.Api.http
@@ -0,0 +1,6 @@
+@OmsorgCore.Api_HostAddress = http://localhost:5245
+
+GET {{OmsorgCore.Api_HostAddress}}/weatherforecast/
+Accept: application/json
+
+###
diff --git a/omsorgCore/src/OmsorgCore.Api/Program.cs b/omsorgCore/src/OmsorgCore.Api/Program.cs
new file mode 100644
index 0000000..9cbfcf0
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Program.cs
@@ -0,0 +1,161 @@
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+using System.Text;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
+using Microsoft.IdentityModel.Tokens;
+using Microsoft.OpenApi.Models;
+using OmsorgCore.Api.Security;
+using OmsorgCore.Application;
+using OmsorgCore.Application.Abstractions;
+using OmsorgCore.Email;
+using OmsorgCore.Engine;
+using OmsorgCore.Infrastructure;
+using OmsorgCore.Infrastructure.Persistence;
+using OmsorgCore.Infrastructure.Security;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddControllers();
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen(options =>
+{
+ options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
+ {
+ Name = "Authorization",
+ Type = SecuritySchemeType.Http,
+ Scheme = "Bearer",
+ BearerFormat = "JWT",
+ In = ParameterLocation.Header
+ });
+ options.AddSecurityRequirement(new OpenApiSecurityRequirement
+ {
+ {
+ new OpenApiSecurityScheme
+ {
+ Reference = new OpenApiReference
+ {
+ Type = ReferenceType.SecurityScheme,
+ Id = "Bearer"
+ }
+ },
+ Array.Empty()
+ }
+ });
+});
+
+// Schichten verdrahten: Domain kennt niemanden, Application kennt nur Domain,
+// Infrastructure implementiert Application-Interfaces, Engine ist die Event-Schicht
+// auf denselben Daten, Api verdrahtet alles nur hier.
+builder.Services.AddApplication();
+builder.Services.AddInfrastructure(builder.Configuration);
+builder.Services.AddEngine();
+builder.Services.AddEmail(builder.Configuration);
+
+builder.Services.AddHttpContextAccessor();
+builder.Services.AddScoped();
+
+var jwtOptions = builder.Configuration.GetSection(JwtOptions.SectionName).Get() ?? new JwtOptions();
+
+builder.Services
+ .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ .AddJwtBearer(options =>
+ {
+ options.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuer = true,
+ ValidateAudience = true,
+ ValidateLifetime = true,
+ ValidateIssuerSigningKey = true,
+ ValidIssuer = jwtOptions.Issuer,
+ ValidAudience = jwtOptions.Audience,
+ IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.Secret))
+ };
+
+ // Killswitch: ein JWT ist an sich zustandslos gültig bis zum Ablauf. Damit ein erzwungener
+ // Logout (SessionAdminService) SOFORT wirkt und nicht erst nach bis zu 60 Minuten, wird bei
+ // jedem Request der "sst"-Claim gegen den aktuellen User.SecurityStamp in der DB geprüft.
+ options.Events = new JwtBearerEvents
+ {
+ OnTokenValidated = async context =>
+ {
+ var userIdClaim = context.Principal?.FindFirstValue(JwtRegisteredClaimNames.Sub)
+ ?? context.Principal?.FindFirstValue(ClaimTypes.NameIdentifier);
+ var stampClaim = context.Principal?.FindFirstValue("sst");
+
+ if (!Guid.TryParse(userIdClaim, out var userId) || string.IsNullOrEmpty(stampClaim))
+ {
+ context.Fail("Token ohne gültige Benutzer-/Security-Stamp-Claims.");
+ return;
+ }
+
+ var userRepository = context.HttpContext.RequestServices.GetRequiredService();
+ var user = await userRepository.GetByIdAsync(userId);
+
+ if (user is null || !user.IsActive || user.SecurityStamp.ToString() != stampClaim)
+ {
+ context.Fail("Session wurde widerrufen.");
+ return;
+ }
+
+ // Erzwingt einen Passwortwechsel serverseitig (nicht nur im UI versteckt) - solange
+ // MustChangePassword gesetzt ist (Admin hat ein initiales Passwort direkt vergeben),
+ // sind nur noch change-password/logout/refresh/me erlaubt. Frischer DB-Wert statt
+ // Claim, damit ein erfolgreicher Wechsel sofort wirkt und nicht erst nach Token-Ablauf.
+ if (user.MustChangePassword)
+ {
+ var path = context.HttpContext.Request.Path;
+ var isAllowlisted = path.StartsWithSegments("/api/auth/change-password")
+ || path.StartsWithSegments("/api/auth/logout")
+ || path.StartsWithSegments("/api/auth/refresh")
+ || path.StartsWithSegments("/api/auth/me");
+
+ if (!isAllowlisted)
+ {
+ context.Fail("Passwort muss vor weiteren Aktionen geändert werden.");
+ }
+ }
+ }
+ };
+ });
+
+builder.Services.AddAuthorization();
+
+var app = builder.Build();
+
+// Wendet beim Start ausstehende Migrationen an - in jeder Umgebung, nicht nur Development,
+// damit ein Deploy/Update nie mit einem Schema laufen kann, das hinter dem Code zurückliegt.
+using (var migrationScope = app.Services.CreateScope())
+{
+ var db = migrationScope.ServiceProvider.GetRequiredService();
+ await db.Database.MigrateAsync();
+}
+
+if (app.Environment.IsDevelopment())
+{
+ app.UseSwagger();
+ app.UseSwaggerUI();
+
+ try
+ {
+ using var seedScope = app.Services.CreateScope();
+ var db = seedScope.ServiceProvider.GetRequiredService();
+ var passwordHasher = seedScope.ServiceProvider.GetRequiredService();
+ var seedOptions = seedScope.ServiceProvider.GetRequiredService>().Value;
+ await DbSeeder.SeedDefaultAdminAsync(db, passwordHasher, seedOptions);
+ }
+ catch (Exception ex)
+ {
+ app.Logger.LogWarning(ex, "Seed des Standard-Admin-Users fehlgeschlagen (z.B. Datenbank nicht erreichbar).");
+ }
+}
+
+app.UseHttpsRedirection();
+
+app.UseAuthentication();
+app.UseAuthorization();
+
+app.MapControllers();
+
+app.Run();
diff --git a/omsorgCore/src/OmsorgCore.Api/Properties/launchSettings.json b/omsorgCore/src/OmsorgCore.Api/Properties/launchSettings.json
new file mode 100644
index 0000000..605cdf2
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Properties/launchSettings.json
@@ -0,0 +1,41 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:35930",
+ "sslPort": 44310
+ }
+ },
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "applicationUrl": "http://localhost:5245",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "applicationUrl": "https://localhost:7268;http://localhost:5245",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Api/Security/CurrentUserService.cs b/omsorgCore/src/OmsorgCore.Api/Security/CurrentUserService.cs
new file mode 100644
index 0000000..a796a35
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Security/CurrentUserService.cs
@@ -0,0 +1,27 @@
+using System.Security.Claims;
+using OmsorgCore.Application.Abstractions;
+
+namespace OmsorgCore.Api.Security;
+
+public class CurrentUserService : ICurrentUserService
+{
+ private readonly IHttpContextAccessor _httpContextAccessor;
+
+ public CurrentUserService(IHttpContextAccessor httpContextAccessor)
+ {
+ _httpContextAccessor = httpContextAccessor;
+ }
+
+ public bool IsAuthenticated => _httpContextAccessor.HttpContext?.User.Identity?.IsAuthenticated ?? false;
+
+ public Guid? UserId
+ {
+ get
+ {
+ var value = _httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
+ ?? _httpContextAccessor.HttpContext?.User.FindFirstValue("sub");
+
+ return Guid.TryParse(value, out var id) ? id : null;
+ }
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Api/Security/RequirePermissionAttribute.cs b/omsorgCore/src/OmsorgCore.Api/Security/RequirePermissionAttribute.cs
new file mode 100644
index 0000000..d00c6cc
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/Security/RequirePermissionAttribute.cs
@@ -0,0 +1,41 @@
+using Microsoft.AspNetCore.Mvc.Filters;
+using OmsorgCore.Application.Abstractions;
+using OmsorgCore.Domain.Enums;
+
+namespace OmsorgCore.Api.Security;
+
+///
+/// Erzwingt die Rechteprüfung auf Endpunkt-Ebene serverseitig, zusätzlich zur Prüfung auf
+/// Datenebene in den Application-Services (REQUIREMENTS.md NFR-9).
+/// Verwendung: [RequirePermission(ModuleType.Employees, PermissionAction.Create)]
+///
+[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
+public class RequirePermissionAttribute : Attribute, IAsyncAuthorizationFilter
+{
+ private readonly ModuleType _module;
+ private readonly PermissionAction _action;
+
+ public RequirePermissionAttribute(ModuleType module, PermissionAction action)
+ {
+ _module = module;
+ _action = action;
+ }
+
+ public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
+ {
+ var currentUser = context.HttpContext.RequestServices.GetRequiredService();
+ if (!currentUser.IsAuthenticated || currentUser.UserId is null)
+ {
+ context.Result = new Microsoft.AspNetCore.Mvc.UnauthorizedResult();
+ return;
+ }
+
+ var permissionService = context.HttpContext.RequestServices.GetRequiredService();
+ var allowed = await permissionService.HasPermissionAsync(currentUser.UserId.Value, _module, _action);
+
+ if (!allowed)
+ {
+ context.Result = new Microsoft.AspNetCore.Mvc.ForbidResult();
+ }
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Api/appsettings.json b/omsorgCore/src/OmsorgCore.Api/appsettings.json
new file mode 100644
index 0000000..de720a9
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Api/appsettings.json
@@ -0,0 +1,57 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "Jwt": {
+ "Issuer": "OmsorgCore",
+ "Audience": "OmsorgClients",
+ "ExpiryMinutes": 60
+ },
+ "RefreshToken": {
+ "ExpiryDays": 60
+ },
+ "Auth": {
+ "MaxLoginFailures": 5,
+ "LoginLockoutMinutes": 10
+ },
+ "Email": {
+ "PinExpiryMinutes": 5,
+ "MaxAttempts": 3,
+ "ResetTokenExpiryMinutes": 10,
+ "RequestCooldownSeconds": 60,
+ "Provider": "Console",
+ "PasswordResetAccount": "PasswordReset",
+ "UserInviteAccount": "UserInvite",
+ "Smtp": {
+ "Host": "",
+ "Port": 587,
+ "EnableSsl": true
+ },
+ "Accounts": {
+ "PasswordReset": {
+ "Username": "",
+ "Password": "",
+ "SenderAddress": "",
+ "SenderDisplayName": "OMSORG"
+ },
+ "UserInvite": {
+ "Username": "",
+ "Password": "",
+ "SenderAddress": "",
+ "SenderDisplayName": "OMSORG"
+ }
+ },
+ "PasswordResetTemplate": {
+ "Subject": "Ihr OMSORG Passwort-Reset-Code",
+ "BodyTemplate": "Ihr Code lautet: $RESET_PIN$\nDer Code ist $RESET_PIN_EXPIRY_MINUTES$ Minuten gültig."
+ },
+ "UserInviteTemplate": {
+ "Subject": "Ihr OMSORG-Zugang",
+ "BodyTemplate": "Für Sie wurde ein OMSORG-Konto angelegt.\nIhr Einladungscode lautet: $INVITE_PIN$\nDer Code ist $INVITE_PIN_EXPIRY_DAYS$ Tage gültig. Damit legen Sie beim ersten Login Ihr eigenes Passwort fest."
+ }
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/EmailMessage.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/EmailMessage.cs
new file mode 100644
index 0000000..e861454
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/EmailMessage.cs
@@ -0,0 +1,8 @@
+namespace OmsorgCore.Application.Abstractions;
+
+///
+/// wählt einen der konfigurierten Mail-Accounts (Username/Passwort/
+/// Absenderadresse) aus, z. B. den dedizierten Passwort-Reset-Account - der SMTP-Server selbst ist
+/// gemeinsam konfiguriert, nur die Accounts unterscheiden sich. null = Default-Account.
+///
+public record EmailMessage(string ToAddress, string Subject, string PlainTextBody, string? FromAccountKey = null);
diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/ICurrentUserService.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/ICurrentUserService.cs
new file mode 100644
index 0000000..727b611
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/ICurrentUserService.cs
@@ -0,0 +1,12 @@
+namespace OmsorgCore.Application.Abstractions;
+
+///
+/// Liest die Identität des aktuell angemeldeten Benutzers aus dem Request-Kontext.
+/// Implementierung lebt in der Api-Schicht (dort ist HttpContext bekannt), Application/Engine
+/// kennen nur dieses Interface.
+///
+public interface ICurrentUserService
+{
+ Guid? UserId { get; }
+ bool IsAuthenticated { get; }
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/IEmailSender.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/IEmailSender.cs
new file mode 100644
index 0000000..3bef9a8
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/IEmailSender.cs
@@ -0,0 +1,6 @@
+namespace OmsorgCore.Application.Abstractions;
+
+public interface IEmailSender
+{
+ Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default);
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/IEmployeeRepository.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/IEmployeeRepository.cs
new file mode 100644
index 0000000..cb04dd4
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/IEmployeeRepository.cs
@@ -0,0 +1,19 @@
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Application.Abstractions;
+
+public interface IEmployeeRepository
+{
+ Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
+ Task> GetAllAsync(CancellationToken cancellationToken = default);
+ Task<(IReadOnlyList Items, int TotalCount)> GetPagedAsync(
+ string? search,
+ string? status,
+ string? employmentType,
+ int page,
+ int pageSize,
+ CancellationToken cancellationToken = default);
+ Task AddAsync(Employee employee, CancellationToken cancellationToken = default);
+ Task UpdateAsync(Employee employee, CancellationToken cancellationToken = default);
+ Task SaveChangesAsync(CancellationToken cancellationToken = default);
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/IJwtTokenGenerator.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/IJwtTokenGenerator.cs
new file mode 100644
index 0000000..41a0aff
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/IJwtTokenGenerator.cs
@@ -0,0 +1,8 @@
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Application.Abstractions;
+
+public interface IJwtTokenGenerator
+{
+ (string Token, DateTime ExpiresAt) GenerateToken(User user);
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/ILoginAttemptRepository.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/ILoginAttemptRepository.cs
new file mode 100644
index 0000000..5e70cce
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/ILoginAttemptRepository.cs
@@ -0,0 +1,16 @@
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Application.Abstractions;
+
+public interface ILoginAttemptRepository
+{
+ /// Anzahl fehlgeschlagener Versuche dieser IP seit .
+ Task CountRecentFailuresAsync(string ipAddress, DateTime since, CancellationToken cancellationToken = default);
+
+ Task AddAsync(LoginAttempt attempt, CancellationToken cancellationToken = default);
+
+ /// Löscht alle bisherigen Fehlversuche dieser IP - nach einem erfolgreichen Login.
+ Task ClearFailuresAsync(string ipAddress, CancellationToken cancellationToken = default);
+
+ Task SaveChangesAsync(CancellationToken cancellationToken = default);
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/ILoginLockoutPolicy.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/ILoginLockoutPolicy.cs
new file mode 100644
index 0000000..9c4fd4c
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/ILoginLockoutPolicy.cs
@@ -0,0 +1,8 @@
+namespace OmsorgCore.Application.Abstractions;
+
+/// Konfigurierte Schwellwerte für die IP-basierte Login-Sperre (siehe AuthService.LoginAsync).
+public interface ILoginLockoutPolicy
+{
+ int MaxFailures { get; }
+ TimeSpan LockoutWindow { get; }
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/IPasswordHasher.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/IPasswordHasher.cs
new file mode 100644
index 0000000..ca18438
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/IPasswordHasher.cs
@@ -0,0 +1,7 @@
+namespace OmsorgCore.Application.Abstractions;
+
+public interface IPasswordHasher
+{
+ string Hash(string plainPassword);
+ bool Verify(string plainPassword, string hash);
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/IPasswordResetCodeGenerator.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/IPasswordResetCodeGenerator.cs
new file mode 100644
index 0000000..bf7813e
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/IPasswordResetCodeGenerator.cs
@@ -0,0 +1,23 @@
+namespace OmsorgCore.Application.Abstractions;
+
+public interface IPasswordResetCodeGenerator
+{
+ /// Mindestabstand zwischen zwei Code-Anfragen desselben Users (minimaler Spam-Schutz).
+ TimeSpan RequestCooldown { get; }
+
+ /// Maximale Anzahl Fehlversuche bei der PIN-Eingabe, bevor der Code invalidiert wird.
+ int MaxAttempts { get; }
+
+ /// Erzeugt einen neuen 6-stelligen PIN, dessen Hash zur Speicherung und sein Ablaufdatum.
+ (string RawPin, string PinHash, DateTime ExpiresAt) GeneratePin();
+
+ /// Wie , aber mit explizit vorgegebener Gültigkeitsdauer statt der konfigurierten (z. B. für Account-Einladungen mit mehrtägiger Gültigkeit).
+ (string RawPin, string PinHash, DateTime ExpiresAt) GeneratePin(TimeSpan validity);
+
+ /// Erzeugt einen neuen Reset-Token, dessen Hash zur Speicherung und sein Ablaufdatum.
+ (string RawResetToken, string ResetTokenHash, DateTime ExpiresAt) GenerateResetToken();
+
+ string HashPin(string rawPin);
+
+ string HashResetToken(string rawResetToken);
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/IPasswordResetCodeRepository.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/IPasswordResetCodeRepository.cs
new file mode 100644
index 0000000..9b4541e
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/IPasswordResetCodeRepository.cs
@@ -0,0 +1,21 @@
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Application.Abstractions;
+
+public interface IPasswordResetCodeRepository
+{
+ Task AddAsync(PasswordResetCode code, CancellationToken cancellationToken = default);
+
+ /// Aktivster (nicht widerrufener, nicht abgelaufener, noch nicht konsumierter, Versuche unter maxAttempts) Code eines Users, falls vorhanden.
+ Task GetActiveByUserIdAsync(Guid userId, int maxAttempts, CancellationToken cancellationToken = default);
+
+ /// Der jüngste Code eines Users unabhängig vom Status - für den Anfrage-Cooldown.
+ Task GetLatestByUserIdAsync(Guid userId, CancellationToken cancellationToken = default);
+
+ Task GetByResetTokenHashAsync(string resetTokenHash, CancellationToken cancellationToken = default);
+
+ /// Entwertet alle noch aktiven Codes eines Users (z. B. bei einer neuen Anfrage - immer nur ein gültiger Code).
+ Task InvalidateActiveForUserAsync(Guid userId, CancellationToken cancellationToken = default);
+
+ Task SaveChangesAsync(CancellationToken cancellationToken = default);
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/IPasswordResetEmailComposer.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/IPasswordResetEmailComposer.cs
new file mode 100644
index 0000000..8517957
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/IPasswordResetEmailComposer.cs
@@ -0,0 +1,11 @@
+namespace OmsorgCore.Application.Abstractions;
+
+///
+/// Baut die fertige Passwort-Reset-Mail (Betreff+Text) aus dem PIN. Implementierung liegt in
+/// OmsorgCore.Email (kennt die dortige Template-Konfiguration) - Engine kennt nur dieses Interface,
+/// damit die Abhängigkeitsrichtung (Engine -> Application, nicht -> Email) gewahrt bleibt.
+///
+public interface IPasswordResetEmailComposer
+{
+ EmailMessage Compose(string toEmail, string rawPin);
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/IPermissionService.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/IPermissionService.cs
new file mode 100644
index 0000000..d1146ca
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/IPermissionService.cs
@@ -0,0 +1,17 @@
+using OmsorgCore.Application.Models;
+using OmsorgCore.Domain.Enums;
+
+namespace OmsorgCore.Application.Abstractions;
+
+///
+/// Zentrale Rechteprüfung: Rollen-Default kombiniert mit individuellen Overrides des Benutzers.
+/// Wird sowohl vom API-Authorization-Attribut als auch von Business-Services genutzt, damit
+/// Rechte auf Daten- UND Funktionsebene geprüft werden (REQUIREMENTS.md NFR-9).
+///
+public interface IPermissionService
+{
+ Task HasPermissionAsync(Guid userId, ModuleType module, PermissionAction action, CancellationToken cancellationToken = default);
+
+ /// Alle tatsächlich gewährten Modul/Aktion-Kombinationen dieses Users (Rollen-Default + Overrides aufgelöst).
+ Task> GetGrantedPermissionsAsync(Guid userId, CancellationToken cancellationToken = default);
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/IRefreshTokenGenerator.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/IRefreshTokenGenerator.cs
new file mode 100644
index 0000000..092192b
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/IRefreshTokenGenerator.cs
@@ -0,0 +1,10 @@
+namespace OmsorgCore.Application.Abstractions;
+
+public interface IRefreshTokenGenerator
+{
+ /// Erzeugt einen neuen zufälligen Rohtoken, dessen Hash zur Speicherung und sein Ablaufdatum.
+ (string RawToken, string TokenHash, DateTime ExpiresAt) GenerateToken();
+
+ /// Hasht einen vom Client vorgelegten Rohtoken zum Nachschlagen in der DB.
+ string HashToken(string rawToken);
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/IRefreshTokenRepository.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/IRefreshTokenRepository.cs
new file mode 100644
index 0000000..736869c
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/IRefreshTokenRepository.cs
@@ -0,0 +1,20 @@
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Application.Abstractions;
+
+public interface IRefreshTokenRepository
+{
+ Task AddAsync(RefreshToken token, CancellationToken cancellationToken = default);
+ Task GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken = default);
+
+ /// Für die Admin-Sessions-Ansicht - dort ist nur die Session-Id bekannt, nicht der Rohtoken.
+ Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
+
+ /// Alle nicht widerrufenen, nicht abgelaufenen Refresh-Tokens (inkl. User) für die Sessions-Liste.
+ Task> GetAllActiveAsync(CancellationToken cancellationToken = default);
+
+ /// Alle aktiven Refresh-Tokens eines bestimmten Users (für die Ein-Session-Regel beim Login).
+ Task> GetActiveByUserIdAsync(Guid userId, CancellationToken cancellationToken = default);
+
+ Task SaveChangesAsync(CancellationToken cancellationToken = default);
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/IRoleRepository.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/IRoleRepository.cs
new file mode 100644
index 0000000..024b8b1
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/IRoleRepository.cs
@@ -0,0 +1,12 @@
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Application.Abstractions;
+
+public interface IRoleRepository
+{
+ Task> GetAllAsync(CancellationToken cancellationToken = default);
+ Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
+ Task ExistsByNameAsync(string name, CancellationToken cancellationToken = default);
+ Task AddAsync(Role role, CancellationToken cancellationToken = default);
+ Task SaveChangesAsync(CancellationToken cancellationToken = default);
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/IUserInviteEmailComposer.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/IUserInviteEmailComposer.cs
new file mode 100644
index 0000000..cf224c5
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/IUserInviteEmailComposer.cs
@@ -0,0 +1,10 @@
+namespace OmsorgCore.Application.Abstractions;
+
+///
+/// Baut die fertige Einladungsmail (Betreff+Text) aus dem Invite-PIN. Implementierung liegt in
+/// OmsorgCore.Email (analog IPasswordResetEmailComposer) - Engine kennt nur dieses Interface.
+///
+public interface IUserInviteEmailComposer
+{
+ EmailMessage Compose(string toEmail, string rawPin, int validityDays);
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/IUserRepository.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/IUserRepository.cs
new file mode 100644
index 0000000..1502690
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/IUserRepository.cs
@@ -0,0 +1,27 @@
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Application.Abstractions;
+
+public interface IUserRepository
+{
+ Task GetByUsernameAsync(string username, CancellationToken cancellationToken = default);
+
+ /// Inklusive Role+RolePermissions+PermissionOverrides (für die Rechteauflösung) und Employee (für /api/auth/me).
+ Task GetByIdWithPermissionsAsync(Guid id, CancellationToken cancellationToken = default);
+
+ /// Schlanker Lookup ohne Includes - für den SecurityStamp-Check bei jedem Request.
+ Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
+
+ /// Inklusive Employee (für die E-Mail-Adresse) und Role (für den Rollennamen in Ergebnissen) - für Admin-Aktionen auf einem bestehenden User.
+ Task GetByIdWithEmployeeAsync(Guid id, CancellationToken cancellationToken = default);
+
+ Task> GetAllAsync(CancellationToken cancellationToken = default);
+
+ Task GetByEmployeeIdAsync(Guid employeeId, CancellationToken cancellationToken = default);
+
+ Task ExistsByUsernameAsync(string username, CancellationToken cancellationToken = default);
+
+ Task AddAsync(User user, CancellationToken cancellationToken = default);
+
+ Task SaveChangesAsync(CancellationToken cancellationToken = default);
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/DependencyInjection.cs b/omsorgCore/src/OmsorgCore.Application/DependencyInjection.cs
new file mode 100644
index 0000000..a41b5f1
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/DependencyInjection.cs
@@ -0,0 +1,21 @@
+using Microsoft.Extensions.DependencyInjection;
+using OmsorgCore.Application.Abstractions;
+using OmsorgCore.Application.Services;
+
+namespace OmsorgCore.Application;
+
+public static class DependencyInjection
+{
+ public static IServiceCollection AddApplication(this IServiceCollection services)
+ {
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ return services;
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Models/PermissionGrant.cs b/omsorgCore/src/OmsorgCore.Application/Models/PermissionGrant.cs
new file mode 100644
index 0000000..7a15a99
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Models/PermissionGrant.cs
@@ -0,0 +1,6 @@
+using OmsorgCore.Domain.Enums;
+
+namespace OmsorgCore.Application.Models;
+
+/// Eine für einen User tatsächlich gewährte Modul/Aktion-Kombination (Ergebnis der Rechte-Auflösung).
+public record PermissionGrant(ModuleType Module, PermissionAction Action);
diff --git a/omsorgCore/src/OmsorgCore.Application/Models/UserProfile.cs b/omsorgCore/src/OmsorgCore.Application/Models/UserProfile.cs
new file mode 100644
index 0000000..9406cb1
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Models/UserProfile.cs
@@ -0,0 +1,19 @@
+namespace OmsorgCore.Application.Models;
+
+///
+/// Profil des eingeloggten Users samt aufgelöster Rechte, für den `/api/auth/me`-Endpunkt. Trägt
+/// zusätzlich die verknüpften Employee-Basisdaten (falls vorhanden) - JWT und Login-Response tragen
+/// nur Username/Rolle, Clients brauchen für "angemeldet als ..."-Anzeigen aber Name/E-Mail/Avatar,
+/// ohne dafür einen separaten Employee-Request zu brauchen (siehe omsorgWeb/mitarbeiter-app).
+///
+public record UserProfile(
+ Guid UserId,
+ string Username,
+ string RoleName,
+ IReadOnlyList Permissions,
+ Guid? EmployeeId,
+ string? FirstName,
+ string? LastName,
+ string? Email,
+ string? PhoneNumber,
+ string? AvatarFileName);
diff --git a/omsorgCore/src/OmsorgCore.Application/OmsorgCore.Application.csproj b/omsorgCore/src/OmsorgCore.Application/OmsorgCore.Application.csproj
new file mode 100644
index 0000000..e15c016
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/OmsorgCore.Application.csproj
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
diff --git a/omsorgCore/src/OmsorgCore.Application/Services/AdminResetPasswordResult.cs b/omsorgCore/src/OmsorgCore.Application/Services/AdminResetPasswordResult.cs
new file mode 100644
index 0000000..102fe30
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Services/AdminResetPasswordResult.cs
@@ -0,0 +1,31 @@
+namespace OmsorgCore.Application.Services;
+
+public enum AdminResetPasswordFailureReason
+{
+ UserNotFound,
+ EmployeeEmailMissing,
+ InitialPasswordRequired
+}
+
+public class AdminResetPasswordResult
+{
+ public bool Success { get; init; }
+ public AdminResetPasswordFailureReason? FailureReason { get; init; }
+
+ /// Nur im Invite-Modus gesetzt - für den Versand der Einladungsmail.
+ public string? Email { get; init; }
+ public string? RawInvitePin { get; init; }
+
+ public static AdminResetPasswordResult Fail(AdminResetPasswordFailureReason reason) => new()
+ {
+ Success = false,
+ FailureReason = reason
+ };
+
+ public static AdminResetPasswordResult Ok(string? email = null, string? rawInvitePin = null) => new()
+ {
+ Success = true,
+ Email = email,
+ RawInvitePin = rawInvitePin
+ };
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Services/AuthResult.cs b/omsorgCore/src/OmsorgCore.Application/Services/AuthResult.cs
new file mode 100644
index 0000000..2c66eb5
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Services/AuthResult.cs
@@ -0,0 +1,24 @@
+namespace OmsorgCore.Application.Services;
+
+public class AuthResult
+{
+ public bool Success { get; init; }
+ public string? Token { get; init; }
+ public string? RefreshToken { get; init; }
+ public DateTime? ExpiresAt { get; init; }
+ public bool MustChangePassword { get; init; }
+ public bool IsLockedOut { get; init; }
+
+ public static AuthResult Fail() => new() { Success = false };
+
+ public static AuthResult LockedOut() => new() { Success = false, IsLockedOut = true };
+
+ public static AuthResult Ok(string token, string refreshToken, DateTime expiresAt, bool mustChangePassword) => new()
+ {
+ Success = true,
+ Token = token,
+ RefreshToken = refreshToken,
+ ExpiresAt = expiresAt,
+ MustChangePassword = mustChangePassword
+ };
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Services/AuthService.cs b/omsorgCore/src/OmsorgCore.Application/Services/AuthService.cs
new file mode 100644
index 0000000..d2d6db3
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Services/AuthService.cs
@@ -0,0 +1,155 @@
+using OmsorgCore.Application.Abstractions;
+using OmsorgCore.Application.Models;
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Application.Services;
+
+public class AuthService : IAuthService
+{
+ private readonly IUserRepository _userRepository;
+ private readonly IRefreshTokenRepository _refreshTokenRepository;
+ private readonly IPasswordHasher _passwordHasher;
+ private readonly IJwtTokenGenerator _tokenGenerator;
+ private readonly IRefreshTokenGenerator _refreshTokenGenerator;
+ private readonly IPermissionService _permissionService;
+ private readonly ILoginAttemptRepository _loginAttemptRepository;
+ private readonly ILoginLockoutPolicy _loginLockoutPolicy;
+
+ public AuthService(
+ IUserRepository userRepository,
+ IRefreshTokenRepository refreshTokenRepository,
+ IPasswordHasher passwordHasher,
+ IJwtTokenGenerator tokenGenerator,
+ IRefreshTokenGenerator refreshTokenGenerator,
+ IPermissionService permissionService,
+ ILoginAttemptRepository loginAttemptRepository,
+ ILoginLockoutPolicy loginLockoutPolicy)
+ {
+ _userRepository = userRepository;
+ _refreshTokenRepository = refreshTokenRepository;
+ _passwordHasher = passwordHasher;
+ _tokenGenerator = tokenGenerator;
+ _refreshTokenGenerator = refreshTokenGenerator;
+ _permissionService = permissionService;
+ _loginAttemptRepository = loginAttemptRepository;
+ _loginLockoutPolicy = loginLockoutPolicy;
+ }
+
+ public async Task LoginAsync(string username, string password, string ipAddress, CancellationToken cancellationToken = default)
+ {
+ var lockoutSince = DateTime.UtcNow - _loginLockoutPolicy.LockoutWindow;
+ var recentFailures = await _loginAttemptRepository.CountRecentFailuresAsync(ipAddress, lockoutSince, cancellationToken);
+ if (recentFailures >= _loginLockoutPolicy.MaxFailures)
+ {
+ return AuthResult.LockedOut();
+ }
+
+ var user = await _userRepository.GetByUsernameAsync(username, cancellationToken);
+ if (user is null || !user.IsActive || !_passwordHasher.Verify(password, user.PasswordHash))
+ {
+ await _loginAttemptRepository.AddAsync(new LoginAttempt
+ {
+ IpAddress = ipAddress,
+ Username = username,
+ Succeeded = false
+ }, cancellationToken);
+ await _loginAttemptRepository.SaveChangesAsync(cancellationToken);
+
+ return AuthResult.Fail();
+ }
+
+ await _loginAttemptRepository.ClearFailuresAsync(ipAddress, cancellationToken);
+ await _loginAttemptRepository.SaveChangesAsync(cancellationToken);
+
+ await EndOtherSessionsAsync(user, cancellationToken);
+
+ return await IssueTokenPairAsync(user, cancellationToken);
+ }
+
+ // Erzwingt "ein User = eine aktive Session": widerruft alle bisherigen Refresh-Tokens dieses
+ // Users und würfelt den SecurityStamp neu, damit auch bereits ausgestellte Access-Tokens
+ // anderer Sessions sofort ungültig werden (derselbe Killswitch-Mechanismus wie SessionAdminService).
+ private async Task EndOtherSessionsAsync(User user, CancellationToken cancellationToken)
+ {
+ var activeSessions = await _refreshTokenRepository.GetActiveByUserIdAsync(user.Id, cancellationToken);
+ foreach (var session in activeSessions)
+ {
+ session.RevokedAt = DateTime.UtcNow;
+ }
+
+ user.SecurityStamp = Guid.NewGuid();
+ }
+
+ public async Task RefreshAsync(string refreshToken, CancellationToken cancellationToken = default)
+ {
+ var tokenHash = _refreshTokenGenerator.HashToken(refreshToken);
+ var existing = await _refreshTokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
+ if (existing is null || !existing.IsActive)
+ {
+ return AuthResult.Fail();
+ }
+
+ existing.RevokedAt = DateTime.UtcNow;
+
+ return await IssueTokenPairAsync(existing.User, cancellationToken, replaces: existing);
+ }
+
+ public async Task RevokeAsync(string refreshToken, CancellationToken cancellationToken = default)
+ {
+ var tokenHash = _refreshTokenGenerator.HashToken(refreshToken);
+ var existing = await _refreshTokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
+ if (existing is null || existing.RevokedAt is not null)
+ {
+ return;
+ }
+
+ existing.RevokedAt = DateTime.UtcNow;
+ await _refreshTokenRepository.SaveChangesAsync(cancellationToken);
+ }
+
+ public async Task GetProfileAsync(Guid userId, CancellationToken cancellationToken = default)
+ {
+ var user = await _userRepository.GetByIdWithPermissionsAsync(userId, cancellationToken);
+ if (user is null || !user.IsActive)
+ {
+ return null;
+ }
+
+ var permissions = await _permissionService.GetGrantedPermissionsAsync(userId, cancellationToken);
+ return new UserProfile(
+ user.Id,
+ user.Username,
+ user.Role.Name,
+ permissions,
+ user.EmployeeId,
+ user.Employee?.FirstName,
+ user.Employee?.LastName,
+ user.Employee?.Email,
+ user.Employee?.PhoneNumber,
+ user.Employee?.AvatarFileName);
+ }
+
+ private async Task IssueTokenPairAsync(User user, CancellationToken cancellationToken, RefreshToken? replaces = null)
+ {
+ var (accessToken, accessTokenExpiresAt) = _tokenGenerator.GenerateToken(user);
+ var (rawRefreshToken, refreshTokenHash, refreshTokenExpiresAt) = _refreshTokenGenerator.GenerateToken();
+
+ var newToken = new RefreshToken
+ {
+ UserId = user.Id,
+ User = user,
+ TokenHash = refreshTokenHash,
+ ExpiresAt = refreshTokenExpiresAt
+ };
+ await _refreshTokenRepository.AddAsync(newToken, cancellationToken);
+
+ if (replaces is not null)
+ {
+ replaces.ReplacedByTokenId = newToken.Id;
+ }
+
+ await _refreshTokenRepository.SaveChangesAsync(cancellationToken);
+
+ return AuthResult.Ok(accessToken, rawRefreshToken, accessTokenExpiresAt, user.MustChangePassword);
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Services/CreateRoleResult.cs b/omsorgCore/src/OmsorgCore.Application/Services/CreateRoleResult.cs
new file mode 100644
index 0000000..9b1f4d4
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Services/CreateRoleResult.cs
@@ -0,0 +1,28 @@
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Application.Services;
+
+public enum CreateRoleFailureReason
+{
+ NameRequired,
+ NameTaken
+}
+
+public class CreateRoleResult
+{
+ public bool Success { get; init; }
+ public CreateRoleFailureReason? FailureReason { get; init; }
+ public Role? Role { get; init; }
+
+ public static CreateRoleResult Fail(CreateRoleFailureReason reason) => new()
+ {
+ Success = false,
+ FailureReason = reason
+ };
+
+ public static CreateRoleResult Ok(Role role) => new()
+ {
+ Success = true,
+ Role = role
+ };
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Services/CreateUserResult.cs b/omsorgCore/src/OmsorgCore.Application/Services/CreateUserResult.cs
new file mode 100644
index 0000000..b718eda
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Services/CreateUserResult.cs
@@ -0,0 +1,38 @@
+namespace OmsorgCore.Application.Services;
+
+public enum CreateUserFailureReason
+{
+ EmployeeNotFound,
+ EmployeeAlreadyHasAccount,
+ EmployeeEmailMissing,
+ UsernameTaken,
+ RoleNotFound,
+ InitialPasswordRequired
+}
+
+public class CreateUserResult
+{
+ public bool Success { get; init; }
+ public CreateUserFailureReason? FailureReason { get; init; }
+ public Guid? UserId { get; init; }
+ public string? RoleName { get; init; }
+
+ /// Nur im Invite-Modus gesetzt - für den Versand der Einladungsmail.
+ public string? Email { get; init; }
+ public string? RawInvitePin { get; init; }
+
+ public static CreateUserResult Fail(CreateUserFailureReason reason) => new()
+ {
+ Success = false,
+ FailureReason = reason
+ };
+
+ public static CreateUserResult Ok(Guid userId, string roleName, string? email = null, string? rawInvitePin = null) => new()
+ {
+ Success = true,
+ UserId = userId,
+ RoleName = roleName,
+ Email = email,
+ RawInvitePin = rawInvitePin
+ };
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Services/EmployeeService.cs b/omsorgCore/src/OmsorgCore.Application/Services/EmployeeService.cs
new file mode 100644
index 0000000..9c6f20f
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Services/EmployeeService.cs
@@ -0,0 +1,69 @@
+using OmsorgCore.Application.Abstractions;
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Application.Services;
+
+public class EmployeeService : IEmployeeService
+{
+ private readonly IEmployeeRepository _employeeRepository;
+
+ public EmployeeService(IEmployeeRepository employeeRepository)
+ {
+ _employeeRepository = employeeRepository;
+ }
+
+ public Task> GetAllAsync(CancellationToken cancellationToken = default)
+ => _employeeRepository.GetAllAsync(cancellationToken);
+
+ public Task<(IReadOnlyList Items, int TotalCount)> GetPagedAsync(
+ string? search,
+ string? status,
+ string? employmentType,
+ int page,
+ int pageSize,
+ CancellationToken cancellationToken = default)
+ => _employeeRepository.GetPagedAsync(search, status, employmentType, page, pageSize, cancellationToken);
+
+ public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
+ => _employeeRepository.GetByIdAsync(id, cancellationToken);
+
+ public async Task CreateAsync(Employee employee, CancellationToken cancellationToken = default)
+ {
+ await _employeeRepository.AddAsync(employee, cancellationToken);
+ await _employeeRepository.SaveChangesAsync(cancellationToken);
+ return employee;
+ }
+
+ public async Task UpdateAsync(Guid id, Employee updates, CancellationToken cancellationToken = default)
+ {
+ var employee = await _employeeRepository.GetByIdAsync(id, cancellationToken);
+ if (employee is null)
+ {
+ return null;
+ }
+
+ employee.FirstName = updates.FirstName;
+ employee.LastName = updates.LastName;
+ employee.DateOfBirth = updates.DateOfBirth;
+ employee.Street = updates.Street;
+ employee.PostalCode = updates.PostalCode;
+ employee.City = updates.City;
+ employee.Country = updates.Country;
+ employee.PhoneNumber = updates.PhoneNumber;
+ employee.Email = updates.Email;
+ employee.EntryDate = updates.EntryDate;
+ employee.ExitDate = updates.ExitDate;
+ employee.Status = updates.Status;
+ employee.EmergencyContactName = updates.EmergencyContactName;
+ employee.EmergencyContactPhone = updates.EmergencyContactPhone;
+ employee.EmergencyContactRelation = updates.EmergencyContactRelation;
+ employee.EmploymentType = updates.EmploymentType;
+ employee.Qualification = updates.Qualification;
+ employee.AvatarFileName = updates.AvatarFileName;
+ employee.UpdatedAt = DateTime.UtcNow;
+
+ await _employeeRepository.UpdateAsync(employee, cancellationToken);
+ await _employeeRepository.SaveChangesAsync(cancellationToken);
+ return employee;
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Services/IAuthService.cs b/omsorgCore/src/OmsorgCore.Application/Services/IAuthService.cs
new file mode 100644
index 0000000..cea4b4d
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Services/IAuthService.cs
@@ -0,0 +1,17 @@
+using OmsorgCore.Application.Models;
+
+namespace OmsorgCore.Application.Services;
+
+public interface IAuthService
+{
+ Task LoginAsync(string username, string password, string ipAddress, CancellationToken cancellationToken = default);
+
+ /// Löst den vorgelegten Refresh-Token ein: widerruft ihn und stellt ein neues Token-Paar aus (Rotation).
+ Task RefreshAsync(string refreshToken, CancellationToken cancellationToken = default);
+
+ /// Widerruft einen Refresh-Token (Logout). Idempotent, kein Fehler bei unbekanntem/bereits widerrufenem Token.
+ Task RevokeAsync(string refreshToken, CancellationToken cancellationToken = default);
+
+ /// Profil (Username, Rolle, aufgelöste Rechte) des eingeloggten Users, für `/api/auth/me`.
+ Task GetProfileAsync(Guid userId, CancellationToken cancellationToken = default);
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Services/IEmployeeService.cs b/omsorgCore/src/OmsorgCore.Application/Services/IEmployeeService.cs
new file mode 100644
index 0000000..84fc905
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Services/IEmployeeService.cs
@@ -0,0 +1,18 @@
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Application.Services;
+
+public interface IEmployeeService
+{
+ Task> GetAllAsync(CancellationToken cancellationToken = default);
+ Task<(IReadOnlyList Items, int TotalCount)> GetPagedAsync(
+ string? search,
+ string? status,
+ string? employmentType,
+ int page,
+ int pageSize,
+ CancellationToken cancellationToken = default);
+ Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
+ Task CreateAsync(Employee employee, CancellationToken cancellationToken = default);
+ Task UpdateAsync(Guid id, Employee updates, CancellationToken cancellationToken = default);
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Services/IPasswordResetService.cs b/omsorgCore/src/OmsorgCore.Application/Services/IPasswordResetService.cs
new file mode 100644
index 0000000..c71cfd8
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Services/IPasswordResetService.cs
@@ -0,0 +1,17 @@
+namespace OmsorgCore.Application.Services;
+
+public interface IPasswordResetService
+{
+ Task RequestResetAsync(string username, CancellationToken cancellationToken = default);
+
+ Task VerifyCodeAsync(string username, string pin, CancellationToken cancellationToken = default);
+
+ Task ResetPasswordAsync(string resetToken, string newPassword, CancellationToken cancellationToken = default);
+
+ ///
+ /// Stellt einen PIN für eine Account-Einladung aus (admin-ausgelöst, kein Self-Service-Request des Users
+ /// selbst) - läuft durch dieselbe PasswordResetCode-Tabelle wie RequestResetAsync, aber ohne Cooldown-Prüfung
+ /// und mit einer explizit vorgegebenen, i. d. R. mehrtägigen Gültigkeit statt der kurzen Reset-PIN-Gültigkeit.
+ ///
+ Task IssueInviteCodeAsync(Guid userId, TimeSpan validity, CancellationToken cancellationToken = default);
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Services/IRoleService.cs b/omsorgCore/src/OmsorgCore.Application/Services/IRoleService.cs
new file mode 100644
index 0000000..1e89678
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Services/IRoleService.cs
@@ -0,0 +1,9 @@
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Application.Services;
+
+public interface IRoleService
+{
+ Task> GetAllAsync(CancellationToken cancellationToken = default);
+ Task CreateAsync(string name, CancellationToken cancellationToken = default);
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Services/ISessionAdminService.cs b/omsorgCore/src/OmsorgCore.Application/Services/ISessionAdminService.cs
new file mode 100644
index 0000000..a030929
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Services/ISessionAdminService.cs
@@ -0,0 +1,12 @@
+namespace OmsorgCore.Application.Services;
+
+public interface ISessionAdminService
+{
+ Task> GetActiveSessionsAsync(CancellationToken cancellationToken = default);
+
+ /// Widerruft genau diese Session und erzwingt für ihren Nutzer sofort einen neuen Login.
+ Task RevokeSessionAsync(Guid sessionId, CancellationToken cancellationToken = default);
+
+ /// Widerruft alle aktiven Sessions und erzwingt für ALLE Nutzer sofort einen neuen Login.
+ Task RevokeAllSessionsAsync(CancellationToken cancellationToken = default);
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Services/IUserService.cs b/omsorgCore/src/OmsorgCore.Application/Services/IUserService.cs
new file mode 100644
index 0000000..ed63a04
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Services/IUserService.cs
@@ -0,0 +1,36 @@
+namespace OmsorgCore.Application.Services;
+
+public interface IUserService
+{
+ Task> GetAllAsync(CancellationToken cancellationToken = default);
+
+ Task CreateForEmployeeAsync(
+ Guid employeeId,
+ string username,
+ Guid roleId,
+ UserCreationMode mode,
+ string? initialPassword,
+ TimeSpan? invitePinValidity,
+ CancellationToken cancellationToken = default);
+
+ Task ChangeOwnPasswordAsync(
+ Guid userId,
+ string currentPassword,
+ string newPassword,
+ CancellationToken cancellationToken = default);
+
+ /// Admin setzt das Passwort eines bestehenden Users zurück (Invite-PIN oder direktes Passwort) - spiegelt CreateForEmployeeAsync, aber ohne Neuanlage.
+ Task AdminResetPasswordAsync(
+ Guid userId,
+ UserCreationMode mode,
+ string? initialPassword,
+ TimeSpan? invitePinValidity,
+ CancellationToken cancellationToken = default);
+
+ /// Rolle und/oder Aktiv-Status ändern. Deaktivieren beendet sofort alle Sessions (kein Hard-Delete).
+ Task UpdateAsync(
+ Guid userId,
+ Guid roleId,
+ bool isActive,
+ CancellationToken cancellationToken = default);
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Services/PasswordResetRequestResult.cs b/omsorgCore/src/OmsorgCore.Application/Services/PasswordResetRequestResult.cs
new file mode 100644
index 0000000..2ac017f
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Services/PasswordResetRequestResult.cs
@@ -0,0 +1,27 @@
+namespace OmsorgCore.Application.Services;
+
+public enum PasswordResetRequestStatus
+{
+ Sent,
+ CannotReset
+}
+
+public class PasswordResetRequestResult
+{
+ public PasswordResetRequestStatus Status { get; init; }
+ public Guid? UserId { get; init; }
+ public string? Email { get; init; }
+
+ /// Nur gesetzt, wenn tatsächlich ein neuer Code erzeugt wurde und dispatcht werden soll.
+ public string? RawPin { get; init; }
+
+ public static PasswordResetRequestResult CannotReset() => new() { Status = PasswordResetRequestStatus.CannotReset };
+
+ public static PasswordResetRequestResult Sent(Guid userId, string email, string? rawPin) => new()
+ {
+ Status = PasswordResetRequestStatus.Sent,
+ UserId = userId,
+ Email = email,
+ RawPin = rawPin
+ };
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Services/PasswordResetService.cs b/omsorgCore/src/OmsorgCore.Application/Services/PasswordResetService.cs
new file mode 100644
index 0000000..fd50497
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Services/PasswordResetService.cs
@@ -0,0 +1,139 @@
+using OmsorgCore.Application.Abstractions;
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Application.Services;
+
+public class PasswordResetService : IPasswordResetService
+{
+ private readonly IUserRepository _userRepository;
+ private readonly IPasswordResetCodeRepository _codeRepository;
+ private readonly IPasswordResetCodeGenerator _codeGenerator;
+ private readonly IPasswordHasher _passwordHasher;
+ private readonly IRefreshTokenRepository _refreshTokenRepository;
+
+ public PasswordResetService(
+ IUserRepository userRepository,
+ IPasswordResetCodeRepository codeRepository,
+ IPasswordResetCodeGenerator codeGenerator,
+ IPasswordHasher passwordHasher,
+ IRefreshTokenRepository refreshTokenRepository)
+ {
+ _userRepository = userRepository;
+ _codeRepository = codeRepository;
+ _codeGenerator = codeGenerator;
+ _passwordHasher = passwordHasher;
+ _refreshTokenRepository = refreshTokenRepository;
+ }
+
+ public async Task RequestResetAsync(string username, CancellationToken cancellationToken = default)
+ {
+ var user = await _userRepository.GetByUsernameAsync(username, cancellationToken);
+ var email = user?.Employee?.Email;
+ if (user is null || !user.IsActive || string.IsNullOrWhiteSpace(email))
+ {
+ return PasswordResetRequestResult.CannotReset();
+ }
+
+ var latest = await _codeRepository.GetLatestByUserIdAsync(user.Id, cancellationToken);
+ if (latest is not null && DateTime.UtcNow - latest.CreatedAt < _codeGenerator.RequestCooldown)
+ {
+ // Cooldown aktiv: kein neuer Code, kein erneuter Versand, aber nach außen unauffällig -
+ // der Client bekommt weiterhin "Sent" ohne dass tatsächlich etwas verschickt wird.
+ return PasswordResetRequestResult.Sent(user.Id, email, rawPin: null);
+ }
+
+ await _codeRepository.InvalidateActiveForUserAsync(user.Id, cancellationToken);
+
+ var (rawPin, pinHash, expiresAt) = _codeGenerator.GeneratePin();
+ await _codeRepository.AddAsync(new PasswordResetCode
+ {
+ UserId = user.Id,
+ User = user,
+ CodeHash = pinHash,
+ ExpiresAt = expiresAt
+ }, cancellationToken);
+ await _codeRepository.SaveChangesAsync(cancellationToken);
+
+ return PasswordResetRequestResult.Sent(user.Id, email, rawPin);
+ }
+
+ public async Task VerifyCodeAsync(string username, string pin, CancellationToken cancellationToken = default)
+ {
+ var user = await _userRepository.GetByUsernameAsync(username, cancellationToken);
+ if (user is null)
+ {
+ return PasswordResetVerifyResult.Fail("invalid_or_expired");
+ }
+
+ var code = await _codeRepository.GetActiveByUserIdAsync(user.Id, _codeGenerator.MaxAttempts, cancellationToken);
+ if (code is null)
+ {
+ return PasswordResetVerifyResult.Fail("invalid_or_expired");
+ }
+
+ var pinHash = _codeGenerator.HashPin(pin);
+ if (code.CodeHash != pinHash)
+ {
+ code.AttemptCount++;
+ if (code.AttemptCount >= _codeGenerator.MaxAttempts)
+ {
+ code.InvalidatedAt = DateTime.UtcNow;
+ }
+ await _codeRepository.SaveChangesAsync(cancellationToken);
+
+ return code.AttemptCount >= _codeGenerator.MaxAttempts
+ ? PasswordResetVerifyResult.Fail("too_many_attempts")
+ : PasswordResetVerifyResult.Fail("invalid_or_expired");
+ }
+
+ code.ConsumedAt = DateTime.UtcNow;
+ var (rawResetToken, resetTokenHash, resetExpiresAt) = _codeGenerator.GenerateResetToken();
+ code.ResetTokenHash = resetTokenHash;
+ code.ResetTokenExpiresAt = resetExpiresAt;
+ await _codeRepository.SaveChangesAsync(cancellationToken);
+
+ return PasswordResetVerifyResult.Ok(rawResetToken);
+ }
+
+ public async Task IssueInviteCodeAsync(Guid userId, TimeSpan validity, CancellationToken cancellationToken = default)
+ {
+ await _codeRepository.InvalidateActiveForUserAsync(userId, cancellationToken);
+
+ var (rawPin, pinHash, expiresAt) = _codeGenerator.GeneratePin(validity);
+ await _codeRepository.AddAsync(new PasswordResetCode
+ {
+ UserId = userId,
+ CodeHash = pinHash,
+ ExpiresAt = expiresAt
+ }, cancellationToken);
+ await _codeRepository.SaveChangesAsync(cancellationToken);
+
+ return rawPin;
+ }
+
+ public async Task ResetPasswordAsync(string resetToken, string newPassword, CancellationToken cancellationToken = default)
+ {
+ var resetTokenHash = _codeGenerator.HashResetToken(resetToken);
+ var code = await _codeRepository.GetByResetTokenHashAsync(resetTokenHash, cancellationToken);
+ if (code is null || !code.IsResetTokenActive)
+ {
+ return false;
+ }
+
+ code.ResetTokenUsedAt = DateTime.UtcNow;
+ code.User.PasswordHash = _passwordHasher.Hash(newPassword);
+
+ // Wie nach einem normalen Login: alle bestehenden Sessions beenden, SecurityStamp erneuern -
+ // ein Passwort-Reset darf alte, evtl. kompromittierte Sessions nicht überleben lassen.
+ var activeSessions = await _refreshTokenRepository.GetActiveByUserIdAsync(code.UserId, cancellationToken);
+ foreach (var session in activeSessions)
+ {
+ session.RevokedAt = DateTime.UtcNow;
+ }
+ code.User.SecurityStamp = Guid.NewGuid();
+
+ await _codeRepository.SaveChangesAsync(cancellationToken);
+ await _refreshTokenRepository.SaveChangesAsync(cancellationToken);
+ return true;
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Services/PasswordResetVerifyResult.cs b/omsorgCore/src/OmsorgCore.Application/Services/PasswordResetVerifyResult.cs
new file mode 100644
index 0000000..c49f9ec
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Services/PasswordResetVerifyResult.cs
@@ -0,0 +1,14 @@
+namespace OmsorgCore.Application.Services;
+
+public class PasswordResetVerifyResult
+{
+ public bool Success { get; init; }
+ public string? ResetToken { get; init; }
+
+ /// "invalid_or_expired" | "too_many_attempts"
+ public string? FailureReason { get; init; }
+
+ public static PasswordResetVerifyResult Fail(string reason) => new() { Success = false, FailureReason = reason };
+
+ public static PasswordResetVerifyResult Ok(string resetToken) => new() { Success = true, ResetToken = resetToken };
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Services/PermissionService.cs b/omsorgCore/src/OmsorgCore.Application/Services/PermissionService.cs
new file mode 100644
index 0000000..89934a0
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Services/PermissionService.cs
@@ -0,0 +1,67 @@
+using OmsorgCore.Application.Abstractions;
+using OmsorgCore.Application.Models;
+using OmsorgCore.Domain.Entities;
+using OmsorgCore.Domain.Enums;
+
+namespace OmsorgCore.Application.Services;
+
+///
+/// Implementiert die Regel aus REQUIREMENTS.md Abschnitt 3/7 und Blueprint 6.5:
+/// Rollen-Default gilt, ein individueller Override (Grant oder Revoke) gewinnt immer.
+///
+public class PermissionService : IPermissionService
+{
+ private readonly IUserRepository _userRepository;
+
+ public PermissionService(IUserRepository userRepository)
+ {
+ _userRepository = userRepository;
+ }
+
+ public async Task HasPermissionAsync(Guid userId, ModuleType module, PermissionAction action, CancellationToken cancellationToken = default)
+ {
+ User? user = await _userRepository.GetByIdWithPermissionsAsync(userId, cancellationToken);
+ if (user is null || !user.IsActive)
+ {
+ return false;
+ }
+
+ return IsGranted(user, module, action);
+ }
+
+ public async Task> GetGrantedPermissionsAsync(Guid userId, CancellationToken cancellationToken = default)
+ {
+ User? user = await _userRepository.GetByIdWithPermissionsAsync(userId, cancellationToken);
+ if (user is null || !user.IsActive)
+ {
+ return Array.Empty();
+ }
+
+ var grants = new List();
+ foreach (ModuleType module in Enum.GetValues())
+ {
+ foreach (PermissionAction action in Enum.GetValues())
+ {
+ if (IsGranted(user, module, action))
+ {
+ grants.Add(new PermissionGrant(module, action));
+ }
+ }
+ }
+
+ return grants;
+ }
+
+ private static bool IsGranted(User user, ModuleType module, PermissionAction action)
+ {
+ UserPermissionOverride? override_ = user.PermissionOverrides
+ .FirstOrDefault(o => o.Module == module && o.Action == action);
+
+ if (override_ is not null)
+ {
+ return override_.Effect == PermissionEffect.Grant;
+ }
+
+ return user.Role.RolePermissions.Any(rp => rp.Module == module && rp.Action == action);
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Services/RoleService.cs b/omsorgCore/src/OmsorgCore.Application/Services/RoleService.cs
new file mode 100644
index 0000000..30d4aec
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Services/RoleService.cs
@@ -0,0 +1,36 @@
+using OmsorgCore.Application.Abstractions;
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Application.Services;
+
+public class RoleService : IRoleService
+{
+ private readonly IRoleRepository _roleRepository;
+
+ public RoleService(IRoleRepository roleRepository)
+ {
+ _roleRepository = roleRepository;
+ }
+
+ public Task> GetAllAsync(CancellationToken cancellationToken = default)
+ => _roleRepository.GetAllAsync(cancellationToken);
+
+ public async Task CreateAsync(string name, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ return CreateRoleResult.Fail(CreateRoleFailureReason.NameRequired);
+ }
+
+ if (await _roleRepository.ExistsByNameAsync(name, cancellationToken))
+ {
+ return CreateRoleResult.Fail(CreateRoleFailureReason.NameTaken);
+ }
+
+ var role = new Role { Name = name };
+ await _roleRepository.AddAsync(role, cancellationToken);
+ await _roleRepository.SaveChangesAsync(cancellationToken);
+
+ return CreateRoleResult.Ok(role);
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Services/SessionAdminService.cs b/omsorgCore/src/OmsorgCore.Application/Services/SessionAdminService.cs
new file mode 100644
index 0000000..2f3a862
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Services/SessionAdminService.cs
@@ -0,0 +1,54 @@
+using OmsorgCore.Application.Abstractions;
+
+namespace OmsorgCore.Application.Services;
+
+public class SessionAdminService : ISessionAdminService
+{
+ private readonly IRefreshTokenRepository _refreshTokenRepository;
+ private readonly IUserRepository _userRepository;
+
+ public SessionAdminService(IRefreshTokenRepository refreshTokenRepository, IUserRepository userRepository)
+ {
+ _refreshTokenRepository = refreshTokenRepository;
+ _userRepository = userRepository;
+ }
+
+ public async Task> GetActiveSessionsAsync(CancellationToken cancellationToken = default)
+ {
+ var tokens = await _refreshTokenRepository.GetAllActiveAsync(cancellationToken);
+ return tokens
+ .Select(t => new SessionInfo(t.Id, t.User.Username, t.CreatedAt, t.ExpiresAt))
+ .ToList();
+ }
+
+ public async Task RevokeSessionAsync(Guid sessionId, CancellationToken cancellationToken = default)
+ {
+ var token = await _refreshTokenRepository.GetByIdAsync(sessionId, cancellationToken);
+ if (token is null || token.RevokedAt is not null)
+ {
+ return;
+ }
+
+ token.RevokedAt = DateTime.UtcNow;
+ token.User.SecurityStamp = Guid.NewGuid();
+
+ await _refreshTokenRepository.SaveChangesAsync(cancellationToken);
+ }
+
+ public async Task RevokeAllSessionsAsync(CancellationToken cancellationToken = default)
+ {
+ var activeTokens = await _refreshTokenRepository.GetAllActiveAsync(cancellationToken);
+ foreach (var token in activeTokens)
+ {
+ token.RevokedAt = DateTime.UtcNow;
+ }
+
+ var users = await _userRepository.GetAllAsync(cancellationToken);
+ foreach (var user in users)
+ {
+ user.SecurityStamp = Guid.NewGuid();
+ }
+
+ await _userRepository.SaveChangesAsync(cancellationToken);
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Services/SessionInfo.cs b/omsorgCore/src/OmsorgCore.Application/Services/SessionInfo.cs
new file mode 100644
index 0000000..08a7b82
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Services/SessionInfo.cs
@@ -0,0 +1,3 @@
+namespace OmsorgCore.Application.Services;
+
+public record SessionInfo(Guid Id, string Username, DateTime CreatedAt, DateTime ExpiresAt);
diff --git a/omsorgCore/src/OmsorgCore.Application/Services/UpdateUserResult.cs b/omsorgCore/src/OmsorgCore.Application/Services/UpdateUserResult.cs
new file mode 100644
index 0000000..d15c9c9
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Services/UpdateUserResult.cs
@@ -0,0 +1,21 @@
+namespace OmsorgCore.Application.Services;
+
+public enum UpdateUserFailureReason
+{
+ UserNotFound,
+ RoleNotFound
+}
+
+public class UpdateUserResult
+{
+ public bool Success { get; init; }
+ public UpdateUserFailureReason? FailureReason { get; init; }
+
+ public static UpdateUserResult Fail(UpdateUserFailureReason reason) => new()
+ {
+ Success = false,
+ FailureReason = reason
+ };
+
+ public static UpdateUserResult Ok() => new() { Success = true };
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Services/UserCreationMode.cs b/omsorgCore/src/OmsorgCore.Application/Services/UserCreationMode.cs
new file mode 100644
index 0000000..4f9c56b
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Services/UserCreationMode.cs
@@ -0,0 +1,10 @@
+namespace OmsorgCore.Application.Services;
+
+public enum UserCreationMode
+{
+ /// Mitarbeiter bekommt eine Einladungsmail mit PIN und setzt sein Passwort selbst - der Admin kennt es nie.
+ Invite,
+
+ /// Admin vergibt ein initiales Passwort direkt (z. B. ohne hinterlegte E-Mail) - MustChangePassword erzwingt einen Wechsel beim ersten Login.
+ Direct
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Services/UserService.cs b/omsorgCore/src/OmsorgCore.Application/Services/UserService.cs
new file mode 100644
index 0000000..0a7520a
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Services/UserService.cs
@@ -0,0 +1,222 @@
+using OmsorgCore.Application.Abstractions;
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Application.Services;
+
+public class UserService : IUserService
+{
+ private readonly IUserRepository _userRepository;
+ private readonly IEmployeeRepository _employeeRepository;
+ private readonly IRoleRepository _roleRepository;
+ private readonly IPasswordHasher _passwordHasher;
+ private readonly IPasswordResetService _passwordResetService;
+ private readonly IRefreshTokenRepository _refreshTokenRepository;
+
+ public UserService(
+ IUserRepository userRepository,
+ IEmployeeRepository employeeRepository,
+ IRoleRepository roleRepository,
+ IPasswordHasher passwordHasher,
+ IPasswordResetService passwordResetService,
+ IRefreshTokenRepository refreshTokenRepository)
+ {
+ _userRepository = userRepository;
+ _employeeRepository = employeeRepository;
+ _roleRepository = roleRepository;
+ _passwordHasher = passwordHasher;
+ _passwordResetService = passwordResetService;
+ _refreshTokenRepository = refreshTokenRepository;
+ }
+
+ public async Task> GetAllAsync(CancellationToken cancellationToken = default)
+ {
+ var users = await _userRepository.GetAllAsync(cancellationToken);
+ return users
+ .Select(u => new UserSummary(u.Id, u.Username, u.EmployeeId, u.Role.Name, u.IsActive, u.MustChangePassword))
+ .ToList();
+ }
+
+ public async Task CreateForEmployeeAsync(
+ Guid employeeId,
+ string username,
+ Guid roleId,
+ UserCreationMode mode,
+ string? initialPassword,
+ TimeSpan? invitePinValidity,
+ CancellationToken cancellationToken = default)
+ {
+ var employee = await _employeeRepository.GetByIdAsync(employeeId, cancellationToken);
+ if (employee is null)
+ {
+ return CreateUserResult.Fail(CreateUserFailureReason.EmployeeNotFound);
+ }
+
+ var existingForEmployee = await _userRepository.GetByEmployeeIdAsync(employeeId, cancellationToken);
+ if (existingForEmployee is not null)
+ {
+ return CreateUserResult.Fail(CreateUserFailureReason.EmployeeAlreadyHasAccount);
+ }
+
+ if (await _userRepository.ExistsByUsernameAsync(username, cancellationToken))
+ {
+ return CreateUserResult.Fail(CreateUserFailureReason.UsernameTaken);
+ }
+
+ var role = await _roleRepository.GetByIdAsync(roleId, cancellationToken);
+ if (role is null)
+ {
+ return CreateUserResult.Fail(CreateUserFailureReason.RoleNotFound);
+ }
+
+ if (mode == UserCreationMode.Direct && string.IsNullOrWhiteSpace(initialPassword))
+ {
+ return CreateUserResult.Fail(CreateUserFailureReason.InitialPasswordRequired);
+ }
+
+ if (mode == UserCreationMode.Invite && string.IsNullOrWhiteSpace(employee.Email))
+ {
+ return CreateUserResult.Fail(CreateUserFailureReason.EmployeeEmailMissing);
+ }
+
+ var user = new User
+ {
+ Username = username,
+ RoleId = roleId,
+ EmployeeId = employeeId,
+ // Invite-Modus: kein für den Login nutzbares Passwort - Hash eines Zufallswerts, den niemand
+ // je eingibt. Der Mitarbeiter setzt sein eigenes Passwort über den unten ausgestellten PIN-Flow.
+ PasswordHash = mode == UserCreationMode.Direct
+ ? _passwordHasher.Hash(initialPassword!)
+ : _passwordHasher.Hash(Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N")),
+ MustChangePassword = mode == UserCreationMode.Direct
+ };
+
+ await _userRepository.AddAsync(user, cancellationToken);
+ await _userRepository.SaveChangesAsync(cancellationToken);
+
+ if (mode == UserCreationMode.Invite)
+ {
+ var validity = invitePinValidity ?? TimeSpan.FromDays(7);
+ var rawPin = await _passwordResetService.IssueInviteCodeAsync(user.Id, validity, cancellationToken);
+ return CreateUserResult.Ok(user.Id, role.Name, employee.Email, rawPin);
+ }
+
+ return CreateUserResult.Ok(user.Id, role.Name);
+ }
+
+ public async Task ChangeOwnPasswordAsync(
+ Guid userId,
+ string currentPassword,
+ string newPassword,
+ CancellationToken cancellationToken = default)
+ {
+ var user = await _userRepository.GetByIdAsync(userId, cancellationToken);
+ if (user is null || !_passwordHasher.Verify(currentPassword, user.PasswordHash))
+ {
+ return false;
+ }
+
+ user.PasswordHash = _passwordHasher.Hash(newPassword);
+ user.MustChangePassword = false;
+
+ // Wie nach einem Passwort-Reset: bestehende Sessions beenden, SecurityStamp erneuern - der
+ // gerade benutzte Access-Token wird dadurch ab dem nächsten Request ungültig, ein neuer Login
+ // mit dem neuen Passwort ist danach nötig (siehe PasswordResetService.ResetPasswordAsync).
+ var activeSessions = await _refreshTokenRepository.GetActiveByUserIdAsync(userId, cancellationToken);
+ foreach (var session in activeSessions)
+ {
+ session.RevokedAt = DateTime.UtcNow;
+ }
+ user.SecurityStamp = Guid.NewGuid();
+
+ await _userRepository.SaveChangesAsync(cancellationToken);
+ await _refreshTokenRepository.SaveChangesAsync(cancellationToken);
+ return true;
+ }
+
+ public async Task AdminResetPasswordAsync(
+ Guid userId,
+ UserCreationMode mode,
+ string? initialPassword,
+ TimeSpan? invitePinValidity,
+ CancellationToken cancellationToken = default)
+ {
+ var user = await _userRepository.GetByIdWithEmployeeAsync(userId, cancellationToken);
+ if (user is null)
+ {
+ return AdminResetPasswordResult.Fail(AdminResetPasswordFailureReason.UserNotFound);
+ }
+
+ if (mode == UserCreationMode.Direct && string.IsNullOrWhiteSpace(initialPassword))
+ {
+ return AdminResetPasswordResult.Fail(AdminResetPasswordFailureReason.InitialPasswordRequired);
+ }
+
+ if (mode == UserCreationMode.Invite && string.IsNullOrWhiteSpace(user.Employee?.Email))
+ {
+ return AdminResetPasswordResult.Fail(AdminResetPasswordFailureReason.EmployeeEmailMissing);
+ }
+
+ if (mode == UserCreationMode.Direct)
+ {
+ user.PasswordHash = _passwordHasher.Hash(initialPassword!);
+ user.MustChangePassword = true;
+
+ // Admin-gesetztes Passwort ersetzt sofort alle laufenden Sessions - wie ein regulärer
+ // Passwort-Reset (siehe PasswordResetService.ResetPasswordAsync).
+ var activeSessions = await _refreshTokenRepository.GetActiveByUserIdAsync(userId, cancellationToken);
+ foreach (var session in activeSessions)
+ {
+ session.RevokedAt = DateTime.UtcNow;
+ }
+ user.SecurityStamp = Guid.NewGuid();
+
+ await _userRepository.SaveChangesAsync(cancellationToken);
+ await _refreshTokenRepository.SaveChangesAsync(cancellationToken);
+ return AdminResetPasswordResult.Ok();
+ }
+
+ var validity = invitePinValidity ?? TimeSpan.FromDays(7);
+ var rawPin = await _passwordResetService.IssueInviteCodeAsync(user.Id, validity, cancellationToken);
+ return AdminResetPasswordResult.Ok(user.Employee!.Email, rawPin);
+ }
+
+ public async Task UpdateAsync(
+ Guid userId,
+ Guid roleId,
+ bool isActive,
+ CancellationToken cancellationToken = default)
+ {
+ var user = await _userRepository.GetByIdAsync(userId, cancellationToken);
+ if (user is null)
+ {
+ return UpdateUserResult.Fail(UpdateUserFailureReason.UserNotFound);
+ }
+
+ var role = await _roleRepository.GetByIdAsync(roleId, cancellationToken);
+ if (role is null)
+ {
+ return UpdateUserResult.Fail(UpdateUserFailureReason.RoleNotFound);
+ }
+
+ user.RoleId = roleId;
+
+ var wasActive = user.IsActive;
+ user.IsActive = isActive;
+
+ if (wasActive && !isActive)
+ {
+ // Sofortiger Killswitch bei Deaktivierung - kein Warten auf Token-Ablauf (siehe SessionAdminService).
+ var activeSessions = await _refreshTokenRepository.GetActiveByUserIdAsync(userId, cancellationToken);
+ foreach (var session in activeSessions)
+ {
+ session.RevokedAt = DateTime.UtcNow;
+ }
+ user.SecurityStamp = Guid.NewGuid();
+ await _refreshTokenRepository.SaveChangesAsync(cancellationToken);
+ }
+
+ await _userRepository.SaveChangesAsync(cancellationToken);
+ return UpdateUserResult.Ok();
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Application/Services/UserSummary.cs b/omsorgCore/src/OmsorgCore.Application/Services/UserSummary.cs
new file mode 100644
index 0000000..8ab3a67
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Application/Services/UserSummary.cs
@@ -0,0 +1,4 @@
+namespace OmsorgCore.Application.Services;
+
+/// Für die Account-Übersicht (Mitarbeiter mit/ohne Login-Konto) - siehe UsersController.
+public record UserSummary(Guid Id, string Username, Guid? EmployeeId, string RoleName, bool IsActive, bool MustChangePassword);
diff --git a/omsorgCore/src/OmsorgCore.Domain/Common/AuditableEntity.cs b/omsorgCore/src/OmsorgCore.Domain/Common/AuditableEntity.cs
new file mode 100644
index 0000000..c6f0217
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Domain/Common/AuditableEntity.cs
@@ -0,0 +1,11 @@
+namespace OmsorgCore.Domain.Common;
+
+public abstract class AuditableEntity : Entity
+{
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+ public DateTime? UpdatedAt { get; set; }
+
+ // Soft-Delete statt Hard-Delete für geschäftsrelevante Daten (siehe CLAUDE.md).
+ public bool IsDeleted { get; set; }
+ public DateTime? DeletedAt { get; set; }
+}
diff --git a/omsorgCore/src/OmsorgCore.Domain/Common/Entity.cs b/omsorgCore/src/OmsorgCore.Domain/Common/Entity.cs
new file mode 100644
index 0000000..a381ca3
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Domain/Common/Entity.cs
@@ -0,0 +1,6 @@
+namespace OmsorgCore.Domain.Common;
+
+public abstract class Entity
+{
+ public Guid Id { get; protected set; } = Guid.NewGuid();
+}
diff --git a/omsorgCore/src/OmsorgCore.Domain/Entities/Contract.cs b/omsorgCore/src/OmsorgCore.Domain/Entities/Contract.cs
new file mode 100644
index 0000000..d848d86
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Domain/Entities/Contract.cs
@@ -0,0 +1,22 @@
+using OmsorgCore.Domain.Common;
+
+namespace OmsorgCore.Domain.Entities;
+
+///
+/// Core-Objekt "Vertrag" — verbindliche Vereinbarung mit Mitarbeiter oder Einrichtung
+/// (REQUIREMENTS.md Abschnitt 6, Blueprint 19.3).
+///
+public class Contract : AuditableEntity
+{
+ public string ContractType { get; set; } = string.Empty;
+
+ public Guid? EmployeeId { get; set; }
+ public Employee? Employee { get; set; }
+
+ public Guid? FacilityId { get; set; }
+ public Facility? Facility { get; set; }
+
+ public DateOnly StartDate { get; set; }
+ public DateOnly? EndDate { get; set; }
+ public string Status { get; set; } = "Entwurf";
+}
diff --git a/omsorgCore/src/OmsorgCore.Domain/Entities/Employee.cs b/omsorgCore/src/OmsorgCore.Domain/Entities/Employee.cs
new file mode 100644
index 0000000..dc50ecb
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Domain/Entities/Employee.cs
@@ -0,0 +1,32 @@
+using OmsorgCore.Domain.Common;
+
+namespace OmsorgCore.Domain.Entities;
+
+///
+/// Core-Objekt "Mitarbeiter" — digitale Personalakte (REQUIREMENTS.md Abschnitt 6, Blueprint 19.1).
+/// Kernfelder für das Fundament; Ausbau (Qualifikationen, Fortbildungen, Dokumente, ...) folgt in
+/// späteren Schritten.
+///
+public class Employee : AuditableEntity
+{
+ public string FirstName { get; set; } = string.Empty;
+ public string LastName { get; set; } = string.Empty;
+ public DateOnly? DateOfBirth { get; set; }
+ public string? Street { get; set; }
+ public string? PostalCode { get; set; }
+ public string? City { get; set; }
+ public string? Country { get; set; }
+ public string? PhoneNumber { get; set; }
+ public string? Email { get; set; }
+ public DateOnly? EntryDate { get; set; }
+ public DateOnly? ExitDate { get; set; }
+ public string Status { get; set; } = "Aktiv";
+ public string? EmergencyContactName { get; set; }
+ public string? EmergencyContactPhone { get; set; }
+ public string? EmergencyContactRelation { get; set; }
+ public string? EmploymentType { get; set; }
+ public string? Qualification { get; set; }
+
+ /// Dateiname des Profilbilds - die Datei selbst liegt weiterhin lokal beim jeweiligen Client (z. B. omsorgWeb/assets/avatars/), hier nur die Zuordnung.
+ public string? AvatarFileName { get; set; }
+}
diff --git a/omsorgCore/src/OmsorgCore.Domain/Entities/Facility.cs b/omsorgCore/src/OmsorgCore.Domain/Entities/Facility.cs
new file mode 100644
index 0000000..8e8d98b
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Domain/Entities/Facility.cs
@@ -0,0 +1,17 @@
+using OmsorgCore.Domain.Common;
+
+namespace OmsorgCore.Domain.Entities;
+
+///
+/// Core-Objekt "Einrichtung" — CRM-/Kundendatensatz (REQUIREMENTS.md Abschnitt 6, Blueprint 19.2).
+///
+public class Facility : AuditableEntity
+{
+ public string Name { get; set; } = string.Empty;
+ public string? FacilityType { get; set; }
+ public string? Address { get; set; }
+ public string? BillingAddress { get; set; }
+ public string? PhoneNumber { get; set; }
+ public string? Email { get; set; }
+ public string CrmStatus { get; set; } = "Lead";
+}
diff --git a/omsorgCore/src/OmsorgCore.Domain/Entities/Invoice.cs b/omsorgCore/src/OmsorgCore.Domain/Entities/Invoice.cs
new file mode 100644
index 0000000..ea00ed3
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Domain/Entities/Invoice.cs
@@ -0,0 +1,24 @@
+using OmsorgCore.Domain.Common;
+
+namespace OmsorgCore.Domain.Entities;
+
+///
+/// Core-Objekt "Rechnung" — entsteht ausschließlich aus freigegebener Zeiterfassung + Konditionen
+/// der Einrichtung (REQUIREMENTS.md Abschnitt 6, Blueprint 19.6, Regel FR-RE-1/FR-ZE-3).
+///
+public class Invoice : AuditableEntity
+{
+ public string InvoiceNumber { get; set; } = string.Empty;
+
+ public Guid FacilityId { get; set; }
+ public Facility Facility { get; set; } = null!;
+
+ public DateOnly BillingPeriodStart { get; set; }
+ public DateOnly BillingPeriodEnd { get; set; }
+
+ public decimal NetAmount { get; set; }
+ public decimal GrossAmount { get; set; }
+
+ public string Status { get; set; } = "Entwurf";
+ public DateOnly? DueDate { get; set; }
+}
diff --git a/omsorgCore/src/OmsorgCore.Domain/Entities/LoginAttempt.cs b/omsorgCore/src/OmsorgCore.Domain/Entities/LoginAttempt.cs
new file mode 100644
index 0000000..860d6c6
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Domain/Entities/LoginAttempt.cs
@@ -0,0 +1,15 @@
+using OmsorgCore.Domain.Common;
+
+namespace OmsorgCore.Domain.Entities;
+
+///
+/// Protokolliert jeden Login-Versuch (Erfolg und Fehlschlag) IP-basiert - Grundlage für die
+/// zentrale Brute-Force-Sperre in .
+///
+public class LoginAttempt : Entity
+{
+ public string IpAddress { get; set; } = string.Empty;
+ public string Username { get; set; } = string.Empty;
+ public bool Succeeded { get; set; }
+ public DateTime AttemptedAt { get; set; } = DateTime.UtcNow;
+}
diff --git a/omsorgCore/src/OmsorgCore.Domain/Entities/Order.cs b/omsorgCore/src/OmsorgCore.Domain/Entities/Order.cs
new file mode 100644
index 0000000..4a4c0ef
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Domain/Entities/Order.cs
@@ -0,0 +1,17 @@
+using OmsorgCore.Domain.Common;
+
+namespace OmsorgCore.Domain.Entities;
+
+///
+/// Core-Objekt "Auftrag" — Personalbedarf einer Einrichtung (REQUIREMENTS.md Abschnitt 6, Blueprint 19.4).
+///
+public class Order : AuditableEntity
+{
+ public Guid FacilityId { get; set; }
+ public Facility Facility { get; set; } = null!;
+
+ public DateOnly StartDate { get; set; }
+ public DateOnly? EndDate { get; set; }
+ public string? RequiredQualification { get; set; }
+ public string Status { get; set; } = "Anfrage";
+}
diff --git a/omsorgCore/src/OmsorgCore.Domain/Entities/PasswordResetCode.cs b/omsorgCore/src/OmsorgCore.Domain/Entities/PasswordResetCode.cs
new file mode 100644
index 0000000..07b96a4
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Domain/Entities/PasswordResetCode.cs
@@ -0,0 +1,35 @@
+using OmsorgCore.Domain.Common;
+
+namespace OmsorgCore.Domain.Entities;
+
+///
+/// Einmal-PIN für den "Passwort vergessen"-Flow. Es wird nie der rohe 6-stellige PIN gespeichert,
+/// nur sein SHA-256-Hash (analog RefreshToken.TokenHash). Nach erfolgreicher Verifikation wird ein
+/// separater, ebenfalls nur gehashter Reset-Token ausgestellt (ResetTokenHash), der für den
+/// abschließenden Passwort-Set-Aufruf gebraucht wird - der PIN selbst wird nie zweimal als
+/// Credential verwendet (siehe RefreshToken-Rotationsmuster).
+///
+public class PasswordResetCode : Entity
+{
+ public Guid UserId { get; set; }
+ public User User { get; set; } = null!;
+
+ public string CodeHash { get; set; } = string.Empty;
+ public int AttemptCount { get; set; }
+
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+ public DateTime ExpiresAt { get; set; }
+ public DateTime? ConsumedAt { get; set; }
+ public DateTime? InvalidatedAt { get; set; }
+
+ /// Nur gesetzt, nachdem der PIN erfolgreich verifiziert wurde (ConsumedAt != null).
+ public string? ResetTokenHash { get; set; }
+ public DateTime? ResetTokenExpiresAt { get; set; }
+ public DateTime? ResetTokenUsedAt { get; set; }
+
+ public bool IsActive => InvalidatedAt is null && ConsumedAt is null
+ && DateTime.UtcNow < ExpiresAt;
+
+ public bool IsResetTokenActive => ResetTokenHash is not null && ResetTokenUsedAt is null
+ && ResetTokenExpiresAt is not null && DateTime.UtcNow < ResetTokenExpiresAt;
+}
diff --git a/omsorgCore/src/OmsorgCore.Domain/Entities/RefreshToken.cs b/omsorgCore/src/OmsorgCore.Domain/Entities/RefreshToken.cs
new file mode 100644
index 0000000..08e3ff9
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Domain/Entities/RefreshToken.cs
@@ -0,0 +1,22 @@
+using OmsorgCore.Domain.Common;
+
+namespace OmsorgCore.Domain.Entities;
+
+///
+/// Langlebiges Gegenstück zum kurzlebigen JWT-Access-Token. Rotiert bei jeder Nutzung
+/// (), damit ein Nutzer nicht täglich neu einloggen muss.
+/// Es wird nie der Rohtoken gespeichert, nur sein Hash.
+///
+public class RefreshToken : Entity
+{
+ public Guid UserId { get; set; }
+ public User User { get; set; } = null!;
+
+ public string TokenHash { get; set; } = string.Empty;
+ public DateTime ExpiresAt { get; set; }
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+ public DateTime? RevokedAt { get; set; }
+ public Guid? ReplacedByTokenId { get; set; }
+
+ public bool IsActive => RevokedAt is null && DateTime.UtcNow < ExpiresAt;
+}
diff --git a/omsorgCore/src/OmsorgCore.Domain/Entities/Role.cs b/omsorgCore/src/OmsorgCore.Domain/Entities/Role.cs
new file mode 100644
index 0000000..6aaa081
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Domain/Entities/Role.cs
@@ -0,0 +1,15 @@
+using OmsorgCore.Domain.Common;
+
+namespace OmsorgCore.Domain.Entities;
+
+///
+/// Rolle als Rechte-Vorlage (z. B. Geschäftsführung, Disposition, Recruiting, Außendienst).
+/// Siehe REQUIREMENTS.md Abschnitt 3 und 7.
+///
+public class Role : Entity
+{
+ public string Name { get; set; } = string.Empty;
+
+ public ICollection RolePermissions { get; set; } = new List();
+ public ICollection Users { get; set; } = new List();
+}
diff --git a/omsorgCore/src/OmsorgCore.Domain/Entities/RolePermission.cs b/omsorgCore/src/OmsorgCore.Domain/Entities/RolePermission.cs
new file mode 100644
index 0000000..e565240
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Domain/Entities/RolePermission.cs
@@ -0,0 +1,16 @@
+using OmsorgCore.Domain.Common;
+using OmsorgCore.Domain.Enums;
+
+namespace OmsorgCore.Domain.Entities;
+
+///
+/// Standard-Recht einer Rolle für ein Modul/eine Aktion.
+///
+public class RolePermission : Entity
+{
+ public Guid RoleId { get; set; }
+ public Role Role { get; set; } = null!;
+
+ public ModuleType Module { get; set; }
+ public PermissionAction Action { get; set; }
+}
diff --git a/omsorgCore/src/OmsorgCore.Domain/Entities/TimeEntry.cs b/omsorgCore/src/OmsorgCore.Domain/Entities/TimeEntry.cs
new file mode 100644
index 0000000..1042f70
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Domain/Entities/TimeEntry.cs
@@ -0,0 +1,24 @@
+using OmsorgCore.Domain.Common;
+
+namespace OmsorgCore.Domain.Entities;
+
+///
+/// Core-Objekt "Zeiterfassung" — geleistete Arbeitszeit eines Mitarbeiters innerhalb eines Auftrags
+/// (REQUIREMENTS.md Abschnitt 6, Blueprint 19.5). Nur freigegebene Einträge (Status "Freigegeben")
+/// dürfen in die Rechnungserstellung einfließen (FR-ZE-3).
+///
+public class TimeEntry : AuditableEntity
+{
+ public Guid EmployeeId { get; set; }
+ public Employee Employee { get; set; } = null!;
+
+ public Guid OrderId { get; set; }
+ public Order Order { get; set; } = null!;
+
+ public DateOnly Date { get; set; }
+ public TimeOnly Start { get; set; }
+ public TimeOnly End { get; set; }
+ public TimeSpan BreakDuration { get; set; }
+
+ public string Status { get; set; } = "Entwurf";
+}
diff --git a/omsorgCore/src/OmsorgCore.Domain/Entities/User.cs b/omsorgCore/src/OmsorgCore.Domain/Entities/User.cs
new file mode 100644
index 0000000..dfa91a1
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Domain/Entities/User.cs
@@ -0,0 +1,36 @@
+using OmsorgCore.Domain.Common;
+
+namespace OmsorgCore.Domain.Entities;
+
+///
+/// Systembenutzer (Login-Konto). Getrennt von , da nicht jeder Mitarbeiter
+/// einen App-Zugang hat und nicht jeder Benutzer zwingend ein Mitarbeiter ist (siehe Blueprint 19.1).
+///
+public class User : AuditableEntity
+{
+ public string Username { get; set; } = string.Empty;
+ public string PasswordHash { get; set; } = string.Empty;
+ public bool IsActive { get; set; } = true;
+
+ ///
+ /// Wechselt, wenn eine Session dieses Users erzwungen beendet wird (siehe SessionAdminService).
+ /// Wird als Claim in jeden Access-Token eingebettet und bei jedem Request dagegen geprüft -
+ /// ein Wechsel invalidiert sofort alle bereits ausgestellten Access-Tokens dieses Users.
+ ///
+ public Guid SecurityStamp { get; set; } = Guid.NewGuid();
+
+ ///
+ /// Erzwingt beim nächsten Request (außer Passwort-ändern/Logout/Refresh/Me) einen Passwortwechsel -
+ /// gesetzt, wenn ein Admin ein initiales Passwort direkt vergibt statt eine Einladungsmail zu senden.
+ ///
+ public bool MustChangePassword { get; set; }
+
+ public Guid RoleId { get; set; }
+ public Role Role { get; set; } = null!;
+
+ /// Optionale Verknüpfung zum Personalakte-Datensatz (nicht jeder Benutzer ist Mitarbeiter).
+ public Guid? EmployeeId { get; set; }
+ public Employee? Employee { get; set; }
+
+ public ICollection PermissionOverrides { get; set; } = new List();
+}
diff --git a/omsorgCore/src/OmsorgCore.Domain/Entities/UserPermissionOverride.cs b/omsorgCore/src/OmsorgCore.Domain/Entities/UserPermissionOverride.cs
new file mode 100644
index 0000000..0054505
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Domain/Entities/UserPermissionOverride.cs
@@ -0,0 +1,19 @@
+using OmsorgCore.Domain.Common;
+using OmsorgCore.Domain.Enums;
+
+namespace OmsorgCore.Domain.Entities;
+
+///
+/// Individuell für einen Benutzer gewährtes oder entzogenes Recht — überschreibt den Rollen-Default.
+/// Siehe Blueprint Kap. 6.5 ("Rollen dienen als Vorlage. Individuelle Rechte können zusätzlich
+/// vergeben oder entzogen werden.").
+///
+public class UserPermissionOverride : Entity
+{
+ public Guid UserId { get; set; }
+ public User User { get; set; } = null!;
+
+ public ModuleType Module { get; set; }
+ public PermissionAction Action { get; set; }
+ public PermissionEffect Effect { get; set; }
+}
diff --git a/omsorgCore/src/OmsorgCore.Domain/Enums/ModuleType.cs b/omsorgCore/src/OmsorgCore.Domain/Enums/ModuleType.cs
new file mode 100644
index 0000000..5741cd8
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Domain/Enums/ModuleType.cs
@@ -0,0 +1,17 @@
+namespace OmsorgCore.Domain.Enums;
+
+///
+/// Fachliche Module, gegen die Rechte geprüft werden (siehe REQUIREMENTS.md Abschnitt 7, Rechtematrix).
+///
+public enum ModuleType
+{
+ Employees,
+ Facilities,
+ Contracts,
+ Orders,
+ TimeEntries,
+ Invoices,
+ Recruiting,
+ Controlling,
+ UserManagement
+}
diff --git a/omsorgCore/src/OmsorgCore.Domain/Enums/PermissionAction.cs b/omsorgCore/src/OmsorgCore.Domain/Enums/PermissionAction.cs
new file mode 100644
index 0000000..bd28035
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Domain/Enums/PermissionAction.cs
@@ -0,0 +1,14 @@
+namespace OmsorgCore.Domain.Enums;
+
+///
+/// Mögliche Aktionen innerhalb eines Moduls (siehe Blueprint Kap. 6.5).
+///
+public enum PermissionAction
+{
+ View,
+ Create,
+ Edit,
+ Delete,
+ Export,
+ Approve
+}
diff --git a/omsorgCore/src/OmsorgCore.Domain/Enums/PermissionEffect.cs b/omsorgCore/src/OmsorgCore.Domain/Enums/PermissionEffect.cs
new file mode 100644
index 0000000..649782a
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Domain/Enums/PermissionEffect.cs
@@ -0,0 +1,10 @@
+namespace OmsorgCore.Domain.Enums;
+
+///
+/// Ob ein individueller Permission-Override ein Recht zusätzlich gewährt oder entzieht.
+///
+public enum PermissionEffect
+{
+ Grant,
+ Revoke
+}
diff --git a/omsorgCore/src/OmsorgCore.Domain/OmsorgCore.Domain.csproj b/omsorgCore/src/OmsorgCore.Domain/OmsorgCore.Domain.csproj
new file mode 100644
index 0000000..fa71b7a
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Domain/OmsorgCore.Domain.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
diff --git a/omsorgCore/src/OmsorgCore.Email/ConsoleEmailSender.cs b/omsorgCore/src/OmsorgCore.Email/ConsoleEmailSender.cs
new file mode 100644
index 0000000..14f03b6
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Email/ConsoleEmailSender.cs
@@ -0,0 +1,26 @@
+using Microsoft.Extensions.Logging;
+using OmsorgCore.Application.Abstractions;
+
+namespace OmsorgCore.Email;
+
+///
+/// Entwicklungs-Sender: schreibt E-Mails nur ins Log statt sie tatsächlich zu versenden.
+/// Bewusster Default, solange kein echter SMTP-Provider registriert ist (siehe AddEmail).
+///
+public class ConsoleEmailSender : IEmailSender
+{
+ private readonly ILogger _logger;
+
+ public ConsoleEmailSender(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default)
+ {
+ _logger.LogInformation(
+ "[ConsoleEmailSender] An: {To} | Betreff: {Subject}\n{Body}",
+ message.ToAddress, message.Subject, message.PlainTextBody);
+ return Task.CompletedTask;
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Email/DependencyInjection.cs b/omsorgCore/src/OmsorgCore.Email/DependencyInjection.cs
new file mode 100644
index 0000000..1b342bd
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Email/DependencyInjection.cs
@@ -0,0 +1,28 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using OmsorgCore.Application.Abstractions;
+
+namespace OmsorgCore.Email;
+
+public static class DependencyInjection
+{
+ public static IServiceCollection AddEmail(this IServiceCollection services, IConfiguration configuration)
+ {
+ services.Configure(configuration.GetSection(EmailOptions.SectionName));
+
+ var provider = configuration.GetSection(EmailOptions.SectionName)["Provider"];
+ if (string.Equals(provider, "Smtp", StringComparison.OrdinalIgnoreCase))
+ {
+ services.AddScoped();
+ }
+ else
+ {
+ services.AddScoped();
+ }
+
+ services.AddScoped();
+ services.AddScoped();
+
+ return services;
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Email/EmailAccountOptions.cs b/omsorgCore/src/OmsorgCore.Email/EmailAccountOptions.cs
new file mode 100644
index 0000000..81c4fa2
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Email/EmailAccountOptions.cs
@@ -0,0 +1,14 @@
+namespace OmsorgCore.Email;
+
+///
+/// Ein Mail-Account (Login + Absenderidentität) auf dem gemeinsamen SMTP-Server ().
+/// Username/Password sind Secrets - im eingecheckten appsettings nur "CHANGE_ME_..."-Platzhalter,
+/// echte Werte über Umgebungsvariable (Email__Accounts__<Name>__Password) oder dotnet user-secrets.
+///
+public class EmailAccountOptions
+{
+ public string Username { get; set; } = string.Empty;
+ public string Password { get; set; } = string.Empty;
+ public string SenderAddress { get; set; } = string.Empty;
+ public string SenderDisplayName { get; set; } = "OMSORG";
+}
diff --git a/omsorgCore/src/OmsorgCore.Email/EmailOptions.cs b/omsorgCore/src/OmsorgCore.Email/EmailOptions.cs
new file mode 100644
index 0000000..4494a87
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Email/EmailOptions.cs
@@ -0,0 +1,36 @@
+namespace OmsorgCore.Email;
+
+///
+/// Gebunden an den "Email"-Abschnitt in appsettings.json. Ein gemeinsamer SMTP-Server (),
+/// darüber mehrere benannte Accounts () - z. B. ein dedizierter Account nur für
+/// Passwort-Reset-Mails, getrennt von einem künftigen allgemeinen Absender.
+///
+public class EmailOptions
+{
+ public const string SectionName = "Email";
+
+ /// "Console" (Default/Dev, schreibt nur ins Log) oder "Smtp" (echter Versand über ).
+ public string Provider { get; set; } = "Console";
+
+ public SmtpOptions Smtp { get; set; } = new();
+
+ /// Benannte Accounts auf dem SMTP-Server, Key = frei wählbarer Name (z. B. "PasswordReset").
+ public Dictionary Accounts { get; set; } = new();
+
+ /// Welcher Eintrag aus für Passwort-Reset-Mails verwendet wird.
+ public string PasswordResetAccount { get; set; } = "PasswordReset";
+
+ public PasswordResetEmailTemplate PasswordResetTemplate { get; set; } = new();
+
+ /// Welcher Eintrag aus für Account-Einladungsmails verwendet wird.
+ public string UserInviteAccount { get; set; } = "UserInvite";
+
+ public UserInviteEmailTemplate UserInviteTemplate { get; set; } = new();
+
+ ///
+ /// Dupliziert bewusst OmsorgCore.Infrastructure.Security.PasswordResetOptions.PinExpiryMinutes -
+ /// beide binden an dieselbe "Email"-Sektion, bleiben aber unabhängige Klassen je Schicht
+ /// (Engine kennt Infrastructure nicht), analog dem bestehenden Kommentar in PasswordResetOptions.
+ ///
+ public int PinExpiryMinutes { get; set; } = 5;
+}
diff --git a/omsorgCore/src/OmsorgCore.Email/EmailTemplateRenderer.cs b/omsorgCore/src/OmsorgCore.Email/EmailTemplateRenderer.cs
new file mode 100644
index 0000000..ab8b09d
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Email/EmailTemplateRenderer.cs
@@ -0,0 +1,19 @@
+namespace OmsorgCore.Email;
+
+///
+/// Ersetzt Platzhalter im Format $NAME$ in einer Vorlage. Reine Funktion, keine Abhängigkeiten -
+/// bewusst aus dem Handler herausgezogen, damit die Ersetzung isoliert testbar ist.
+///
+public static class EmailTemplateRenderer
+{
+ public static string Render(string template, IReadOnlyDictionary values)
+ {
+ var result = template;
+ foreach (var (key, value) in values)
+ {
+ result = result.Replace($"${key}$", value);
+ }
+
+ return result;
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Email/OmsorgCore.Email.csproj b/omsorgCore/src/OmsorgCore.Email/OmsorgCore.Email.csproj
new file mode 100644
index 0000000..0427d21
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Email/OmsorgCore.Email.csproj
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
diff --git a/omsorgCore/src/OmsorgCore.Email/PasswordResetEmailComposer.cs b/omsorgCore/src/OmsorgCore.Email/PasswordResetEmailComposer.cs
new file mode 100644
index 0000000..846983f
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Email/PasswordResetEmailComposer.cs
@@ -0,0 +1,30 @@
+using Microsoft.Extensions.Options;
+using OmsorgCore.Application.Abstractions;
+
+namespace OmsorgCore.Email;
+
+public class PasswordResetEmailComposer : IPasswordResetEmailComposer
+{
+ private readonly EmailOptions _options;
+
+ public PasswordResetEmailComposer(IOptions options)
+ {
+ _options = options.Value;
+ }
+
+ public EmailMessage Compose(string toEmail, string rawPin)
+ {
+ var template = _options.PasswordResetTemplate;
+ var values = new Dictionary
+ {
+ ["RESET_PIN"] = rawPin,
+ ["RESET_PIN_EXPIRY_MINUTES"] = _options.PinExpiryMinutes.ToString()
+ };
+
+ return new EmailMessage(
+ toEmail,
+ EmailTemplateRenderer.Render(template.Subject, values),
+ EmailTemplateRenderer.Render(template.BodyTemplate, values),
+ FromAccountKey: _options.PasswordResetAccount);
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Email/PasswordResetEmailTemplate.cs b/omsorgCore/src/OmsorgCore.Email/PasswordResetEmailTemplate.cs
new file mode 100644
index 0000000..1abc9a4
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Email/PasswordResetEmailTemplate.cs
@@ -0,0 +1,13 @@
+namespace OmsorgCore.Email;
+
+///
+/// Betreff/Text der Passwort-Reset-Mail, über appsettings konfigurierbar. Platzhalter im Format
+/// $NAME$ (z. B. $RESET_PIN$), ersetzt von .
+///
+public class PasswordResetEmailTemplate
+{
+ public string Subject { get; set; } = "Ihr OMSORG Passwort-Reset-Code";
+
+ public string BodyTemplate { get; set; } =
+ "Ihr Code lautet: $RESET_PIN$\nDer Code ist $RESET_PIN_EXPIRY_MINUTES$ Minuten gültig.";
+}
diff --git a/omsorgCore/src/OmsorgCore.Email/SmtpEmailSender.cs b/omsorgCore/src/OmsorgCore.Email/SmtpEmailSender.cs
new file mode 100644
index 0000000..d645b95
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Email/SmtpEmailSender.cs
@@ -0,0 +1,57 @@
+using MailKit.Net.Smtp;
+using MailKit.Security;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using MimeKit;
+using OmsorgCore.Application.Abstractions;
+
+namespace OmsorgCore.Email;
+
+///
+/// Echter Mail-Versand über SMTP (MailKit statt des veralteten System.Net.Mail.SmtpClient).
+/// Ein gemeinsamer Server (), Login/Absender kommen aus dem per
+/// gewählten Account () -
+/// alles aus appsettings/Env-Var/user-secrets, nie vom Client.
+///
+public class SmtpEmailSender : IEmailSender
+{
+ private readonly EmailOptions _options;
+ private readonly ILogger _logger;
+
+ public SmtpEmailSender(IOptions options, ILogger logger)
+ {
+ _options = options.Value;
+ _logger = logger;
+ }
+
+ public async Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default)
+ {
+ var accountKey = message.FromAccountKey ?? _options.PasswordResetAccount;
+ if (!_options.Accounts.TryGetValue(accountKey, out var account))
+ {
+ throw new InvalidOperationException(
+ $"Kein E-Mail-Account mit Schlüssel '{accountKey}' unter Email:Accounts konfiguriert.");
+ }
+
+ var mimeMessage = new MimeMessage();
+ mimeMessage.From.Add(new MailboxAddress(account.SenderDisplayName, account.SenderAddress));
+ mimeMessage.To.Add(MailboxAddress.Parse(message.ToAddress));
+ mimeMessage.Subject = message.Subject;
+ mimeMessage.Body = new TextPart("plain") { Text = message.PlainTextBody };
+
+ var smtp = _options.Smtp;
+
+ using var client = new SmtpClient();
+ await client.ConnectAsync(
+ smtp.Host,
+ smtp.Port,
+ smtp.EnableSsl ? SecureSocketOptions.StartTlsWhenAvailable : SecureSocketOptions.None,
+ cancellationToken);
+ await client.AuthenticateAsync(account.Username, account.Password, cancellationToken);
+ await client.SendAsync(mimeMessage, cancellationToken);
+ await client.DisconnectAsync(true, cancellationToken);
+
+ _logger.LogInformation("[SmtpEmailSender] Mail an {To} über {Host}:{Port} (Account {Account}) versendet.",
+ message.ToAddress, smtp.Host, smtp.Port, accountKey);
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Email/SmtpOptions.cs b/omsorgCore/src/OmsorgCore.Email/SmtpOptions.cs
new file mode 100644
index 0000000..e0d818e
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Email/SmtpOptions.cs
@@ -0,0 +1,12 @@
+namespace OmsorgCore.Email;
+
+///
+/// Verbindungsdaten des einen gemeinsamen SMTP-Servers, über den alle Accounts ()
+/// versenden. Kein Secret - Username/Passwort liegen pro Account, nicht hier.
+///
+public class SmtpOptions
+{
+ public string Host { get; set; } = string.Empty;
+ public int Port { get; set; } = 587;
+ public bool EnableSsl { get; set; } = true;
+}
diff --git a/omsorgCore/src/OmsorgCore.Email/UserInviteEmailComposer.cs b/omsorgCore/src/OmsorgCore.Email/UserInviteEmailComposer.cs
new file mode 100644
index 0000000..173e42c
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Email/UserInviteEmailComposer.cs
@@ -0,0 +1,30 @@
+using Microsoft.Extensions.Options;
+using OmsorgCore.Application.Abstractions;
+
+namespace OmsorgCore.Email;
+
+public class UserInviteEmailComposer : IUserInviteEmailComposer
+{
+ private readonly EmailOptions _options;
+
+ public UserInviteEmailComposer(IOptions options)
+ {
+ _options = options.Value;
+ }
+
+ public EmailMessage Compose(string toEmail, string rawPin, int validityDays)
+ {
+ var template = _options.UserInviteTemplate;
+ var values = new Dictionary
+ {
+ ["INVITE_PIN"] = rawPin,
+ ["INVITE_PIN_EXPIRY_DAYS"] = validityDays.ToString()
+ };
+
+ return new EmailMessage(
+ toEmail,
+ EmailTemplateRenderer.Render(template.Subject, values),
+ EmailTemplateRenderer.Render(template.BodyTemplate, values),
+ FromAccountKey: _options.UserInviteAccount);
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Email/UserInviteEmailTemplate.cs b/omsorgCore/src/OmsorgCore.Email/UserInviteEmailTemplate.cs
new file mode 100644
index 0000000..fd1e1ed
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Email/UserInviteEmailTemplate.cs
@@ -0,0 +1,13 @@
+namespace OmsorgCore.Email;
+
+///
+/// Betreff/Text der Account-Einladungsmail, über appsettings konfigurierbar. Platzhalter im Format
+/// $NAME$ (z. B. $INVITE_PIN$), ersetzt von .
+///
+public class UserInviteEmailTemplate
+{
+ public string Subject { get; set; } = "Ihr OMSORG-Zugang";
+
+ public string BodyTemplate { get; set; } =
+ "Für Sie wurde ein OMSORG-Konto angelegt.\nIhr Einladungscode lautet: $INVITE_PIN$\nDer Code ist $INVITE_PIN_EXPIRY_DAYS$ Tage gültig. Damit legen Sie beim ersten Login Ihr eigenes Passwort fest.";
+}
diff --git a/omsorgCore/src/OmsorgCore.Engine/DependencyInjection.cs b/omsorgCore/src/OmsorgCore.Engine/DependencyInjection.cs
new file mode 100644
index 0000000..49f0590
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Engine/DependencyInjection.cs
@@ -0,0 +1,18 @@
+using Microsoft.Extensions.DependencyInjection;
+using OmsorgCore.Engine.Events;
+using OmsorgCore.Engine.Handlers;
+
+namespace OmsorgCore.Engine;
+
+public static class DependencyInjection
+{
+ public static IServiceCollection AddEngine(this IServiceCollection services)
+ {
+ services.AddSingleton();
+ services.AddScoped, EmployeeCreatedHandler>();
+ services.AddScoped, PasswordResetRequestedHandler>();
+ services.AddScoped, UserInvitedHandler>();
+
+ return services;
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Engine/Events/DomainEventDispatcher.cs b/omsorgCore/src/OmsorgCore.Engine/Events/DomainEventDispatcher.cs
new file mode 100644
index 0000000..f522f1e
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Engine/Events/DomainEventDispatcher.cs
@@ -0,0 +1,36 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace OmsorgCore.Engine.Events;
+
+///
+/// Einfacher In-Process-Dispatcher: löst alle registrierten Handler für den konkreten
+/// Event-Typ über den DI-Container auf und führt sie aus.
+///
+public class DomainEventDispatcher : IDomainEventDispatcher
+{
+ private readonly IServiceScopeFactory _serviceScopeFactory;
+
+ public DomainEventDispatcher(IServiceScopeFactory serviceScopeFactory)
+ {
+ _serviceScopeFactory = serviceScopeFactory;
+ }
+
+ public async Task DispatchAsync(IDomainEvent domainEvent, CancellationToken cancellationToken = default)
+ {
+ using var scope = _serviceScopeFactory.CreateScope();
+
+ var handlerType = typeof(IDomainEventHandler<>).MakeGenericType(domainEvent.GetType());
+ var handlers = scope.ServiceProvider.GetServices(handlerType);
+
+ foreach (var handler in handlers)
+ {
+ if (handler is null)
+ {
+ continue;
+ }
+
+ var method = handlerType.GetMethod(nameof(IDomainEventHandler.HandleAsync))!;
+ await (Task)method.Invoke(handler, new object[] { domainEvent, cancellationToken })!;
+ }
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Engine/Events/EmployeeCreatedEvent.cs b/omsorgCore/src/OmsorgCore.Engine/Events/EmployeeCreatedEvent.cs
new file mode 100644
index 0000000..c51b235
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Engine/Events/EmployeeCreatedEvent.cs
@@ -0,0 +1,16 @@
+namespace OmsorgCore.Engine.Events;
+
+///
+/// Beispiel-Ereignis für den Trigger "Neuer Mitarbeiter beginnt" aus REQUIREMENTS.md Abschnitt 4.10.
+///
+public class EmployeeCreatedEvent : IDomainEvent
+{
+ public Guid EmployeeId { get; }
+ public DateTime OccurredAt { get; }
+
+ public EmployeeCreatedEvent(Guid employeeId)
+ {
+ EmployeeId = employeeId;
+ OccurredAt = DateTime.UtcNow;
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Engine/Events/IDomainEvent.cs b/omsorgCore/src/OmsorgCore.Engine/Events/IDomainEvent.cs
new file mode 100644
index 0000000..9c1d9b2
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Engine/Events/IDomainEvent.cs
@@ -0,0 +1,10 @@
+namespace OmsorgCore.Engine.Events;
+
+///
+/// Markerinterface für Ereignisse, auf die die Event-Schicht reagiert
+/// (siehe REQUIREMENTS.md Abschnitt 4.10, Blueprint Kap. 20).
+///
+public interface IDomainEvent
+{
+ DateTime OccurredAt { get; }
+}
diff --git a/omsorgCore/src/OmsorgCore.Engine/Events/IDomainEventDispatcher.cs b/omsorgCore/src/OmsorgCore.Engine/Events/IDomainEventDispatcher.cs
new file mode 100644
index 0000000..19fa2a3
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Engine/Events/IDomainEventDispatcher.cs
@@ -0,0 +1,11 @@
+namespace OmsorgCore.Engine.Events;
+
+///
+/// Einstiegspunkt der Event-Schicht: die Api-Schicht ruft nach jeder geschäftsrelevanten
+/// Zustandsänderung (z. B. nach dem Speichern eines neuen Mitarbeiters) DispatchAsync auf.
+/// Läuft im selben Prozess wie die Datenschicht — kein separater Service, keine Queue.
+///
+public interface IDomainEventDispatcher
+{
+ Task DispatchAsync(IDomainEvent domainEvent, CancellationToken cancellationToken = default);
+}
diff --git a/omsorgCore/src/OmsorgCore.Engine/Events/IDomainEventHandler.cs b/omsorgCore/src/OmsorgCore.Engine/Events/IDomainEventHandler.cs
new file mode 100644
index 0000000..ee94dda
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Engine/Events/IDomainEventHandler.cs
@@ -0,0 +1,6 @@
+namespace OmsorgCore.Engine.Events;
+
+public interface IDomainEventHandler where TEvent : IDomainEvent
+{
+ Task HandleAsync(TEvent domainEvent, CancellationToken cancellationToken = default);
+}
diff --git a/omsorgCore/src/OmsorgCore.Engine/Events/PasswordResetRequestedEvent.cs b/omsorgCore/src/OmsorgCore.Engine/Events/PasswordResetRequestedEvent.cs
new file mode 100644
index 0000000..1670a1a
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Engine/Events/PasswordResetRequestedEvent.cs
@@ -0,0 +1,15 @@
+namespace OmsorgCore.Engine.Events;
+
+public class PasswordResetRequestedEvent : IDomainEvent
+{
+ public string ToEmail { get; }
+ public string RawPin { get; }
+ public DateTime OccurredAt { get; }
+
+ public PasswordResetRequestedEvent(string toEmail, string rawPin)
+ {
+ ToEmail = toEmail;
+ RawPin = rawPin;
+ OccurredAt = DateTime.UtcNow;
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Engine/Events/UserInvitedEvent.cs b/omsorgCore/src/OmsorgCore.Engine/Events/UserInvitedEvent.cs
new file mode 100644
index 0000000..aa7a37e
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Engine/Events/UserInvitedEvent.cs
@@ -0,0 +1,17 @@
+namespace OmsorgCore.Engine.Events;
+
+public class UserInvitedEvent : IDomainEvent
+{
+ public string ToEmail { get; }
+ public string RawPin { get; }
+ public int ValidityDays { get; }
+ public DateTime OccurredAt { get; }
+
+ public UserInvitedEvent(string toEmail, string rawPin, int validityDays)
+ {
+ ToEmail = toEmail;
+ RawPin = rawPin;
+ ValidityDays = validityDays;
+ OccurredAt = DateTime.UtcNow;
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Engine/Handlers/EmployeeCreatedHandler.cs b/omsorgCore/src/OmsorgCore.Engine/Handlers/EmployeeCreatedHandler.cs
new file mode 100644
index 0000000..85734da
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Engine/Handlers/EmployeeCreatedHandler.cs
@@ -0,0 +1,30 @@
+using Microsoft.Extensions.Logging;
+using OmsorgCore.Engine.Events;
+
+namespace OmsorgCore.Engine.Handlers;
+
+///
+/// Referenzimplementierung eines Event-Handlers. Reagiert auf
+/// (Trigger "Neuer Mitarbeiter beginnt", REQUIREMENTS.md Abschnitt 4.10). Protokolliert bislang nur —
+/// die eigentlichen Aktionen (Dashboard-Hinweis, App-Zugang vorbereiten, ...) werden ergänzt,
+/// sobald die zugehörigen Module existieren.
+///
+public class EmployeeCreatedHandler : IDomainEventHandler
+{
+ private readonly ILogger _logger;
+
+ public EmployeeCreatedHandler(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public Task HandleAsync(EmployeeCreatedEvent domainEvent, CancellationToken cancellationToken = default)
+ {
+ _logger.LogInformation(
+ "Engine: Mitarbeiter {EmployeeId} wurde angelegt ({OccurredAt}).",
+ domainEvent.EmployeeId,
+ domainEvent.OccurredAt);
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Engine/Handlers/PasswordResetRequestedHandler.cs b/omsorgCore/src/OmsorgCore.Engine/Handlers/PasswordResetRequestedHandler.cs
new file mode 100644
index 0000000..c997324
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Engine/Handlers/PasswordResetRequestedHandler.cs
@@ -0,0 +1,31 @@
+using Microsoft.Extensions.Logging;
+using OmsorgCore.Application.Abstractions;
+using OmsorgCore.Engine.Events;
+
+namespace OmsorgCore.Engine.Handlers;
+
+public class PasswordResetRequestedHandler : IDomainEventHandler
+{
+ private readonly IEmailSender _emailSender;
+ private readonly IPasswordResetEmailComposer _emailComposer;
+ private readonly ILogger _logger;
+
+ public PasswordResetRequestedHandler(
+ IEmailSender emailSender,
+ IPasswordResetEmailComposer emailComposer,
+ ILogger logger)
+ {
+ _emailSender = emailSender;
+ _emailComposer = emailComposer;
+ _logger = logger;
+ }
+
+ public async Task HandleAsync(PasswordResetRequestedEvent domainEvent, CancellationToken cancellationToken = default)
+ {
+ var message = _emailComposer.Compose(domainEvent.ToEmail, domainEvent.RawPin);
+ await _emailSender.SendAsync(message, cancellationToken);
+
+ _logger.LogInformation("Engine: Passwort-Reset-Code an {Email} versendet ({OccurredAt}).",
+ domainEvent.ToEmail, domainEvent.OccurredAt);
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Engine/Handlers/UserInvitedHandler.cs b/omsorgCore/src/OmsorgCore.Engine/Handlers/UserInvitedHandler.cs
new file mode 100644
index 0000000..c4122d8
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Engine/Handlers/UserInvitedHandler.cs
@@ -0,0 +1,31 @@
+using Microsoft.Extensions.Logging;
+using OmsorgCore.Application.Abstractions;
+using OmsorgCore.Engine.Events;
+
+namespace OmsorgCore.Engine.Handlers;
+
+public class UserInvitedHandler : IDomainEventHandler
+{
+ private readonly IEmailSender _emailSender;
+ private readonly IUserInviteEmailComposer _emailComposer;
+ private readonly ILogger _logger;
+
+ public UserInvitedHandler(
+ IEmailSender emailSender,
+ IUserInviteEmailComposer emailComposer,
+ ILogger logger)
+ {
+ _emailSender = emailSender;
+ _emailComposer = emailComposer;
+ _logger = logger;
+ }
+
+ public async Task HandleAsync(UserInvitedEvent domainEvent, CancellationToken cancellationToken = default)
+ {
+ var message = _emailComposer.Compose(domainEvent.ToEmail, domainEvent.RawPin, domainEvent.ValidityDays);
+ await _emailSender.SendAsync(message, cancellationToken);
+
+ _logger.LogInformation("Engine: Account-Einladung an {Email} versendet ({OccurredAt}).",
+ domainEvent.ToEmail, domainEvent.OccurredAt);
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Engine/OmsorgCore.Engine.csproj b/omsorgCore/src/OmsorgCore.Engine/OmsorgCore.Engine.csproj
new file mode 100644
index 0000000..b452bc5
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Engine/OmsorgCore.Engine.csproj
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/DependencyInjection.cs b/omsorgCore/src/OmsorgCore.Infrastructure/DependencyInjection.cs
new file mode 100644
index 0000000..e7d104d
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Infrastructure/DependencyInjection.cs
@@ -0,0 +1,42 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using OmsorgCore.Application.Abstractions;
+using OmsorgCore.Infrastructure.Persistence;
+using OmsorgCore.Infrastructure.Repositories;
+using OmsorgCore.Infrastructure.Security;
+
+namespace OmsorgCore.Infrastructure;
+
+///
+/// Bündelt die Registrierung aller Infrastructure-Implementierungen, damit Program.cs
+/// nur diese eine Erweiterungsmethode aufrufen muss statt jede Klasse einzeln zu verdrahten.
+///
+public static class DependencyInjection
+{
+ public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
+ {
+ services.AddDbContext(options =>
+ options.UseNpgsql(configuration.GetConnectionString("OmsorgCore")));
+
+ services.Configure(configuration.GetSection(JwtOptions.SectionName));
+ services.Configure(configuration.GetSection(RefreshTokenOptions.SectionName));
+ services.Configure(configuration.GetSection(PasswordResetOptions.SectionName));
+ services.Configure(configuration.GetSection(SeedOptions.SectionName));
+ services.Configure(configuration.GetSection(LoginAttemptOptions.SectionName));
+
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ return services;
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/OmsorgCore.Infrastructure.csproj b/omsorgCore/src/OmsorgCore.Infrastructure/OmsorgCore.Infrastructure.csproj
new file mode 100644
index 0000000..b8bc898
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Infrastructure/OmsorgCore.Infrastructure.csproj
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/ContractConfiguration.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/ContractConfiguration.cs
new file mode 100644
index 0000000..f68ad79
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/ContractConfiguration.cs
@@ -0,0 +1,26 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Infrastructure.Persistence.Configurations;
+
+public class ContractConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("contracts");
+ builder.HasKey(c => c.Id);
+ builder.Property(c => c.ContractType).IsRequired().HasMaxLength(100);
+ builder.Property(c => c.Status).IsRequired().HasMaxLength(50);
+
+ builder.HasOne(c => c.Employee)
+ .WithMany()
+ .HasForeignKey(c => c.EmployeeId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ builder.HasOne(c => c.Facility)
+ .WithMany()
+ .HasForeignKey(c => c.FacilityId)
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/EmployeeConfiguration.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/EmployeeConfiguration.cs
new file mode 100644
index 0000000..71c6627
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/EmployeeConfiguration.cs
@@ -0,0 +1,31 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Infrastructure.Persistence.Configurations;
+
+public class EmployeeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("employees");
+ builder.HasKey(e => e.Id);
+ builder.Property(e => e.FirstName).IsRequired().HasMaxLength(200);
+ builder.Property(e => e.LastName).IsRequired().HasMaxLength(200);
+ builder.Property(e => e.Status).IsRequired().HasMaxLength(50);
+ builder.Property(e => e.Street).HasMaxLength(200);
+ builder.Property(e => e.PostalCode).HasMaxLength(10);
+ builder.Property(e => e.City).HasMaxLength(100);
+ builder.Property(e => e.Country).HasMaxLength(100);
+ builder.Property(e => e.PhoneNumber).HasMaxLength(50);
+ builder.Property(e => e.Email).HasMaxLength(200);
+ builder.Property(e => e.EmergencyContactName).HasMaxLength(200);
+ builder.Property(e => e.EmergencyContactPhone).HasMaxLength(50);
+ builder.Property(e => e.EmergencyContactRelation).HasMaxLength(100);
+ builder.Property(e => e.EmploymentType).HasMaxLength(50);
+ builder.Property(e => e.Qualification).HasMaxLength(500);
+
+ // Soft-gelöschte Mitarbeiter sind für alle normalen Queries unsichtbar.
+ builder.HasQueryFilter(e => !e.IsDeleted);
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/FacilityConfiguration.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/FacilityConfiguration.cs
new file mode 100644
index 0000000..6654008
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/FacilityConfiguration.cs
@@ -0,0 +1,16 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Infrastructure.Persistence.Configurations;
+
+public class FacilityConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("facilities");
+ builder.HasKey(f => f.Id);
+ builder.Property(f => f.Name).IsRequired().HasMaxLength(300);
+ builder.Property(f => f.CrmStatus).IsRequired().HasMaxLength(50);
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/InvoiceConfiguration.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/InvoiceConfiguration.cs
new file mode 100644
index 0000000..54d5f0c
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/InvoiceConfiguration.cs
@@ -0,0 +1,24 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Infrastructure.Persistence.Configurations;
+
+public class InvoiceConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("invoices");
+ builder.HasKey(i => i.Id);
+ builder.Property(i => i.InvoiceNumber).IsRequired().HasMaxLength(50);
+ builder.HasIndex(i => i.InvoiceNumber).IsUnique();
+ builder.Property(i => i.Status).IsRequired().HasMaxLength(50);
+ builder.Property(i => i.NetAmount).HasColumnType("numeric(12,2)");
+ builder.Property(i => i.GrossAmount).HasColumnType("numeric(12,2)");
+
+ builder.HasOne(i => i.Facility)
+ .WithMany()
+ .HasForeignKey(i => i.FacilityId)
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/LoginAttemptConfiguration.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/LoginAttemptConfiguration.cs
new file mode 100644
index 0000000..a4e3a77
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/LoginAttemptConfiguration.cs
@@ -0,0 +1,17 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Infrastructure.Persistence.Configurations;
+
+public class LoginAttemptConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("login_attempts");
+ builder.HasKey(a => a.Id);
+ builder.Property(a => a.IpAddress).IsRequired().HasMaxLength(64);
+ builder.Property(a => a.Username).IsRequired().HasMaxLength(100);
+ builder.HasIndex(a => new { a.IpAddress, a.AttemptedAt });
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/OrderConfiguration.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/OrderConfiguration.cs
new file mode 100644
index 0000000..e98ebb8
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/OrderConfiguration.cs
@@ -0,0 +1,20 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Infrastructure.Persistence.Configurations;
+
+public class OrderConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("orders");
+ builder.HasKey(o => o.Id);
+ builder.Property(o => o.Status).IsRequired().HasMaxLength(50);
+
+ builder.HasOne(o => o.Facility)
+ .WithMany()
+ .HasForeignKey(o => o.FacilityId)
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/PasswordResetCodeConfiguration.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/PasswordResetCodeConfiguration.cs
new file mode 100644
index 0000000..b78dba8
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/PasswordResetCodeConfiguration.cs
@@ -0,0 +1,25 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Infrastructure.Persistence.Configurations;
+
+public class PasswordResetCodeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("password_reset_codes");
+ builder.HasKey(c => c.Id);
+ builder.Property(c => c.CodeHash).IsRequired().HasMaxLength(128);
+ builder.Property(c => c.ResetTokenHash).HasMaxLength(128);
+ builder.HasIndex(c => c.UserId);
+ builder.HasIndex(c => c.ResetTokenHash);
+ builder.Ignore(c => c.IsActive);
+ builder.Ignore(c => c.IsResetTokenActive);
+
+ builder.HasOne(c => c.User)
+ .WithMany()
+ .HasForeignKey(c => c.UserId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs
new file mode 100644
index 0000000..ac0e278
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs
@@ -0,0 +1,28 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Infrastructure.Persistence.Configurations;
+
+public class RefreshTokenConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("refresh_tokens");
+ builder.HasKey(t => t.Id);
+ builder.Property(t => t.TokenHash).IsRequired().HasMaxLength(128);
+ builder.HasIndex(t => t.TokenHash).IsUnique();
+ builder.HasIndex(t => t.UserId);
+ builder.Ignore(t => t.IsActive);
+
+ builder.HasOne(t => t.User)
+ .WithMany()
+ .HasForeignKey(t => t.UserId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(t => t.ReplacedByTokenId)
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/RoleConfiguration.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/RoleConfiguration.cs
new file mode 100644
index 0000000..16dc96f
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/RoleConfiguration.cs
@@ -0,0 +1,16 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Infrastructure.Persistence.Configurations;
+
+public class RoleConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("roles");
+ builder.HasKey(r => r.Id);
+ builder.Property(r => r.Name).IsRequired().HasMaxLength(100);
+ builder.HasIndex(r => r.Name).IsUnique();
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/RolePermissionConfiguration.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/RolePermissionConfiguration.cs
new file mode 100644
index 0000000..cea9456
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/RolePermissionConfiguration.cs
@@ -0,0 +1,20 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Infrastructure.Persistence.Configurations;
+
+public class RolePermissionConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("role_permissions");
+ builder.HasKey(rp => rp.Id);
+ builder.HasIndex(rp => new { rp.RoleId, rp.Module, rp.Action }).IsUnique();
+
+ builder.HasOne(rp => rp.Role)
+ .WithMany(r => r.RolePermissions)
+ .HasForeignKey(rp => rp.RoleId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/TimeEntryConfiguration.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/TimeEntryConfiguration.cs
new file mode 100644
index 0000000..2122ce9
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/TimeEntryConfiguration.cs
@@ -0,0 +1,25 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Infrastructure.Persistence.Configurations;
+
+public class TimeEntryConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("time_entries");
+ builder.HasKey(t => t.Id);
+ builder.Property(t => t.Status).IsRequired().HasMaxLength(50);
+
+ builder.HasOne(t => t.Employee)
+ .WithMany()
+ .HasForeignKey(t => t.EmployeeId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ builder.HasOne(t => t.Order)
+ .WithMany()
+ .HasForeignKey(t => t.OrderId)
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/UserConfiguration.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/UserConfiguration.cs
new file mode 100644
index 0000000..e44a436
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/UserConfiguration.cs
@@ -0,0 +1,28 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Infrastructure.Persistence.Configurations;
+
+public class UserConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("users");
+ builder.HasKey(u => u.Id);
+ builder.Property(u => u.Username).IsRequired().HasMaxLength(100);
+ builder.HasIndex(u => u.Username).IsUnique();
+ builder.Property(u => u.PasswordHash).IsRequired();
+ builder.Property(u => u.SecurityStamp).IsRequired();
+
+ builder.HasOne(u => u.Role)
+ .WithMany(r => r.Users)
+ .HasForeignKey(u => u.RoleId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ builder.HasOne(u => u.Employee)
+ .WithMany()
+ .HasForeignKey(u => u.EmployeeId)
+ .OnDelete(DeleteBehavior.SetNull);
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/UserPermissionOverrideConfiguration.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/UserPermissionOverrideConfiguration.cs
new file mode 100644
index 0000000..f76c2b3
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/UserPermissionOverrideConfiguration.cs
@@ -0,0 +1,20 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using OmsorgCore.Domain.Entities;
+
+namespace OmsorgCore.Infrastructure.Persistence.Configurations;
+
+public class UserPermissionOverrideConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("user_permission_overrides");
+ builder.HasKey(o => o.Id);
+ builder.HasIndex(o => new { o.UserId, o.Module, o.Action }).IsUnique();
+
+ builder.HasOne(o => o.User)
+ .WithMany(u => u.PermissionOverrides)
+ .HasForeignKey(o => o.UserId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/DbSeeder.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/DbSeeder.cs
new file mode 100644
index 0000000..5ef3d8b
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/DbSeeder.cs
@@ -0,0 +1,50 @@
+using Microsoft.EntityFrameworkCore;
+using OmsorgCore.Application.Abstractions;
+using OmsorgCore.Domain.Entities;
+using OmsorgCore.Domain.Enums;
+using OmsorgCore.Infrastructure.Security;
+
+namespace OmsorgCore.Infrastructure.Persistence;
+
+///
+/// Legt beim ersten Start einen Standard-Administrator an, damit ein Login überhaupt möglich ist,
+/// solange es noch keinen echten Registrierungs-/User-Management-Flow gibt. Idempotent (prüft, ob
+/// bereits Benutzer existieren) und bewusst nur für Development gedacht - siehe Program.cs.
+///
+public static class DbSeeder
+{
+ private const string AdministratorRoleName = "Administrator";
+
+ public static async Task SeedDefaultAdminAsync(OmsorgCoreDbContext db, IPasswordHasher passwordHasher, SeedOptions options, CancellationToken cancellationToken = default)
+ {
+ if (await db.Users.AnyAsync(cancellationToken))
+ {
+ return;
+ }
+
+ var role = await db.Roles.FirstOrDefaultAsync(r => r.Name == AdministratorRoleName, cancellationToken);
+ if (role is null)
+ {
+ role = new Role { Name = AdministratorRoleName };
+ db.Roles.Add(role);
+
+ foreach (ModuleType module in Enum.GetValues())
+ {
+ foreach (PermissionAction action in Enum.GetValues())
+ {
+ role.RolePermissions.Add(new RolePermission { Role = role, Module = module, Action = action });
+ }
+ }
+ }
+
+ db.Users.Add(new User
+ {
+ Username = options.AdminUsername,
+ PasswordHash = passwordHasher.Hash(options.AdminPassword),
+ IsActive = true,
+ Role = role
+ });
+
+ await db.SaveChangesAsync(cancellationToken);
+ }
+}
diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260804154905_InitialCreate.Designer.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260804154905_InitialCreate.Designer.cs
new file mode 100644
index 0000000..8e3d3fb
--- /dev/null
+++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260804154905_InitialCreate.Designer.cs
@@ -0,0 +1,515 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using OmsorgCore.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace OmsorgCore.Infrastructure.Persistence.Migrations
+{
+ [DbContext(typeof(OmsorgCoreDbContext))]
+ [Migration("20260804154905_InitialCreate")]
+ partial class InitialCreate
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("OmsorgCore.Domain.Entities.Contract", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ContractType")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EmployeeId")
+ .HasColumnType("uuid");
+
+ b.Property("EndDate")
+ .HasColumnType("date");
+
+ b.Property("FacilityId")
+ .HasColumnType("uuid");
+
+ b.Property("StartDate")
+ .HasColumnType("date");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EmployeeId");
+
+ b.HasIndex("FacilityId");
+
+ b.ToTable("contracts", (string)null);
+ });
+
+ modelBuilder.Entity("OmsorgCore.Domain.Entities.Employee", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Address")
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DateOfBirth")
+ .HasColumnType("date");
+
+ b.Property("Email")
+ .HasColumnType("text");
+
+ b.Property("EntryDate")
+ .HasColumnType("date");
+
+ b.Property("ExitDate")
+ .HasColumnType("date");
+
+ b.Property("FirstName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("LastName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("text");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.ToTable("employees", (string)null);
+ });
+
+ modelBuilder.Entity("OmsorgCore.Domain.Entities.Facility", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Address")
+ .HasColumnType("text");
+
+ b.Property("BillingAddress")
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CrmStatus")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("Email")
+ .HasColumnType("text");
+
+ b.Property("FacilityType")
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("text");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.ToTable("facilities", (string)null);
+ });
+
+ modelBuilder.Entity("OmsorgCore.Domain.Entities.Invoice", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("BillingPeriodEnd")
+ .HasColumnType("date");
+
+ b.Property("BillingPeriodStart")
+ .HasColumnType("date");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property