# 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, AuditRedactedAttribute (Basisklassen) Enums/ # ModuleType, PermissionAction, PermissionEffect, AuditEventCategory, DocumentEntityType Entities/ # Employee, Facility, Contract, Order, TimeEntry, Invoice, Document, # User, Role, RolePermission, UserPermissionOverride, AuditLogEntry 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, FacilityRepository, FacilityContactRepository, DocumentRepository, UserRepository, RefreshTokenRepository, AuditLogRepository (implementieren Application-Interfaces) Storage/ # StorageOptions, DocumentUploadPolicy, FileSystemDocumentStorage (Dokument-Bytes auf Disk, siehe "Dokumentenarchiv" unten) 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), AuditEventHandler DependencyInjection.cs # AddEngine() OmsorgCore.Api/ # ASP.NET Core Web API. Abhängig von Application+Infrastructure+Engine. Controllers/ # AuthController, EmployeesController, FacilitiesController, FacilityContactsController, DocumentsController, HealthController, AdminSessionsController, AuditLogController 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. **`ModuleType.EmployeeFacilityDistances`** (additiv hinzugefügt 2026-08-10) gated ausschließlich `MyFacilityDistancesController` (Selbstbedienung, siehe "Konditionen einer Einrichtung" unten) — bewusst getrennt von `Facilities`, damit der Außendienst seine eigene Fahrtstrecke pflegen kann, ohne Konditionen/CRM-Daten von Einrichtungen zu sehen. Nur `Außendienst` bekommt es im Basis-Seed (`Create`/`View`/`Edit`, Scope `Own`, siehe `DbSeeder.SeedBaseRolesAsync`); Büro-Rollen verwalten Distanzen weiterhin über `EmployeeFacilityDistancesController`/`Facilities`-Recht. Die aufgelösten Rechte eines Users (nicht nur eine einzelne Prüfung) liefert `PermissionService.GetGrantedPermissionsAsync` als Liste von `PermissionGrant(Module, Action, Scope)`. Exponiert über `GET /api/auth/me` (`AuthController.Me`, `[Authorize]`) als `MeResponse { username, role, permissions: [{ module, action, scope }, ...] }` — 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]`. **Drei getrennte Admin-Rechte statt einer Sammelkategorie (seit 2026-08-09):** `UserManagement` deckte ursprünglich die komplette "Einstellungen"-Seite ab (Benutzerkonten, Rollen-Rechte-Matrix, User-Overrides, Status-Verwaltung, Debug/Sessions) — wer irgendeinen dieser Bereiche brauchte, bekam automatisch Zugriff auf alle, inklusive der Möglichkeit, sich selbst beliebige Rechte zu geben. Jetzt drei fachlich getrennte `ModuleType`-Werte: - **`Users`** — Benutzerkonten sehen/anlegen/(de)aktivieren/Passwort zurücksetzen (`UsersController`: `GetAll`/`Create`/`ResetPassword`/`Update`, alle `[RequirePermission(ModuleType.Users, ...)]`). Admin-UI: `omsorgapp/src/modules/settings/UsersPanel.jsx` (Tab "Benutzer" in `SettingsPage`). - **`UserManagement`** (enger als zuvor) — nur noch die rechte-eskalierenden Aktionen: Rollen-Rechte-Matrix + individuelle User-Permission-Overrides + Session-Killswitch (`AdminSessionsController`) + Test-Mail (`AdminEmailController`). Bewusst weiterhin ein eigenes, sensibleres Recht, weil hierüber Rechte selbst verändert werden. - **`Configuration`** — Status-Verwaltung/Auswahllisten (`ValueListsController`, alle schreibenden Endpoints `[RequirePermission(ModuleType.Configuration, PermissionAction.Edit)]`), fachlich unabhängig von Nutzerverwaltung. Die Sidebar-Sichtbarkeit von "Einstellungen" in `omsorgapp` ist deshalb kein einzelnes Modul mehr, sondern ein OR über alle drei (`navPermissions.js`, `SETTINGS_MODULES`, analog zum bestehenden `TRASH_MODULES`-Muster für den Papierkorb) — `SettingsPage.jsx` filtert die vier Tabs (Benutzer/Rollen/Benutzerrechte/Status-Verwaltung) zusätzlich einzeln nach ihrem jeweiligen Modul, ein Nutzer sieht also nur die Tabs, für die er tatsächlich `View` hat. **Wichtig für neue Rollen:** `GET /api/users` (Liste aller Benutzerkonten) prüft `Users`/`View` — wer individuelle `UserPermissionOverride`-Ausnahmen verwalten will (`UserManagement`/Edit), braucht zusätzlich `Users`/`View`, um überhaupt einen Nutzer zur Auswahl zu bekommen (`UserOverridesPanel.jsx`); ebenso nutzen `EmployeesPage.jsx` (Account-anlegen-Button in der Personalakte) und `AuditLogPage.jsx` (Akteur-Filter) `GET /api/users` und prüfen daher `Users`/View bzw. `Users`/Create, nicht mehr `UserManagement`. Der Basis-Rollen-Seed (`DbSeeder.SeedBaseRolesAsync`, siehe unten) wurde bei diesem Split **nicht** angepasst — nur `Geschäftsführung` iteriert ohnehin generisch über `Enum.GetValues()` und bekommt damit automatisch alle drei neuen Rechte, die anderen Basis-Rollen hatten vorher kein `UserManagement` und haben jetzt entsprechend auch keins der drei neuen Rechte. Eine Vergabe (z. B. `Configuration` an Disposition/Buchhaltung) ist eine bewusste, spätere Entscheidung über die Rollen-UI, kein Teil dieses technischen Splits. **Rollen-Rechte-Matrix und User-Overrides verwalten (Admin-Flow):** Eine neu angelegte Rolle (`RoleService.CreateAsync`) hat zunächst keine `RolePermission`-Einträge — die Rechte-Matrix wird separat gesetzt über `GET /api/roles/{id}` (Rolle inkl. ihrer aktuellen `RolePermission`-Liste, `RoleService.GetByIdWithPermissionsAsync`) und `PUT /api/roles/{id}/permissions` (`RoleService.UpdatePermissionsAsync` — ersetzt die komplette `RolePermission`-Menge der Rolle durch die übergebene Menge, kein inkrementelles Patchen). Individuelle `UserPermissionOverride`-Ausnahmen eines Users werden über `GET/POST/DELETE /api/users/{id}/permission-overrides[...]` verwaltet (`UserService.GetPermissionOverridesAsync`/`AddPermissionOverrideAsync`/`RemovePermissionOverrideAsync` — `AddPermissionOverrideAsync` ist ein Upsert: existiert bereits ein Override für dasselbe Modul+Aktion bei diesem User, werden dessen `Effect` **und** `Scope` aktualisiert statt dupliziert). Diese Endpoints liegen auf `RolesController`/`UsersController` (die `.../permission-overrides`-Routen), gegated über `[RequirePermission(ModuleType.UserManagement, View|Edit)]`. Admin-UI dazu: `omsorgapp/src/modules/settings/` (`SettingsPage`, `RolesPanel`, `RolePermissionMatrix`, `UserOverridesPanel`). **Rolle löschen (seit 2026-08-10):** `DELETE /api/roles/{id}` (`[RequirePermission(ModuleType.UserManagement, PermissionAction.Delete)]`) — Löschschutz analog zu `ValueListService.DeleteItemAsync`, aber ohne die generische `IValueListUsageChecker`-Abstraktion, da eine Rolle nur über genau einen Ownership-Anker referenziert wird (`User.RoleId`, echte FK statt Freitext): `RoleService.DeleteAsync` prüft über `IUserRepository.GetUserIdsByRoleAsync` (bereits für die Cache-Invalidierung bei Rechte-Änderungen vorhanden), ob noch Benutzer dieser Rolle zugewiesen sind, und lehnt mit `409` + Anzahl im Klartext ab (`DeleteRoleResult`/`DeleteRoleFailureReason`), statt zu löschen. Hard-Delete (kein Soft-Delete/Papierkorb) — `Role` ist `Entity`, nicht `AuditableEntity`, kein eigenständiges Core-Objekt mit Löschprotokoll-Anforderung. **Wichtig:** `DbSeeder.SeedBaseRolesAsync` legt eine der vier Basis-Rollen (Geschäftsführung/Disposition/Buchhaltung/Recruiting/Außendienst) bei fehlendem Namen bei jedem Start erneut an (idempotent pro Rollenname, siehe dort) — eine gelöschte Basis-Rolle taucht nach einem Neustart automatisch wieder auf, das ist bestehendes, dokumentiertes Verhalten, keine Neuerung dieses Schritts. Frontend: `RolesPanel.jsx` zeigt einen Löschen-Button je Rolle (nur mit `UserManagement`/`Delete`), zeigt die `409`-Fehlermeldung des Servers an statt sie zu verschlucken. **Datenebenen-Scope (`PermissionScope`, "nur eigene Daten"):** Dritte Dimension neben Modul×Aktion — jede `RolePermission`/`UserPermissionOverride`-Zeile trägt zusätzlich `Scope` (`All` oder `Own`, `src/OmsorgCore.Domain/Enums/PermissionScope.cs`). `PermissionService.GetScopeAsync(userId, module, action)` löst das auf und liefert `PermissionScope?` (`null` = gar nicht gewährt) — ein Override ersetzt dabei die komplette Zelle (Grant **und** Scope) der Rolle, es wird nicht gemergt, analog zur bestehenden Effect-Semantik. `HasPermissionAsync`/`RequirePermissionAttribute` bleiben bewusst scope-unabhängig (ein Own-User muss den Endpunkt-Gate trotzdem passieren) — die eigentliche Einschränkung passiert in den Application-Services: `EmployeeService`/`ContractService` konsultieren `GetScopeAsync` vor `GetPagedAsync`/`GetByIdAsync` und filtern bei `Own` auf `ICurrentUserService.EmployeeId` (neuer JWT-Claim `"employeeId"`, aus `User.EmployeeId`, nur eingebettet wenn gesetzt — wirkt daher erst mit dem nächsten Token-Refresh, wenn die Verknüpfung sich ändert). Own ohne verknüpfte `EmployeeId` liefert bewusst keine Datensätze (nicht "alle", nicht 500). **`Employees`, `Contracts`, `Absences` und `TimeEntries` werten den Scope aktuell aus** (alle vier haben einen Ownership-Anker: `User.EmployeeId`, `Contract.EmployeeId`, `Absence.EmployeeId`, `TimeEntry.EmployeeId` — siehe "Abwesenheits-/Urlaubs-/Krankmeldungsanträge" und "Zeiterfassung" unten) — alle anderen Module ignorieren `Scope` faktisch, weil ihnen kein Ownership-Anker zugrunde liegt (`Order` z. B. hat noch keinen Mitarbeiter-Bezug). **Wichtig:** Wird ein neuer `ModuleType` oder `PermissionAction`-Wert hinzugefügt, oder ändert sich sonst das Rollen-/Rechtesystem, muss dieser Abschnitt (Rechtesystem) im selben Change aktualisiert werden — diese Dokumentation ist keine Momentaufnahme, sondern muss mit der Software mitwachsen. ## Audit-Log ("wer hat wann was verändert") Zwei sich ergänzende Erfassungswege, beide münden in dieselbe Tabelle `AuditLogEntry` (`src/OmsorgCore.Domain/Entities/AuditLogEntry.cs`, `Category` unterscheidet die Herkunft): 1. **Automatisch, Entity-Änderungen:** `AuditSaveChangesInterceptor` (`src/OmsorgCore.Infrastructure/Persistence/AuditSaveChangesInterceptor.cs`, ein EF-Core-`SaveChangesInterceptor`) erfasst bei **jedem** `SaveChangesAsync` auf `OmsorgCoreDbContext` automatisch jede Create/Update/(Soft-)Delete-Änderung an einer beliebigen `AuditableEntity`-Subklasse — inkl. aller künftigen (Facility/Contract/Order/TimeEntry/Invoice), **ohne dass dafür Code in deren Services/Controllern nötig ist**. Bei Update wird nur der tatsächliche Feld-Diff (`{old, new}` je geändertem Feld) als JSON in `Details` gespeichert; ein Soft-Delete (`IsDeleted: false→true`) wird als Action `"Deleted"` erkannt, nicht als `"Updated"`. Sensible Felder (aktuell `User.PasswordHash`/`User.SecurityStamp`) sind mit `[AuditRedacted]` (`src/OmsorgCore.Domain/Common/AuditRedactedAttribute.cs`) markiert — der Interceptor ersetzt ihren Wert im Log durch `"***redacted***"`. **Neue sensible Felder in künftigen Entitäten müssen dieses Attribut bekommen**, sonst landen sie im Klartext im Audit-Log. 2. **Explizit, Verhaltens-Ereignisse ohne Entity-Änderung:** `AuditEvent` (`src/OmsorgCore.Engine/Events/AuditEvent.cs`) über den bestehenden `IDomainEventDispatcher` dispatcht, analog zu `EmployeeCreatedEvent` (siehe "Event-Schicht" unten) — für Aktionen wie Login/Logout/Session-Kill, die keine `AuditableEntity` verändern. `AuditEventHandler` (`Engine/Handlers/`) persistiert das Event als `AuditLogEntry` mit `Category = BehavioralEvent`. Aktuelle Dispatch-Stellen: `AuthController.Login` (`"Login"`/`"LoginFailed"`), `AuthController.Logout` (`"Logout"`), `AdminSessionsController.Revoke`/`RevokeAll` (`"SessionRevoked"`/`"AllSessionsRevoked"`). **Neue Aktionen folgen demselben Muster:** eine Zeile `await _dispatcher.DispatchAsync(new AuditEvent(actorUserId, actorUsername, ipAddress, "MeineAktion", details))` an der auslösenden Stelle im Api-Layer. Bewusst **kein** Event bei `AuthController.Refresh` (zu häufig/geräuschig für eine sliding Session, kein eigenständiges "wer hat was getan"-Faktum). Aktor-Informationen (`Username`/`RoleName`/`IpAddress`) kommen über `ICurrentUserService` (erweitert um diese drei Properties, Implementierung `CurrentUserService` liest sie aus JWT-Claims bzw. `HttpContext.Connection.RemoteIpAddress`). **Einsicht:** `GET /api/audit-log` (`AuditLogController`, Query-Filter `entityType`/`entityId`/`actorUserId`/`fromUtc`/`toUtc` + Pagination), gegated über `[RequirePermission(ModuleType.AuditLog, PermissionAction.View)]`. Per Default nur die Rolle `Geschäftsführung` (`DbSeeder.SeedBaseRolesAsync` iteriert für sie generisch alle `ModuleType`-Werte, siehe dort) — alle anderen Basis-Rollen sehen das Audit-Log nicht. ## 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`): nur noch `accessToken`, `expiresAt`, `mustChangePassword` (camelCase, Default-JSON-Serialisierung von ASP.NET Core) — der Refresh-Token selbst geht **nicht** im Body raus, sondern als **HttpOnly-Secure-Cookie** (`refreshToken`, `Path=/api/auth`, `AuthController.SetRefreshTokenCookie`, `Secure` nur außerhalb von Development, da der lokale Dev-Server standardmäßig nur über `http://` läuft), seit `omsorgapp` als Browser-SPA (nicht mehr Electron) läuft und ein Browser-Frontend den Token sonst nie sicher clientseitig halten könnte (kein `safeStorage`-Äquivalent im Browser). Das setzt CORS mit `AllowCredentials()` voraus (`Program.cs`, `Cors:AllowedOrigins`-Config), sonst schickt der Browser die Cookie nicht mit. 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]`, kein Body — der Refresh-Token kommt aus der Cookie, die selbst das Credential ist): `AuthService.RefreshAsync` prüft den Refresh-Token per Hash-Lookup, **rotiert** ihn (alten Token widerrufen, neuen ausstellen, per `ReplacedByTokenId` verkettet, neue Cookie gesetzt) und liefert ein neues Token-Paar. Erlaubt langlebige Sessions ohne täglichen Passwort-Login (siehe `omsorgapp/CLAUDE.md`). 5. `POST /api/auth/logout` (kein Body) widerruft den Refresh-Token aus der Cookie (`AuthService.RevokeAsync`, idempotent) und löscht die Cookie (`Response.Cookies.Delete`). **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`. **Passwort-Mindestlänge:** zentral über `IPasswordPolicy`/`PasswordPolicy` (`src/OmsorgCore.Infrastructure/Security/PasswordPolicy.cs`), gebunden an `PasswordPolicy:MinLength` in `appsettings.json` (Default 8, überschreibbar per `PasswordPolicy__MinLength`, siehe `CONFIGURATION.md`). Wird von `UserService` (Account-Anlage im Direct-Modus, Admin-Reset, `ChangeOwnPasswordAsync`) und `PasswordResetService.ResetPasswordAsync` injiziert geprüft — **nicht** mehr in den Controllern dupliziert. Bei Verstoß liefern die jeweiligen `*Result`-Typen einen `PasswordTooShort`-Wert, den `UsersController`/`AuthController` auf `400 BadRequest` mit der aktuell konfigurierten Zahl im Fehlertext abbilden. `GET /api/auth/password-policy` (kein `[Authorize]`) liefert `{ minLength }` für Clients, die denselben Wert für Hinweistexte/Vorab-Validierung brauchen (`omsorgapp`: `AuthContext.jsx`; `omsorgWeb/mitarbeiter-app`: `omsorgcore_password_policy()` in `lib/omsorgCoreClient.php`). **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. **Basis-Rollen-Seed (alle Umgebungen):** `DbSeeder.SeedBaseRolesAsync` (`src/OmsorgCore.Infrastructure/Persistence/DbSeeder.cs`) legt bei jedem Start die vier in `REQUIREMENTS.md` Abschnitt 3 ("Akteure & Rollen") und Abschnitt 7 ("Rechtematrix") beschriebenen Basis-Rollen an — `Geschäftsführung` (Sabina/Malik, voller Zugriff auf alle Module), `Disposition/Buchhaltung` (Sabrina, u. a. `Absences` mit allen Aktionen), `Recruiting` (Sascha), `Außendienst` (`Employees.View` + `Contracts.View` + `Absences.{Create,View}`, alle drei mit `PermissionScope.Own` — siehe "Datenebenen-Scope" oben; `Absences` ist dabei bereits an `omsorgWeb/mitarbeiter-app` angebunden, siehe "Abwesenheits-/Urlaubs-/Krankmeldungsanträge" unten, während `Employees`/`Contracts` dort noch nicht konsumiert werden). Läuft in Program.cs direkt nach den Migrationen, **nicht** auf `IsDevelopment()` beschränkt (im Gegensatz zum Admin-Seed unten) — enthält keine Zugangsdaten, nur Rollen-Stammdaten. Idempotent pro Rollenname: existiert eine Rolle schon (z. B. weil sie über die Rechte-Matrix-UI unter "Einstellungen" angepasst wurde), fasst der Seed sie nicht an. **Wichtig:** Die Zuordnung in `SeedBaseRolesAsync` ist eine Übersetzung der Rechtematrix aus `REQUIREMENTS.md` auf die aktuellen `ModuleType`/`PermissionAction`-Werte. Kommt ein neuer `ModuleType`/eine neue `PermissionAction` dazu, oder ändert sich die Rechtematrix in `REQUIREMENTS.md`, muss dieser Seed im selben Change mit aktualisiert werden — er ist keine Momentaufnahme, sondern muss mit der Software mitwachsen (siehe auch den allgemeinen Pflegehinweis am Ende dieses Abschnitts). **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. `Revoke`/`RevokeAll` dispatchen jeweils zusätzlich ein `AuditEvent` (`"SessionRevoked"`/`"AllSessionsRevoked"`, siehe "Audit-Log" oben) — ein Session-Kill hinterlässt damit nachvollziehbar, welcher Aktor ihn wann ausgelöst hat. **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. **Fehlerbehandlung bei SMTP-Fehlschlag (seit 2026-08-10):** `SmtpEmailSender.SendAsync` selbst hat bewusst **kein** try/catch (MailKit-Exceptions sollen bis zum Aufrufer durchschlagen, nicht dort verschluckt werden) — die Behandlung passiert an den beiden Stellen, wo der Versand tatsächlich ausgelöst wird: - `AuthController.ForgotPasswordRequest` fängt eine fehlgeschlagene `IDomainEventDispatcher.DispatchAsync(PasswordResetRequestedEvent)` ab (loggt via `ILogger`) und antwortet mit `ForgotPasswordRequestResponse("email_unavailable")` statt `"sent"` — der Reset-Code selbst wurde da schon in der DB angelegt (`PasswordResetService.RequestResetAsync`), nur die Mail kam nicht raus. **Kein zusätzliches Enumeration-Risiko** ggü. dem Status quo: `"sent"` vs. `"cannot_reset"` unterscheidet bereits heute, ob der Username existiert — `"email_unavailable"` ist nur ein dritter, ebenso ehrlicher Zustand für "Username existiert, aber die Mail-Infrastruktur ist gerade kaputt". Beide Frontends müssen `"email_unavailable"` separat von `"sent"` behandeln (nicht zum PIN-Eingabe-Schritt weitergehen) — umgesetzt in `omsorgapp/src/modules/auth/ForgotPasswordUsernamePage.jsx` und `omsorgWeb/mitarbeiter-app/pages/forgot-password.php`. - `AdminEmailController.TestSend` fängt den Fehler ebenfalls (loggt via `ILogger`) und antwortet `502` mit `{ error: "send_failed", message: }` statt eines rohen, unbehandelten `500` — die Exception-Message darf hier raus (nur `UserManagement`/`Edit`-Admins erreichen den Endpoint), sie enthält keine Zugangsdaten, nur Diagnoseinfos wie "Authentication failed"/"Connection refused". `DebugSessionsPage.jsx` zeigt `result.data.message` direkt an. ## 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. `AddContractDetailsAndQueryFilter` ist erzeugt, aber noch nicht gegen eine echte Instanz verifiziert (wird beim nächsten API-Start automatisch angewendet). `AddPermissionScope` (fügt `Scope` auf `role_permissions`/`user_permission_overrides` hinzu, siehe "Rechtesystem") wurde per `dotnet ef database update` erfolgreich gegen die echte Instanz angewendet. `AddFacilityConditionsAndQualificationRates` (Konditionen-Felder auf `Facility` + Tabelle `facility_qualification_rates`, FR-EIN-4), `AddAbsences` (Tabelle `absences`, FR-CON-1/FR-EM-3) und `AddTimeEntryStatusAndSurchargeHours` (`TimeEntry.StatusId`+Zuschlagsstunden, `ValueListItem.IsEditableByOwner`, `ValueListItemTransition.RequiresApproval`, FR-ZE-1/FR-ZE-2) wurden beim automatischen API-Start erfolgreich gegen die echte Instanz angewendet. `AddTravelCostModeAndEmployeeFacilityDistances` (`Facility.TravelCostMode`/`TravelCostPerKm` + Tabelle `employee_facility_distances`, siehe "Konditionen einer Einrichtung") ist erzeugt (`dotnet build`/`dotnet test` grün), aber noch nicht gegen eine echte Instanz verifiziert. ## 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. - `DbSeeder.SeedBaseRolesAsync` gegen echte PostgreSQL-Instanz verifiziert: legt `Geschäftsführung`/`Disposition/Buchhaltung`/`Recruiting`/`Außendienst` mit der erwarteten Rechteanzahl an (54/33/10/0 Permissions), zweiter Lauf verändert nichts (idempotent pro Rollenname). **Hinweis:** Seit `AddPermissionScope` bekommt `Außendienst` neu `Employees.View`+`Contracts.View` (Scope `Own`), seit den Absences-/TimeEntries-/EmployeeFacilityDistances-Erweiterungen zusätzlich `Absences.{Create,View,Edit}`, `TimeEntries.{Create,View,Edit}` und `EmployeeFacilityDistances.{Create,View,Edit}` (alle Scope `Own`) — die Zahl "0" für Außendienst ist damit veraltet (jetzt 11 erwartet), aber noch nicht erneut per echtem Seed-Lauf verifiziert (der Seed läuft nur bei leerer `Roles`-Tabelle bzw. pro fehlendem Rollennamen, nicht erneut gegen eine bereits befüllte Instanz). ## Konfigurierbare Auswahllisten Dropdown-Werte, die früher als hartcodierte Arrays im `omsorgapp`-Frontend lebten (Mitarbeiterstatus, Beschäftigungsart, CRM-Status, Einrichtungstyp) plus die entsprechenden, bisher nur als freier String modellierten Felder auf `Contract` (Vertragstyp/-status) und der Auftragsstatus (FR-EM-2) sind jetzt eine gemeinsame, admin-editierbare Stammdaten-Struktur statt Enum/hartcodiertes Array — Ziel: Löschen/Umbenennen/Hinzufügen ohne Code-Deploy, über die "Status-Verwaltung" unter "Einstellungen" in `omsorgapp`. **Datenmodell** (`src/OmsorgCore.Domain/Entities/`): `ValueList` (Stammdaten einer Liste — `Key`, eindeutig, z. B. `"EmployeeStatus"`, `"EmploymentType"`, `"CrmStatus"`, `"FacilityType"`, `"ContractType"`, `"ContractStatus"`, `"OrderStatus"`, `"DocumentCategory"`, `"Qualification"`, `"ShiftType"`, `"Priority"`, `"BillingInterval"`, `"AbsenceType"`, `"AbsenceStatus"`; `DisplayName` für die Admin-UI) + `ValueListItem` (`Value`, `SortOrder`, `IsDefault`, `IsInitial`/`IsTerminal` — `IsTerminal` nur für `"OrderStatus"` relevant, `IsInitial` zusätzlich für `"AbsenceStatus"` (markiert dort den "noch nicht entschieden"-Zustand, siehe "Abwesenheits-/Urlaubs-/Krankmeldungsanträge" unten)) + `ValueListItemTransition` (erlaubte Übergänge zwischen zwei Items derselben Liste, wird nur für `"OrderStatus"` befüllt). Ersetzt das frühere `OrderStatusDefinition`/`OrderStatusTransition`-Sondermodell — Migration `ReplaceOrderStatusWithValueLists` übernimmt bestehende Auftragsstatus-Zeilen 1:1 mit identischen Ids in die neuen Tabellen, damit `Order.StatusId` unverändert gültig bleibt. **Wo welches Feld referenziert wird:** - `Order.StatusId` (FK, echte Fremdschlüsselbeziehung auf `ValueListItem.Id`) — einzige Liste mit Übergangsregeln. `OrderService.CreateAsync`/`UpdateAsync` nutzen `IValueListRepository.GetInitialItemAsync("OrderStatus", ...)`/`CanTransitionAsync(...)` genau wie zuvor `IOrderStatusRepository`. - `Employee.Status`/`EmploymentType`, `Facility.CrmStatus`/`FacilityType`, `Contract.ContractType`/`Status` bleiben bewusst einfache `string`-Spalten (kein FK, keine Schema-Migration auf diesen Tabellen nötig) — stattdessen prüfen `EmployeesController`/`FacilitiesController`/`ContractsController` beim Schreiben serverseitig über `IValueListRepository.GetActiveValuesAsync(key, ...)`, dass der übergebene Wert unter den aktuell konfigurierten Werten der zugehörigen Liste ist (`400` sonst) — analog zur bereits bestehenden Passwort-Policy-Validierung. - `Employee.Qualification`, `Order.RequiredQualification` **und** `FacilityQualificationRate.Qualification` (siehe "Konditionen einer Einrichtung" unten) referenzieren alle drei dieselbe Liste `"Qualification"` (ebenfalls einfache `string`-Spalten, gegen `GetActiveValuesAsync`/`GetItemsAsync` validiert wie oben — `EmployeesController`/`OrdersController`/`FacilityQualificationRatesController`). Bewusst eine gemeinsame Liste statt getrennter, damit "benötigte Qualifikation" auf einem Auftrag, "Qualifikation" eines Mitarbeiters und der qualifikationsabhängige Preis einer Einrichtung aus derselben, gleich sortierten Werteliste kommen — Voraussetzung für einen künftigen automatisierten Abgleich (FR-EM-3, Mitarbeiterzuweisung prüft Qualifikation, noch nicht umgesetzt). `SortOrder` bildet dabei die Rangfolge der Qualifikationsniveaus ab (aufsteigend, siehe Seed unten) — es gibt bewusst kein zusätzliches "Level"-Feld, `SortOrder` übernimmt diese Rolle bereits für jede Liste. Da der Wert von mehreren Entitäten referenziert wird, prüft **eine eigene** `IValueListUsageChecker`-Implementierung (`QualificationValueListUsageChecker`, nicht der generische `StringFieldValueListUsageChecker`) alle drei Tabellen — `ValueListService.FindUsagesAsync` befragt pro Key nur den ersten registrierten Checker, separate Registrierungen mit demselben Key hätten weitere Verwendungsstellen beim Löschschutz stillschweigend ignoriert. - `Order.ShiftType` referenziert die Liste `"ShiftType"` (nur eine Entität betroffen, daher regulärer `StringFieldValueListUsageChecker` wie bei den übrigen einfachen String-Listen). - `Order.Priority` (Pflichtfeld, kein `?`) referenziert die Liste `"Priority"` (Niedrig/Normal/Hoch/Dringend, Default "Normal" passend zum Entity-Default). `GET /api/orders` unterstützt zusätzlich `?priority=`/`?requiredQualification=`/`?shiftType=` als exakte Gleichheitsfilter (neben den bereits bestehenden `?statusId=`/`?facilityId=`, `OrderRepository.GetPagedAsync`) — **Achtung:** ein neuer Query-Parameter auf einem bestehenden Endpoint ändert die OpenAPI-Spec genauso wie ein geändertes Contract-DTO, `omsorgapp/api-client-ts` muss danach ebenfalls neu generiert werden (siehe "Generierte API-Clients" unten), sonst kennt der generierte `OrdersApi.apiOrdersGetRaw` den Parameter nicht und die Filterung wirkt sich nicht aus, obwohl Backend und Frontend-Code beide "richtig" aussehen. - `Facility.BillingInterval` referenziert die Liste `"BillingInterval"` (Wöchentlich/Monatlich/Quartalsweise, nur eine Entität betroffen, regulärer `StringFieldValueListUsageChecker`), Teil der Konditionen einer Einrichtung — siehe eigener Abschnitt "Konditionen einer Einrichtung" unten. - `Absence.Type`/`Status` referenzieren `"AbsenceType"` (Urlaub/Krankmeldung/Sonstige) bzw. `"AbsenceStatus"` (Eingereicht/Genehmigt/Abgelehnt) — beide einfache `string`-Spalten wie `Contract.Status`, **ohne** `ValueListItemTransition` (kein Übergangsgraph nötig), aber mit `IsInitial` auf dem "Eingereicht"-Item, damit "noch nicht entschieden" nicht als Anzeigetext-Vergleich hartkodiert werden muss (siehe eigener Abschnitt "Abwesenheits-/Urlaubs-/Krankmeldungsanträge" unten). **Verwaltungs-API** (`ValueListsController`, Route `api/value-lists`): `GET /api/value-lists` (alle Listen), `GET /api/value-lists/{key}/items` (nur `[Authorize]`, kein Modul-Recht — die aufrufenden Formulare gehören zu unterschiedlichen Modulen), `POST`/`PUT/DELETE .../items[/...]` sowie `GET/PUT .../transitions` (nur für `"OrderStatus"`) gegated über `[RequirePermission(ModuleType.Configuration, PermissionAction.Edit)]` — ein eigenes Admin-Recht, getrennt von Benutzer-/Rechteverwaltung (siehe "Rechtesystem" oben, Abschnitt "Drei getrennte Admin-Rechte"). **Löschschutz ("erst überall entfernen"):** `ValueListService.DeleteItemAsync` löscht ein `ValueListItem` nur, wenn keine Verwendung mehr existiert. Eine `IValueListUsageChecker`-Implementierung je Liste (`Infrastructure/Repositories/StringFieldValueListUsageChecker.cs` — eine generische Klasse für alle String-Feld-Listen, mehrfach mit unterschiedlicher Query registriert in `Infrastructure/DependencyInjection.cs`; `OrderStatusValueListUsageChecker.cs` für die FK-basierte `"OrderStatus"`-Liste inkl. Übergangsregeln) prüft, ob der Wert noch irgendwo gesetzt ist. Bei Treffern liefert `DELETE .../items/{id}` `409` mit den Fundstellen (`EntityType`/`EntityId`/`DisplayLabel`) im Body, statt zu löschen. `GET .../items/{id}/usages` liefert dieselbe Prüfung jederzeit (nicht nur beim Löschversuch) — für den "wo wird das noch verwendet"-Info-Button in der UI. **Seed:** `DbSeeder.SeedValueListsAsync` (jede Umgebung, idempotent — läuft nur, solange `ValueLists` leer ist) legt alle zwölf Listen mit Startwerten an, inkl. der Auftragsstatus-Pipeline (Anfrage → Prüfung → offen → teilweise besetzt → vollständig besetzt → aktiv → abgeschlossen, plus Storno aus jedem nicht-terminalen Status) samt Übergangsregeln, sowie `"Qualification"` mit sieben Startwerten in aufsteigender Rangfolge (Ungelernte Kraft → Betreuungskraft → Pflegehelfer/in → Pflegefachassistent/in → Altenpfleger/in → Gesundheits- und Krankenpfleger/in → Pflegefachkraft mit Leitungsfunktion), `"ShiftType"` mit sechs Startwerten (Frühdienst, Spätdienst, Nachtdienst, Tagdienst, Bereitschaftsdienst, Sonstige), `"Priority"` mit vier Startwerten (Niedrig, Normal, Hoch, Dringend) und `"BillingInterval"` mit drei Startwerten (Wöchentlich, Monatlich (Default), Quartalsweise) — reine Startbefüllung, über "Status-Verwaltung" admin-editierbar. **Wichtig:** Kommt eine neue admin-editierbare Auswahlliste hinzu, gehört sie hier als weiterer `SeedSimpleListAsync`-Aufruf rein plus eine `IValueListUsageChecker`-Registrierung in `Infrastructure/DependencyInjection.cs` — dieser Abschnitt und der Seed müssen mit der Software mitwachsen. **Frontend (`omsorgapp`):** `src/api/valueListsApi.js` kapselt `/api/value-lists`, `src/app/useValueListItems.js` (Hook) lädt die Items einer Liste für Dropdowns (ersetzt die früheren hartcodierten Arrays in `EmployeeForm.jsx`/`FacilityForm.jsx`/`EmployeesPage.jsx`/`FacilitiesPage.jsx`). Verwaltungs-UI: `src/modules/settings/StatusManagementPanel.jsx`, Tab "Status-Verwaltung" in `SettingsPage.jsx`, gegated über `hasPermission("Configuration", ...)`. ## Dokumentenarchiv (FR-MA-3) `Document` (`src/OmsorgCore.Domain/Entities/Document.cs`, `AuditableEntity`) referenziert eine beliebige Kern-Entität polymorph über `EntityType` (String-Wert aus `DocumentEntityType`, aktuell nur `Employee` mit echtem Upload-/Validierungspfad — `Facility`/`Contract`/`Order` sind als Enum-Werte für ein künftiges Ausrollen vorgesehen, ohne Migration nachzuziehen) + `EntityId` (kein FK, da mehrere Zieltabellen). `Category` wird wie `Employee.Status`/`Contract.ContractType` gegen die admin-editierbare ValueList `"DocumentCategory"` validiert (siehe "Konfigurierbare Auswahllisten"). **Bytes liegen auf dem Dateisystem, nicht als Blob in Postgres:** `IDocumentStorage`/`FileSystemDocumentStorage` (`Infrastructure/Storage/`) legt Uploads unter `Storage:DocumentsRootPath/{EntityType}/{EntityId}/{DocumentId}{Extension}` ab (Guids im Pfad, kein Client-String — kein Path-Traversal-Vektor); die DB speichert nur den relativen Pfad (`Document.StorageKey`) + Metadaten. Größen-/Dateityp-Grenzen (`Storage:MaxDocumentSizeBytes`/`Storage:AllowedDocumentContentTypes`, siehe `CONFIGURATION.md`) prüft `IDocumentUploadPolicy`/`DocumentUploadPolicy`, injiziert in `DocumentService.UploadAsync` — analog zu `IPasswordPolicy`. **API** (`DocumentsController`, Route `api/documents`, gegated über `[RequirePermission(ModuleType.Documents, ...)]`): `GET ?entityType=&entityId=` (Liste), `POST` (multipart/form-data, `UploadDocumentRequest` mit `IFormFile`; bei `EntityType=Employee` prüft der Controller zusätzlich per `IEmployeeRepository`, dass der Mitarbeiter existiert — analog zur `FacilityContactId`-Cross-Validierung in `OrdersController`), `PUT /{id}` (nur Metadaten — `Category`/`Description`/`FileName`, JSON-Body, kein erneuter Datei-Upload — `PermissionAction.Edit`), `GET /{id}/download` (streamt die Datei), `DELETE /{id}` (Soft-Delete). **Zugriffsprotokoll (FR-MA-3-Anforderung):** Der automatische `AuditSaveChangesInterceptor` erfasst nur Create/Update/(Soft-)Delete an `Document`, nicht das lesende Herunterladen. `DocumentsController.Download` dispatcht deshalb zusätzlich ein `AuditEvent(..., "DocumentDownloaded", ...)` über `IDomainEventDispatcher` — derselbe Mechanismus wie Login/Logout/Session-Kill (siehe "Audit-Log" oben). Jeder Download ist damit über `GET /api/audit-log` nachvollziehbar. **Rechtematrix:** `ModuleType.Documents` ist Teil der Basis-Rollen-Seed (`DbSeeder.SeedBaseRolesAsync`) — `Geschäftsführung` und `Disposition/Buchhaltung` (Sabrina) haben vollen Zugriff (FR-MA-3: Sabina/Malik/Sabrina), `Recruiting`/`Außendienst` bewusst nicht. Ein Außendienst-Selbstzugriff ("nur eigene Dokumente") ist noch nicht abgebildet, weil OMSORG Connect noch nicht gegen dieses Backend spricht (siehe "Offene Punkte"). ## Konditionen einer Einrichtung (FR-EIN-4) Elf der zwölf Blueprint-19.2-Konditionsfelder (Verrechnungssatz, vier Zuschläge, Fahrtkosten, Mindeststunden, Pausenregelung, Abrechnungsintervall, Zahlungsziel, individuelle Vereinbarungen) sind flache, nullable Felder direkt auf `Facility` — kein `OwnsOne`/keine eigene Tabelle, analog zu Adresse/Rechnungsadresse auf `Facility` selbst und den Finanzfeldern auf `Contract`: - `BillingRate` (Verrechnungssatz, EUR/Std.), `NightSurchargePercent`/`SaturdaySurchargePercent`/`SundaySurchargePercent`/`HolidaySurchargePercent` (Zuschläge als **Prozent** auf den Verrechnungssatz, nicht als EUR-Betrag), `MinimumHours` (Mindeststunden je Einsatz), `BreakPolicy` (Pausenregelung, Freitext), `PaymentTermDays` (Zahlungsziel in Tagen), `IndividualAgreements` (Freitext). - **Fahrtkosten (seit 2026-08-10 zwei Modi statt nur Pauschale):** `TravelCostMode` (`"Pauschale"` oder `"ProKilometer"`, Default `"Pauschale"`, im Controller gegen ein festes Literal-Array geprüft — **bewusst keine `ValueList`**, da die Rechnungserstellung später hart zwischen genau diesen zwei Fällen unterscheiden muss, ein dritter admin-hinzufügbarer Wert würde die künftige `FR-RE-1`-Berechnungslogik lautlos brechen, analog zur Begründung bei `AbsenceStatus.IsInitial`) entscheidet, welches Feld gilt: `TravelCostRate` (Pauschale je Einsatz, EUR) oder `TravelCostPerKm` (EUR/km) × `EmployeeFacilityDistance.DistanceKm`. Eine feste Facility-Distanz reicht nicht, weil jeder Mitarbeiter von einem anderen Wohnort anfährt — daher `EmployeeFacilityDistance` (`Facility`×`Employee` → `DistanceKm`, `AuditableEntity`) als **zweite** 1:n-Unterressource von Facility, exakt nach dem `FacilityQualificationRate`-Muster: `GET/POST/PUT/DELETE /api/facilities/{facilityId}/employee-distances[/...]` (`EmployeeFacilityDistancesController`), gegated über dieselben `Facilities`-Rechte, kein eigener `ModuleType`. `Create` validiert zusätzlich, dass `EmployeeId` auf einen existierenden Mitarbeiter zeigt (`IEmployeeService.GetByIdAsync`) und dass für das Facility/Employee-Paar noch keine (nicht gelöschte) Distanz existiert. Soft-Delete, über `TrashController` (`api/trash/employee-facility-distances/...`) wiederherstellbar. - **Selbstbedienung durch den Außendienst (seit 2026-08-10, `MyFacilityDistancesController`, Route `api/me/facility-distances`):** Mitarbeiter sollen ihre eigene Fahrtstrecke selbst über `omsorgWeb/mitarbeiter-app` pflegen können, statt dass das Büro jede Kilometerangabe manuell einträgt. Dafür bewusst **kein** `Facilities`-Recht (das würde Konditionen/CRM-Daten offenlegen, die dem Außendienst laut Rechtematrix nicht zustehen), sondern ein neuer, eigenständiger `ModuleType.EmployeeFacilityDistances` (siehe "Rechtesystem" oben) — `GET /api/me/facility-distances` (eigene Distanzen inkl. `FacilityName`), `GET /api/me/facility-distances/facilities` (minimale Einrichtungsauswahl, nur `Id`/`Name` über `FacilityOptionResponse`, für das Formular-Dropdown), `POST`/`PUT` (Anlegen/Bearbeiten). `EmployeeId` kommt bei jeder Aktion ausschließlich aus `ICurrentUserService.EmployeeId` (JWT-Claim), nie vom Client — bei fehlender Verknüpfung `400` statt eines FK-Fehlers, analog zum `AbsenceService.CreateAsync`-Fallback. Bewusst **kein** `DELETE` hier (Löschen bleibt Büro-Aufgabe über den Papierkorb). Nutzt intern denselben `IEmployeeFacilityDistanceService`/dieselbe Tabelle wie `EmployeeFacilityDistancesController` — zwei Controller auf demselben Application-Service, unterschiedliche Zugriffsrechte, kein Datenmodell-Unterschied. - **Zugleich behobener Bestandsfehler:** `FacilityService.UpdateAsync` kopierte die elf Konditionsfelder bislang gar nicht auf die getrackte Entität — der Controller validierte sie korrekt, aber `PUT /api/facilities/{id}` verwarf sie stillschweigend (kein Fehler, kein Log, das Feld blieb einfach `null`/unverändert). Betraf `BillingRate`/alle vier Zuschläge/`TravelCostRate`/`MinimumHours`/`BreakPolicy`/`BillingInterval`/`PaymentTermDays`/`IndividualAgreements` seit deren Einführung. Jetzt behoben, Regressionstest: `FacilityServiceTests.UpdateAsync_PersistsKonditionenFields`. - `BillingInterval` wird wie `FacilityType`/`ContractType` gegen die admin-editierbare `ValueList` `"BillingInterval"` (Wöchentlich/Monatlich/Quartalsweise) validiert — siehe "Konfigurierbare Auswahllisten". - Alle elf Felder sind nur über `PUT /api/facilities/{id}` (`UpdateFacilityRequest`) setzbar, nicht beim Anlegen (`CreateFacilityRequest`) — analog zu `CrmStatus`, der ebenfalls erst nach dem Anlegen über "Bearbeiten" gepflegt wird. **Ausnahme "Qualifikationsabhängige Preise":** eine variable Liste (ein Satz je Qualifikationsstufe) lässt sich nicht als feste Spaltengruppe abbilden — dafür die neue Entität `FacilityQualificationRate` (1:n zu `Facility`, `Qualification` gegen die ValueList `"Qualification"` validiert — dieselbe Liste wie `Employee.Qualification`/`Order.RequiredQualification`, siehe "Wo welches Feld referenziert wird" oben) als 1:n-Unterressource unter `GET/POST/PUT/DELETE /api/facilities/{facilityId}/qualification-rates[/...]` (`FacilityQualificationRatesController`) — exakt nach dem Muster von `FacilityContact`, kein eigener `ModuleType`, gegated über dieselben `Facilities`-Rechte. Löschen ist Soft-Delete, über `TrashController` (`api/trash/facility-qualification-rates/...`) wiederherstellbar. FR-EIN-4s Akzeptanzkriterium ("Konditionssatz ist Grundlage für Rechnungserstellung") ist damit nur zur Hälfte erfüllt — dieser Schritt legt ausschließlich die Datenbasis, `FR-RE-1`/`Invoice` (weiterhin ⬜) konsumiert die Konditionen noch nicht. ## Abwesenheits-/Urlaubs-/Krankmeldungsanträge (FR-CON-1, Datenbasis für FR-EM-3) `Absence` (`src/OmsorgCore.Domain/Entities/Absence.cs`, `AuditableEntity`, first-class Core-Objekt wie `Contract`/`Order`, keine Unterressource) ersetzt die frühere Insellösung aus `omsorgWeb/mitarbeiter-app-legacy` (dort zwei getrennte MySQL-Tabellen `requests_urlaubsantrag`/`requests_abwesenheitsantrag`, verknüpft an lokale `user_id` statt an `omsorgCore`-IDs). Ein gemeinsames Objekt statt zwei getrennter Stacks, weil bis auf die Art alle Felder identisch sind (Root-`CLAUDE.md`: "jede Information wird nur einmal gespeichert"): - `EmployeeId` (Pflicht, FK auf `Employee`), `Type` (gegen `ValueList "AbsenceType"`: Urlaub/Krankmeldung/Sonstige), `StartDate`/`EndDate` (`DateOnly`, `EndDate >= StartDate`), `Reason`/`Substitute`/`Note` (alle optionaler Freitext), `Status` (gegen `ValueList "AbsenceStatus"`: Eingereicht/Genehmigt/Abgelehnt), `AdminNote` (optionaler Freitext der entscheidenden Büro-Rolle). `Absence.Status` selbst hat **keinen** hartkodierten C#-Default (anders als z. B. `Contract.Status = "Entwurf"`) — der initiale Wert wird in `AbsenceService.CreateAsync` zur Laufzeit aus `GetInitialItemAsync("AbsenceStatus", ...)` gelesen (`DbSeeder` markiert "Eingereicht" dafür mit `IsInitial = true`, idempotent nachgezogen über `SeedAbsenceStatusInitialFlagIfMissingAsync` auch für bereits existierende Umgebungen). Grund: würde man `"Eingereicht"` als String an mehreren Stellen (Entity-Default, `UpdateAsync`-Prüfung, Decision-Endpoint, beide Frontends) fest verdrahten, würde ein Umbenennen dieses Werts über die Status-Verwaltung die Logik lautlos brechen, ohne dass Backend oder Frontend einen Fehler zeigen — exakt das Muster, das schon bei `Order.RequiredQualification` (ValueList-Referenz statt Enum) und `TriggersFollowUp` bei `CrmStatus` vermieden wird. - **Kein `ValueListItemTransition`** für `AbsenceStatus` (anders als `OrderStatus`) — wer wohin darf, ergibt sich vollständig aus Rechten: Außendienst hat nur `Create`/`View`/`Edit` (kann also nur im initialen Status anlegen/bearbeiten, keinen bereits entschiedenen Antrag ändern), Büro-Rollen haben zusätzlich `Approve` für den eigenen Entscheidungs-Endpoint (siehe unten) und `Delete`/`Recover`. `IAbsenceService.GetInitialStatusValueAsync(...)` ist der einzige Ort, der den initialen Statuswert auflöst — `AbsenceService.UpdateAsync` und `AbsencesController.Decide` nutzen ihn beide, statt jeweils eigene String-Vergleiche zu pflegen. - `ModuleType.Absences` (neuer, additiv angehängter Enum-Wert) ist das erste Modul außer `Employees`/`Contracts`, das `PermissionScope.Own` tatsächlich auswertet (`Absence.EmployeeId` als Ownership-Anker, siehe "Datenebenen-Scope") — `AbsenceService` folgt exakt dem `EmployeeService`/`ContractService`-Muster (`ResolveOwnScopeRestrictionAsync`), mit einer Ergänzung: `CreateAsync` überschreibt bei Own-Scope die `EmployeeId` **immer** serverseitig aus `ICurrentUserService.EmployeeId` (JWT-Claim) und ignoriert einen ggf. vom Client mitgeschickten Wert komplett — kein Client-Vertrauen darauf, wer die eigene Mitarbeiter-Id ist. `CreateAbsenceRequest` hat bewusst **kein** `employeeId`-Feld ("im Namen von" anlegen ist nicht Teil dieser ersten UI). **Own-Scope ist dabei nur eine Sichtbarkeits-/Anlege-Einschränkung, kein Ausschlusskriterium für Selbstanträge (korrigiert 2026-08-10, ursprünglich zu eng):** auch All-Scope-Aufrufer (Büro-Rollen, `Administrator`) haben oft eine eigene verknüpfte `Employee` und wollen für sich selbst einen Antrag stellen können — `CreateAsync` fällt für sie deshalb ebenfalls auf `ICurrentUserService.EmployeeId` zurück (nicht nur bei Own-Scope), und lehnt nur ab (`400`, ohne die Exception bis zur `employees`-FK-Constraint durchzureichen wie beim ursprünglichen Vorfall), wenn wirklich **kein** Mitarbeiter mit dem aufrufenden `User` verknüpft ist. `POST /api/absences` funktioniert damit für jeden angemeldeten Nutzer mit verknüpftem Mitarbeiter, unabhängig von Rolle/Scope — nur die *Sichtbarkeit* anderer Anträge (`GET /api/absences`) bleibt weiterhin durch Own/All eingeschränkt. - **`AbsencesController`** (Route `api/absences`): `GET`/`GET/{id}` (`View`), `POST` (`Create`, `CreateAbsenceRequest` ohne `EmployeeId`-Feld — wird immer serverseitig gesetzt), `PUT /{id}` (`Edit`, `UpdateAbsenceRequest { type, startDate, endDate, reason, substitute, note }` — **nur solange der Status noch der initiale ist** (`IAbsenceService.GetInitialStatusValueAsync`, nicht der Literal `"Eingereicht"`), sonst `400`; `AbsenceService.UpdateAsync`/`UpdateAbsenceResult` prüft das serverseitig, nicht nur im UI. Own-Scope-Aufrufer dürfen dabei nur eigene Anträge bearbeiten, wie bei `GetByIdAsync` — ein fremder Own-Scope-Antrag liefert `404`, nicht `403`, um dessen Existenz nicht zu verraten), `POST /{id}/decision` (`Approve`, `AbsenceDecisionRequest { status, adminNote }`, `status` muss ein Wert aus `"AbsenceStatus"` sein, der **nicht** der initiale ist — **anders als `Update` gibt es hier bewusst keine Prüfung des aktuellen Status**, eine Entscheidung ist jederzeit erneut änderbar (Korrektur einer versehentlichen Genehmigung/Ablehnung), `AbsenceService.DecideAsync` überschreibt `Status`/`AdminNote` unabhängig vom bisherigen Wert), `DELETE` (`Delete`, Soft-Delete, über `TrashController` (`api/trash/absences/...`) wiederherstellbar). `PermissionAction.Edit` ist Teil des Außendienst-Basis-Rollen-Seeds (`Absences.{Create,View,Edit}`, `PermissionScope.Own`) — jeder darf also nur den eigenen, noch nicht entschiedenen Antrag ändern, nicht fremde. - **Basis-Rollen-Seed:** Geschäftsführung automatisch (generische Schleife), `Disposition/Buchhaltung` bekommt `Absences` mit allen Aktionen/`Scope.All`, `Außendienst` bekommt `Absences.{Create,View}` mit `Scope.Own` (`DbSeeder.SeedBaseRolesAsync`). - **Verwendung von `omsorgWeb/mitarbeiter-app`:** der Außendienst stellt Anträge über `pages/urlaubsantrag.php` (neue Seite in der Connect-Neuauflage, nicht in der Legacy-App) gegen genau diesen Endpoint — siehe `omsorgWeb/CLAUDE.md`. `omsorgapp` bekommt die Prüfen/Genehmigen-Seite (`AbsencesPage`/`AbsenceDetailPanel`, Sidebar-Tab "Abwesenheiten") — siehe `omsorgapp/CLAUDE.md`. - **Bewusst nicht Teil dieses Schritts:** kein Konsum dieser Daten in FR-EM-3 (Verfügbarkeitsprüfung bei Zuweisung) — dieser Schritt legt nur die Datenbasis, analog zu FR-EIN-4/`FR-RE-1` oben. Keine Migration/Portierung der alten Legacy-Anträge aus `mitarbeiter-app-legacy`s MySQL. ## Zeiterfassung (FR-ZE-1/FR-ZE-2) `TimeEntry` (`src/OmsorgCore.Domain/Entities/TimeEntry.cs`, `AuditableEntity`, first-class Core-Objekt wie `Order`/`Absence`) bildet die geleistete Arbeitszeit einer Schicht ab: `EmployeeId`/`OrderId` (Pflicht-FKs), `Date`/`Start`/`End`/`BreakDuration`, vier manuell erfasste Zuschlagsstunden-Felder (`NightHours`/`SaturdayHours`/`SundayHours`/`HolidayHours`, Blueprint 19.5 listet sie als "Erfasste Daten" — keine automatische Berechnung aus Beginn/Ende, das wäre ein Nachtfenster-/Feiertagskalender-Feature, das es aktuell nicht gibt), `StatusId` (FK auf `ValueListItem`, wie `Order.StatusId`), `AdminNote`. **Einrichtung wird nicht redundant gespeichert** — sie ergibt sich über `Order.FacilityId`, `TimeEntryResponse` löst sie nur für die Anzeige mit auf (Root-`CLAUDE.md`-Prinzip "jede Information nur einmal speichern"). **Statuspipeline (FR-ZE-2) — Hybrid aus dem Order- und dem Absence-Muster:** Anders als `Absence` (binäre Entscheidung, kein Übergangsgraph) braucht `TimeEntry` eine echte Mehrstufen-Pipeline (Entwurf → Eingereicht → Prüfung → Rückfrage → Freigegeben → Abgerechnet), dafür wird das bestehende `OrderStatus`-Muster (`ValueListItemTransition`-Graph, `CanTransitionAsync`) wiederverwendet. Zwei zusätzliche, generische Flags lösen die Frage "wer darf welche Kante auslösen", ohne pro Endpoint eigene Statuslisten zu pflegen: - `ValueListItem.IsEditableByOwner` (neues, listenspezifisches Flag analog `IsInitial`/`IsTerminal`) — markiert, in welchen Status-Werten der Ersteller den Datensatz noch inhaltlich bearbeiten darf. Für `"TimeEntryStatus"`: `true` auf Entwurf/Eingereicht/Rückfrage, sonst `false`. Für alle anderen Listen bleibt es `false` (keine Verhaltensänderung). - `ValueListItemTransition.RequiresApproval` (neues Flag, Default `true`) — unterscheidet Selbst-Einreichungs-Kanten (Entwurf→Eingereicht, Rückfrage→Eingereicht, `false`) von Büro-Entscheidungen (alle übrigen Kanten, `true`). `"OrderStatus"`-Transitionen bleiben beim Default `true`, das Feld wird dort schlicht nicht ausgewertet. Drei Endpoints statt zwei (Absence hat Edit+Decide, TimeEntry hat Edit+Submit+Decide): - `PUT /api/time-entries/{id}` (`Edit`) — nur Inhaltsfelder (`Order`/`Date`/`Start`/`End`/`BreakDuration`/Zuschlagsstunden), **kein** `StatusId` im DTO. `TimeEntryService.UpdateAsync` lehnt ab (`400`, `NotEditable`), sobald der aktuelle Status `IsEditableByOwner == false` ist. - `POST /api/time-entries/{id}/submit` (`Edit`, kein Body) — `TimeEntryService.SubmitAsync` sucht über `IValueListRepository.GetSelfServiceTransitionAsync(statusId)` die eine ausgehende Kante mit `RequiresApproval == false` und wendet sie an; `400` falls keine existiert. Das ist die einzige Möglichkeit für den Ersteller, den Status selbst zu ändern. - `POST /api/time-entries/{id}/decision` (`Approve`) — `TimeEntryService.DecideAsync` validiert die Ziel-Transition über `CanTransitionAsync` **und** dass sie `RequiresApproval == true` ist (verhindert, dass dieser Endpoint für die Selbst-Einreichungs-Kante missbraucht wird), keine Prüfung des bisherigen Status (wie bei `Absence.DecideAsync` — Büro kann jederzeit erneut entscheiden). `ModuleType.TimeEntries` wertet `PermissionScope.Own` aus (`TimeEntry.EmployeeId` als Ownership-Anker, `TimeEntryService` folgt exakt dem `AbsenceService`-Muster inkl. `CreateAsync`-Fallback auf `ICurrentUserService.EmployeeId` für All-Scope-Aufrufer mit eigenem Mitarbeiterbezug). Basis-Rollen-Seed: Geschäftsführung automatisch, Disposition/Buchhaltung alle Aktionen/`Scope.All`, Außendienst `{Create,View,Edit}`/`Scope.Own`, Recruiting kein Zugriff. `ValueListsController`s generische `GET/PUT {key}/transitions`-Endpoints funktionieren unverändert für `"TimeEntryStatus"` (keine Sonderbehandlung im Code nötig, siehe "Konfigurierbare Auswahllisten" oben) — `ValueListTransitionResponse` liefert jetzt zusätzlich `RequiresApproval`, damit Clients (siehe `omsorgapp`/`omsorgWeb` unten) die für ihre Rolle relevanten Kanten selbst herausfiltern können. **Verwendung:** `omsorgWeb/mitarbeiter-app` (`pages/stundenerfassung.php`) — Außendienst legt/bearbeitet eigene Einträge und löst `submit` aus. `omsorgapp` (`TimeEntriesPage`/`TimeEntryDetailPanel`, Sidebar-Tab "Zeiterfassung") — Büro prüft/entscheidet über `decision`. **Bewusst offen:** `Order` hat keine Mitarbeiter-Zuweisung (FR-EM-3), das Auftrags-Dropdown in beiden Frontends zeigt deshalb alle aktiven Aufträge statt nur zugewiesene. ## Offene Punkte - `Facility` hat jetzt volles Repository/Service/Controller (`FacilitiesController`, `GET/POST/PUT/DELETE /api/facilities`) nach dem Employee-Muster, inkl. `FacilityCreatedEvent`. Zusätzlich `FacilityContact` (FR-EIN-2, Ansprechpartner), `FacilityQualificationRate` (FR-EIN-4, qualifikationsabhängige Preise) und `EmployeeFacilityDistance` (FR-EIN-4, kilometerbasierte Fahrtkosten) als 1:n-Unterressourcen unter `GET/POST/PUT/DELETE /api/facilities/{facilityId}/contacts[/...]`, `.../qualification-rates[/...]` bzw. `.../employee-distances[/...]` (`FacilityContactsController`/`FacilityQualificationRatesController`/`EmployeeFacilityDistancesController`, siehe "Konditionen einer Einrichtung" oben) — alle drei bewusst kein eigener `ModuleType`, sondern über dieselben `Facilities`-Rechte gegated, da keines ein eigenständiges Core-Objekt ist. Löschen (`Employee`/`Facility`/`Contract`/`Order`/`FacilityContact`/`FacilityQualificationRate`/`EmployeeFacilityDistance`) ist jetzt durchgängig Soft-Delete (`IsDeleted`/`DeletedAt`, `PermissionAction.Delete` je Modul, `DELETE`-Endpoint pro Controller) und über den `TrashController` (`api/trash/...`, `PermissionAction.Recover` je Modul, "Papierkorb"-Seite in `omsorgapp`) wiederherstellbar. - `Contract` hat jetzt ebenfalls volles Repository/Service/Controller (`ContractsController`, `GET/POST/PUT /api/contracts`, gegated über `[RequirePermission(ModuleType.Contracts, ...)]`) nach demselben Facility-Muster, inkl. `ContractCreatedEvent`. Deckt FR-MA-2 auf Backend-Seite ab: `WeeklyHours` (Arbeitszeit), `HourlyWage` (Stundenlohn), `AllowancesDescription` (Zuschläge, Freitext), `OvertimeRules` (Überstundenregelung, Freitext), `VacationDaysPerYear` (Urlaubsanspruch), `ProbationPeriodMonths` (Probezeit) — alle nullable, da ein Vertrag entweder einem Mitarbeiter oder einer Einrichtung zugeordnet ist (`EmployeeId`/`FacilityId`, mindestens eins muss gesetzt sein, per Controller-Validierung erzwungen) und nicht jeder Vertragstyp alle Felder braucht. `ContractConfiguration` hat jetzt (wie `Facility`) einen `HasQueryFilter(!IsDeleted)`. FR-MA-2 ist damit inkl. `omsorgapp`-UI abgeschlossen: "Verträge"-Tab in `EmployeeDetailPanel` (`ContractsList`/`ContractForm`/`Create-`/`EditContractDialog.jsx`), neue Verträge starten als "Entwurf", Statuswechsel nur im Bearbeiten-Formular, Löschen als Soft-Delete über den Papierkorb wiederherstellbar (siehe `omsorgapp/CLAUDE.md`). - `Order` hat jetzt ebenfalls volles Repository/Service/Controller (`OrdersController`, `GET/POST/PUT /api/orders`, gegated über `[RequirePermission(ModuleType.Orders, ...)]`) nach demselben Facility/Contract-Muster, inkl. `OrderCreatedEvent`. Deckt FR-EM-1 auf Backend-Seite ab: `FacilityContactId` (optionaler Ansprechpartner, gegen `FacilityId` cross-validiert — der Kontakt muss zur angegebenen Einrichtung gehören, sonst `400`), `ShiftType` (Schichtart, Freitext), `RequiredHeadcount` (Anzahl Mitarbeiter, mindestens 1), `Conditions` (Konditionen, Freitext), `Priority` (Priorität, Freitext). Der Auftragsstatus (FR-EM-2, `Order.StatusId`) ist Teil der generischen Auswahllisten — siehe "Konfigurierbare Auswahllisten" unten. `OrderConfiguration` hat jetzt (wie `Facility`/`Contract`) einen `HasQueryFilter(!IsDeleted)`. Kein `omsorgapp`-UI-Modul für Aufträge selbst in diesem Schritt (nur die Statuspflege über "Status-Verwaltung"). `TimeEntry` hat jetzt ebenfalls volles Repository/Service/Controller (FR-ZE-1/FR-ZE-2, siehe "Zeiterfassung" oben) — `Invoice` hat weiterhin nur Domain-Entität + DB-Konfiguration, nächster Schritt folgt demselben Muster (Repository-Interface in Application, Implementierung in Infrastructure, Service in Application, Controller in Api) und muss FR-ZE-3 (nur freigegebene Zeit fließt ein) auf Domain-/Application-Ebene erzwingen. - Dokumentenarchiv (FR-MA-3) hat jetzt volles Repository/Service/Controller (`DocumentsController`, `GET/POST /api/documents`, `PUT /{id}` für Metadaten, `GET /{id}/download`, `DELETE /{id}`) — siehe "Dokumentenarchiv" oben. `omsorgapp`-UI ("Dokumente"-Tab in der Personalakte) existiert jetzt ebenfalls, siehe `omsorgapp/CLAUDE.md`. Kein Außendienst-Selbstzugriff, kein physisches Löschen von Dateien beim Soft-Delete/Papierkorb (keine Hard-Purge-Stelle im System, die man konsistent mitziehen müsste). - 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. ## Generierte API-Clients Aus der Swagger/OpenAPI-JSON dieses Backends (`/swagger/v1/swagger.json`, nur im Development-Modus aktiv) werden mit `openapi-generator-cli` typisierte Clients generiert — `omsorgapp/api-client-ts/` (TypeScript, `typescript-fetch`-Template) und `omsorgWeb/mitarbeiter-app/api-client-php/` (PHP). **Wichtig — beide generierten Clients sind im echten Datenpfad, nicht optional:** Alle Wrapper unter `omsorgapp/src/api/*Api.js` (`employeesApi.js`, `facilitiesApi.js`, `facilityContactsApi.js`, `facilityQualificationRatesApi.js`, `employeeFacilityDistancesApi.js`, `usersApi.js`, `rolesApi.js`, `valueListsApi.js`, `auditLogApi.js`, `authApi.js`, `absencesApi.js`, `timeEntriesApi.js`, ...) importieren die jeweilige `*Api`-Klasse aus dem generierten Paket `omsorgcore-client-ts` (`import { ... } from "omsorgcore-client-ts"`) und reichen Requests/Responses **ungeprüft typisiert** durch. `omsorgWeb/mitarbeiter-app/lib/omsorgCoreClient.php` nutzt seit den Absences-/Orders-/TimeEntries-Wrappern ebenfalls den generierten PHP-Client (`../api-client-php/`, `\OmsorgCoreClient\Api\...`) statt rohem cURL — beide Frontends müssen also nach einer Contract-Änderung neu generiert werden, nicht nur `omsorgapp`. **Verbindliche Regel: nach *jeder* Änderung an einem Controller oder DTO in `omsorgCore.Api/Contracts` müssen `omsorgapp/api-client-ts` **und** `omsorgWeb/mitarbeiter-app/api-client-php` neu generiert und neu gebaut werden — noch in demselben Change, nicht als Nachgang.** Wird das vergessen, gibt es **keinen Fehler, keine Exception, keine Warnung** — der generierte Client kennt das neue/geänderte Feld schlicht nicht und lässt es beim Serialisieren/Deserialisieren stillschweigend weg. Das Symptom in der UI: Speichern/Anlegen meldet Erfolg, aber das betroffene Feld kommt nie im Backend an bzw. taucht nie in der Antwort auf — schwer zu debuggen, weil weder Backend noch Frontend-Code einen sichtbaren Fehler werfen (siehe FR-EIN-1/Website-Vorfall, 2026-08-08). ```bash # omsorgCore muss dafür lokal im Development-Modus laufen (Swagger nur dort aktiv): cd omsorgCore && ASPNETCORE_ENVIRONMENT=Development dotnet run --project src/OmsorgCore.Api # in einem zweiten Terminal: cd omsorgapp/api-client-ts npm run generate # entspricht ./generate.sh — überschreibt src/, README.md, package.json, tsconfig*.json npm run build # erzeugt dist/, das die *Client.cjs-Wrapper tatsächlich importieren ``` `npm run generate` überschreibt `package.json` komplett (Standard-Output von openapi-generator) — das dort eingetragene `generate`-Script muss danach jedes Mal erneut ergänzt werden (`git diff package.json` prüfen), sonst verschwindet es beim nächsten Lauf wieder. Details/Voraussetzungen: `omsorgapp/api-client-ts/ANLEITUNG.md` (das jeweilige `README.md` wird vom Generator automatisch überschrieben, `ANLEITUNG.md` bleibt stabil). `omsorgWeb/mitarbeiter-app/api-client-php` ist seit den Absences-/Orders-/TimeEntries-Wrappern ebenfalls im echten Datenpfad (siehe oben) und muss im selben Zug regeneriert werden, nicht nur "bei Gelegenheit". ## 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) läuft automatisch über den `AuditSaveChangesInterceptor` (siehe "Audit-Log" oben), keine Handarbeit pro Entität nötig. 5. Kein Hard-Delete für sensible/geschäftsrelevante Daten — Soft-Delete/Archivierung. Umgesetzt für `Employee`/`Facility`/`Contract`/`Order`/`FacilityContact`/`FacilityQualificationRate`/`EmployeeFacilityDistance`/`Absence`/`TimeEntry` (`IsDeleted`/`DeletedAt`, `DELETE`-Endpoints gegated über `PermissionAction.Delete`, Wiederherstellung über `TrashController`/`PermissionAction.Recover`); `Invoice` hat noch kein CRUD, daher hier noch nicht relevant. 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). Zweites Beispiel, generisch statt fachspezifisch: `AuditEvent` (siehe "Audit-Log" oben) wird von mehreren Stellen im Api-Layer für beliebige Verhaltens-Ereignisse gefeuert, `AuditEventHandler` persistiert sie einheitlich. 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.