Files
omsorg/omsorgCore/CLAUDE.md
T
Felix KemmlerandClaude Sonnet 5 e9e96a57dc Add facilities, contracts, orders, value lists, audit log, and desktop app modules
Extends omsorgCore with full CRUD for Facility/Contract/Order plus
configurable value lists and an audit trail, and wires the omsorgapp
frontend up to the new facilities, settings, and audit-log modules;
includes a sidebar active-nav-item highlight.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 23:29:16 +02:00

244 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<T>` (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
Entities/ # Employee, Facility, Contract, Order, TimeEntry, Invoice,
# 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<T> pro Entität
Migrations/ # EF-Core-Migrationen (InitialCreate bereits erzeugt)
Repositories/ # EmployeeRepository, FacilityRepository, FacilityContactRepository, UserRepository, RefreshTokenRepository, AuditLogRepository (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<T>, 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, 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.
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]`.
**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, wird dessen `Effect` aktualisiert statt dupliziert). Alle diese Endpoints liegen auf `RolesController`/`UsersController`, gegated über `[RequirePermission(ModuleType.UserManagement, View|Edit)]` wie der Rest der Nutzerverwaltung. Admin-UI dazu: `omsorgapp/src/modules/settings/` (`SettingsPage`, `RolesPanel`, `RolePermissionMatrix`, `UserOverridesPanel`).
**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`): `accessToken`, `refreshToken`, `expiresAt` (camelCase, Default-JSON-Serialisierung von ASP.NET Core).
2. Client sendet Access-Token als `Authorization: Bearer <token>`.
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`.
**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), `Recruiting` (Sascha), `Außendienst` (ohne Modul-Rechte, da OMSORG Connect noch nicht gegen dieses Backend spricht und "nur eigene Daten" ohnehin Datenebene statt Modul-Recht ist). 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:<Name>:*`)** — 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. `AddContractDetailsAndQueryFilter` ist erzeugt, aber noch nicht gegen eine echte Instanz verifiziert (wird beim nächsten API-Start automatisch 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 <Name> \
--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).
## 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"`; `DisplayName` für die Admin-UI) + `ValueListItem` (`Value`, `SortOrder`, `IsDefault`, `IsInitial`/`IsTerminal` — die letzten beiden nur für `"OrderStatus"` relevant) + `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.
**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.UserManagement, PermissionAction.Edit)]` — dieselbe Admin-Berechtigung wie die übrige "Einstellungen"-Seite.
**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 sieben 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. **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`):** `electron/backend/valueListsClient.cjs` 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`, dritter Tab ("Status-Verwaltung") in `SettingsPage.jsx`, gegated wie Rollen/Benutzerrechte über `hasPermission("UserManagement", ...)`.
## Offene Punkte
- `Facility` hat jetzt volles Repository/Service/Controller (`FacilitiesController`, `GET/POST/PUT /api/facilities`) nach dem Employee-Muster, inkl. `FacilityCreatedEvent`. Zusätzlich `FacilityContact` (FR-EIN-2, Ansprechpartner) als 1:n-Unterressource unter `GET/POST /api/facilities/{facilityId}/contacts`, `PUT .../contacts/{id}` (`FacilityContactsController`) — bewusst kein eigener `ModuleType`, sondern über dieselben `Facilities`-Rechte gegated, da Ansprechpartner kein eigenständiges Core-Objekt sind. Kein Delete-Endpoint für Ansprechpartner (konsistent mit dem noch fehlenden Soft-Delete für die übrigen Core-Objekte).
- `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)`. Kein `omsorgapp`-UI-Modul dafür in diesem Schritt — nur das Backend-CRUD.
- `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`/`Invoice` haben weiterhin nur Domain-Entitäten + DB-Konfiguration — nächste Schritte folgen demselben Muster (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.
## 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 — `omsorgapp/api-client-ts` ist kein optionales Extra mehr, sondern im echten Datenpfad:** Alle Wrapper unter `omsorgapp/electron/backend/*Client.cjs` (`employeesClient.cjs`, `facilitiesClient.cjs`, `facilityContactsClient.cjs`, `usersClient.cjs`, `rolesClient.cjs`, `valueListsClient.cjs`, `auditLogClient.cjs`, `authClient.cjs`, ...) importieren die jeweilige `*Api`-Klasse aus dem generierten Paket `omsorgcore-client-ts` (`require('omsorgcore-client-ts')`) und reichen Requests/Responses **ungeprüft typisiert** durch. Nur `omsorgWeb/mitarbeiter-app/lib/omsorgCoreClient.php` bleibt tatsächlich unabhängig vom generierten PHP-Client.
**Verbindliche Regel: nach *jeder* Änderung an einem Controller oder DTO in `omsorgCore.Api/Contracts` muss `omsorgapp/api-client-ts` 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 aktuell nicht im echten Datenpfad (siehe oben), sollte aber aus Konsistenzgründen bei Gelegenheit ebenfalls regeneriert werden.
## 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 (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<TEvent>` 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.