Migrate omsorgapp to browser SPA, add Docker/CI build setup
Docker-Images bauen und veröffentlichen / build (, omsorgCore/Dockerfile, omsorgcore) (push) Successful in 3s
Docker-Images bauen und veröffentlichen / build (, omsorgWeb/Dockerfile, omsorgweb) (push) Failing after 4s
Docker-Images bauen und veröffentlichen / build (VITE_OMSORG_CORE_URL=${{ vars.OMSORG_CORE_PUBLIC_URL }}, omsorgapp/Dockerfile, omsorgapp) (push) Successful in 3s

- omsorgapp: drop Electron, run as a plain Vite/React browser app; refresh
  token moves to an HttpOnly cookie (omsorgCore), CORS added for the new
  browser origin, document download/preview switched to Blob-based browser
  APIs.
- Add Dockerfiles for omsorgCore, omsorgapp, and omsorgWeb, a docker-compose.yml
  wiring Postgres/MySQL/all three apps together, and a Gitea Actions workflow
  that builds and pushes images to the repo's container registry on push to
  main and on version tags.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Felix Kemmler
2026-08-10 17:42:45 +02:00
co-authored by Claude Sonnet 5
parent e9e96a57dc
commit 598dfcd38a
461 changed files with 59753 additions and 2859 deletions
+101 -23
View File
@@ -28,8 +28,8 @@ omsorgCore/
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,
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,
@@ -42,7 +42,8 @@ omsorgCore/
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)
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.
@@ -51,7 +52,7 @@ omsorgCore/
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
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
@@ -67,7 +68,7 @@ omsorgCore/
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.
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
@@ -75,7 +76,18 @@ Rechteprüfung auf Controller-Actions:
```
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`).
**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<ModuleType>()` 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`).
**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.
@@ -92,11 +104,11 @@ Aktor-Informationen (`Username`/`RoleName`/`IpAddress`) kommen über `ICurrentUs
## 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).
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 <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).
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`.
@@ -104,7 +116,7 @@ Aktor-Informationen (`Username`/`RoleName`/`IpAddress`) kommen über `ICurrentUs
**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.
**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).
@@ -139,12 +151,16 @@ Vollständig implementiert: `PasswordResetCode`-Entity + `PasswordResetService`
**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<AuthController>`) 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<AdminEmailController>`) und antwortet `502` mit `{ error: "send_failed", message: <MailKit-Fehlertext> }` 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).
- 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.
## Build- und Run-Befehle
@@ -174,31 +190,93 @@ ASPNETCORE_ENVIRONMENT=Development dotnet run --project src/OmsorgCore.Api
- `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).
- `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`) — die Zahl "0" für Außendienst ist damit veraltet (jetzt 2 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"`; `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.
**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.UserManagement, PermissionAction.Edit)]`dieselbe Admin-Berechtigung wie die übrige "Einstellungen"-Seite.
**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 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.
**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`):** `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", ...)`.
**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), `TravelCostRate` (Fahrtkosten als **Pauschale je Einsatz**, kein km-Modell), `MinimumHours` (Mindeststunden je Einsatz), `BreakPolicy` (Pausenregelung, Freitext), `PaymentTermDays` (Zahlungsziel in Tagen), `IndividualAgreements` (Freitext).
- `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 /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).
- `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) als 1:n-Unterressource unter `GET/POST/PUT/DELETE /api/facilities/{facilityId}/contacts[/...]` (`FacilityContactsController`) und `FacilityQualificationRate` (FR-EIN-4, qualifikationsabhängige Preise) als 1:n-Unterressource unter `GET/POST/PUT/DELETE /api/facilities/{facilityId}/qualification-rates[/...]` (`FacilityQualificationRatesController`, siehe "Konditionen einer Einrichtung" oben) — beide bewusst kein eigener `ModuleType`, sondern über dieselben `Facilities`-Rechte gegated, da beides kein eigenständiges Core-Objekt ist. Löschen (`Employee`/`Facility`/`Contract`/`Order`/`FacilityContact`/`FacilityQualificationRate`) 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.
@@ -207,9 +285,9 @@ Dropdown-Werte, die früher als hartcodierte Arrays im `omsorgapp`-Frontend lebt
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.
**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`, `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` 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).
**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):
@@ -221,7 +299,7 @@ npm run generate # entspricht ./generate.sh — überschreibt src/, README.md,
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.
`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)
@@ -232,7 +310,7 @@ Verbindliche Regeln für das Datenmodell:
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).
5. Kein Hard-Delete für sensible/geschäftsrelevante Daten — Soft-Delete/Archivierung. Umgesetzt für `Employee`/`Facility`/`Contract`/`Order`/`FacilityContact`/`FacilityQualificationRate`/`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.