Files
omsorg/omsorgCore/CLAUDE.md
T
Felix KemmlerandClaude Haiku 4.5 e1fbfadd17 Einrichtung: Konditionen und CRM-Status beim Anlegen direkt sichtbar machen
- Backend (omsorgCore): CreateFacilityRequest um Konditionen- und CRM-Status-Felder erweitert, identisch mit UpdateFacilityRequest
- FacilitiesController.Create: Gleiche Validierung wie Update (CrmStatus-Allowlist, FollowUp-Logik, Konditions-Wertebereich), aber ohne Transition-Check (kein Vorzustand beim Anlegen)
- Gemeinsame Validierungslogik in ValidateCrmStatusAndFollowUpAsync extrahiert, um Code-Duplizierung zu vermeiden
- omsorgCore/CLAUDE.md aktualisiert: Konditionen sind jetzt auch beim Anlegen setzbar
- API-Clients neu generiert (TypeScript und PHP)
- Frontend (omsorgapp): FacilityForm.jsx: includeCrmStatus-Flag entfernt, Konditionen und CRM-Status immer sichtbar
- CreateFacilityDialog.jsx: CRM-Status-Auswahl + FollowUpDaysDialog-Workflow (analog zu Edit-Dialog)
- EditFacilityDialog.jsx: vereinfacht (includeCrmStatus-Prop entfernt)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-10 22:33:27 +02:00

75 KiB
Raw Blame History

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, DocumentEntityType
      Entities/                 # Employee, Facility, Contract, Order, TimeEntry, Invoice, Document,
                                 # User, Role, RolePermission, UserPermissionOverride, AuditLogEntry
    OmsorgCore.Application/     # Business-Logik. Abhängig von Domain.
      Abstractions/             # Interfaces: IEmployeeRepository, IUserRepository,
                                 # IPasswordHasher, IJwtTokenGenerator, ICurrentUserService,
                                 # IPermissionService
      Services/                 # PermissionService, AuthService, EmployeeService, SessionAdminService
      DependencyInjection.cs    # AddApplication()
    OmsorgCore.Infrastructure/  # Technische Umsetzung. Abhängig von Domain + Application.
      Persistence/
        OmsorgCoreDbContext.cs
        Configurations/         # ein IEntityTypeConfiguration<T> pro Entität
        Migrations/             # EF-Core-Migrationen (InitialCreate bereits erzeugt)
      Repositories/             # EmployeeRepository, FacilityRepository, FacilityContactRepository, DocumentRepository, UserRepository, RefreshTokenRepository, AuditLogRepository (implementieren Application-Interfaces)
      Storage/                  # StorageOptions, DocumentUploadPolicy, FileSystemDocumentStorage (Dokument-Bytes auf Disk, siehe "Dokumentenarchiv" unten)
      Security/                 # PasswordHasher, JwtOptions, JwtTokenGenerator, RefreshTokenOptions, RefreshTokenGenerator
      DependencyInjection.cs    # AddInfrastructure(configuration)
    OmsorgCore.Engine/          # Event-Schicht. Abhängig von Domain + Application.
      Events/                   # IDomainEvent, IDomainEventHandler<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, DocumentsController, HealthController, AdminSessionsController, AuditLogController
      Contracts/                 # Request-/Response-DTOs (LoginRequest, EmployeeResponse, ...)
      Security/                  # CurrentUserService, RequirePermissionAttribute
      Program.cs                 # einziger Ort, an dem alle Schichten verdrahtet werden
  tests/
    OmsorgCore.Tests/           # xUnit, referenziert Domain + Application

Konvention: eine Klasse/ein Interface/ein Enum pro Datei. Ausnahme: keine — auch kleine DTOs (Requests/Responses als record) bekommen eine eigene Datei.

Abhängigkeitsrichtung ist strikt: Domain kennt niemanden. Application kennt nur Domain und definiert Interfaces, die Infrastructure implementiert (Ports-and-Adapters). Engine kennt Domain + Application, nicht Infrastructure oder Api. Api verdrahtet alles ausschließlich in Program.cs — Controller rufen nur Application-Services und den IDomainEventDispatcher (Engine) auf, niemals direkt OmsorgCoreDbContext/EF Core.

Rechtesystem

Rolle liefert Standard-Rechte (RolePermission: Modul × Aktion), ein individueller UserPermissionOverride (Grant/Revoke) gewinnt immer gegen den Rollen-Default — siehe PermissionService.HasPermissionAsync (src/OmsorgCore.Application/Services/PermissionService.cs). Deckt Blueprint 6.5 ("Rolle als Vorlage + individuelle Rechte") ab.

ModuleType.EmployeeFacilityDistances (additiv hinzugefügt 2026-08-10) gated ausschließlich MyFacilityDistancesController (Selbstbedienung, siehe "Konditionen einer Einrichtung" unten) — bewusst getrennt von Facilities, damit der Außendienst seine eigene Fahrtstrecke pflegen kann, ohne Konditionen/CRM-Daten von Einrichtungen zu sehen. Nur Außendienst bekommt es im Basis-Seed (Create/View/Edit, Scope Own, siehe DbSeeder.SeedBaseRolesAsync); Büro-Rollen verwalten Distanzen weiterhin über EmployeeFacilityDistancesController/Facilities-Recht.

Die aufgelösten Rechte eines Users (nicht nur eine einzelne Prüfung) liefert PermissionService.GetGrantedPermissionsAsync als Liste von PermissionGrant(Module, Action, Scope). Exponiert über GET /api/auth/me (AuthController.Me, [Authorize]) als MeResponse { username, role, permissions: [{ module, action, scope }, ...] } — der einzige Weg, wie granulare Rechte den Client erreichen (das JWT trägt nur den Rollennamen). omsorgapp ruft diesen Endpunkt nach Login/Refresh auf (siehe omsorgapp/CLAUDE.md, "Rechtesystem im Client") und trifft UI-Entscheidungen darüber statt über einen Rollennamen-Vergleich.

Rechteprüfung auf Controller-Actions:

[RequirePermission(ModuleType.Employees, PermissionAction.Create)]

Das Attribut (src/OmsorgCore.Api/Security/RequirePermissionAttribute.cs) prüft serverseitig über IPermissionService — nicht nur im Client (REQUIREMENTS.md NFR-9). Jeder Controller außer AuthController trägt zusätzlich [Authorize].

Drei getrennte Admin-Rechte statt einer Sammelkategorie (seit 2026-08-09): UserManagement deckte ursprünglich die komplette "Einstellungen"-Seite ab (Benutzerkonten, Rollen-Rechte-Matrix, User-Overrides, Status-Verwaltung, Debug/Sessions) — wer irgendeinen dieser Bereiche brauchte, bekam automatisch Zugriff auf alle, inklusive der Möglichkeit, sich selbst beliebige Rechte zu geben. Jetzt drei fachlich getrennte ModuleType-Werte:

  • Users — Benutzerkonten sehen/anlegen/(de)aktivieren/Passwort zurücksetzen (UsersController: GetAll/Create/ResetPassword/Update, alle [RequirePermission(ModuleType.Users, ...)]). Admin-UI: omsorgapp/src/modules/settings/UsersPanel.jsx (Tab "Benutzer" in SettingsPage).
  • UserManagement (enger als zuvor) — nur noch die rechte-eskalierenden Aktionen: Rollen-Rechte-Matrix + individuelle User-Permission-Overrides + Session-Killswitch (AdminSessionsController) + Test-Mail (AdminEmailController). Bewusst weiterhin ein eigenes, sensibleres Recht, weil hierüber Rechte selbst verändert werden.
  • Configuration — Status-Verwaltung/Auswahllisten (ValueListsController, alle schreibenden Endpoints [RequirePermission(ModuleType.Configuration, PermissionAction.Edit)]), fachlich unabhängig von Nutzerverwaltung.

Die Sidebar-Sichtbarkeit von "Einstellungen" in omsorgapp ist deshalb kein einzelnes Modul mehr, sondern ein OR über alle drei (navPermissions.js, SETTINGS_MODULES, analog zum bestehenden TRASH_MODULES-Muster für den Papierkorb) — SettingsPage.jsx filtert die vier Tabs (Benutzer/Rollen/Benutzerrechte/Status-Verwaltung) zusätzlich einzeln nach ihrem jeweiligen Modul, ein Nutzer sieht also nur die Tabs, für die er tatsächlich View hat. Wichtig für neue Rollen: GET /api/users (Liste aller Benutzerkonten) prüft Users/View — wer individuelle UserPermissionOverride-Ausnahmen verwalten will (UserManagement/Edit), braucht zusätzlich Users/View, um überhaupt einen Nutzer zur Auswahl zu bekommen (UserOverridesPanel.jsx); ebenso nutzen EmployeesPage.jsx (Account-anlegen-Button in der Personalakte) und AuditLogPage.jsx (Akteur-Filter) GET /api/users und prüfen daher Users/View bzw. Users/Create, nicht mehr UserManagement.

Der Basis-Rollen-Seed (DbSeeder.SeedBaseRolesAsync, siehe unten) wurde bei diesem Split nicht angepasst — nur Geschäftsführung iteriert ohnehin generisch über Enum.GetValues<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/RemovePermissionOverrideAsyncAddPermissionOverrideAsync ist ein Upsert: existiert bereits ein Override für dasselbe Modul+Aktion bei diesem User, werden dessen Effect und Scope aktualisiert statt dupliziert). Diese Endpoints liegen auf RolesController/UsersController (die .../permission-overrides-Routen), gegated über [RequirePermission(ModuleType.UserManagement, View|Edit)]. Admin-UI dazu: omsorgapp/src/modules/settings/ (SettingsPage, RolesPanel, RolePermissionMatrix, UserOverridesPanel).

Rolle löschen (seit 2026-08-10): DELETE /api/roles/{id} ([RequirePermission(ModuleType.UserManagement, PermissionAction.Delete)]) — Löschschutz analog zu ValueListService.DeleteItemAsync, aber ohne die generische IValueListUsageChecker-Abstraktion, da eine Rolle nur über genau einen Ownership-Anker referenziert wird (User.RoleId, echte FK statt Freitext): RoleService.DeleteAsync prüft über IUserRepository.GetUserIdsByRoleAsync (bereits für die Cache-Invalidierung bei Rechte-Änderungen vorhanden), ob noch Benutzer dieser Rolle zugewiesen sind, und lehnt mit 409 + Anzahl im Klartext ab (DeleteRoleResult/DeleteRoleFailureReason), statt zu löschen. Hard-Delete (kein Soft-Delete/Papierkorb) — Role ist Entity, nicht AuditableEntity, kein eigenständiges Core-Objekt mit Löschprotokoll-Anforderung. Wichtig: DbSeeder.SeedBaseRolesAsync legt eine der vier Basis-Rollen (Geschäftsführung/Disposition/Buchhaltung/Recruiting/Außendienst) bei fehlendem Namen bei jedem Start erneut an (idempotent pro Rollenname, siehe dort) — eine gelöschte Basis-Rolle taucht nach einem Neustart automatisch wieder auf, das ist bestehendes, dokumentiertes Verhalten, keine Neuerung dieses Schritts. Frontend: RolesPanel.jsx zeigt einen Löschen-Button je Rolle (nur mit UserManagement/Delete), zeigt die 409-Fehlermeldung des Servers an statt sie zu verschlucken.

Datenebenen-Scope (PermissionScope, "nur eigene Daten"): Dritte Dimension neben Modul×Aktion — jede RolePermission/UserPermissionOverride-Zeile trägt zusätzlich Scope (All oder Own, src/OmsorgCore.Domain/Enums/PermissionScope.cs). PermissionService.GetScopeAsync(userId, module, action) löst das auf und liefert PermissionScope? (null = gar nicht gewährt) — ein Override ersetzt dabei die komplette Zelle (Grant und Scope) der Rolle, es wird nicht gemergt, analog zur bestehenden Effect-Semantik. HasPermissionAsync/RequirePermissionAttribute bleiben bewusst scope-unabhängig (ein Own-User muss den Endpunkt-Gate trotzdem passieren) — die eigentliche Einschränkung passiert in den Application-Services: EmployeeService/ContractService konsultieren GetScopeAsync vor GetPagedAsync/GetByIdAsync und filtern bei Own auf ICurrentUserService.EmployeeId (neuer JWT-Claim "employeeId", aus User.EmployeeId, nur eingebettet wenn gesetzt — wirkt daher erst mit dem nächsten Token-Refresh, wenn die Verknüpfung sich ändert). Own ohne verknüpfte EmployeeId liefert bewusst keine Datensätze (nicht "alle", nicht 500). Employees, Contracts, Absences und TimeEntries werten den Scope aktuell aus (alle vier haben einen Ownership-Anker: User.EmployeeId, Contract.EmployeeId, Absence.EmployeeId, TimeEntry.EmployeeId — siehe "Abwesenheits-/Urlaubs-/Krankmeldungsanträge" und "Zeiterfassung" unten) — alle anderen Module ignorieren Scope faktisch, weil ihnen kein Ownership-Anker zugrunde liegt (Order z. B. hat noch keinen Mitarbeiter-Bezug).

Wichtig: Wird ein neuer ModuleType oder PermissionAction-Wert hinzugefügt, oder ändert sich sonst das Rollen-/Rechtesystem, muss dieser Abschnitt (Rechtesystem) im selben Change aktualisiert werden — diese Dokumentation ist keine Momentaufnahme, sondern muss mit der Software mitwachsen.

Audit-Log ("wer hat wann was verändert")

Zwei sich ergänzende Erfassungswege, beide münden in dieselbe Tabelle AuditLogEntry (src/OmsorgCore.Domain/Entities/AuditLogEntry.cs, Category unterscheidet die Herkunft):

  1. Automatisch, Entity-Änderungen: AuditSaveChangesInterceptor (src/OmsorgCore.Infrastructure/Persistence/AuditSaveChangesInterceptor.cs, ein EF-Core-SaveChangesInterceptor) erfasst bei jedem SaveChangesAsync auf OmsorgCoreDbContext automatisch jede Create/Update/(Soft-)Delete-Änderung an einer beliebigen AuditableEntity-Subklasse — inkl. aller künftigen (Facility/Contract/Order/TimeEntry/Invoice), ohne dass dafür Code in deren Services/Controllern nötig ist. Bei Update wird nur der tatsächliche Feld-Diff ({old, new} je geändertem Feld) als JSON in Details gespeichert; ein Soft-Delete (IsDeleted: false→true) wird als Action "Deleted" erkannt, nicht als "Updated". Sensible Felder (aktuell User.PasswordHash/User.SecurityStamp) sind mit [AuditRedacted] (src/OmsorgCore.Domain/Common/AuditRedactedAttribute.cs) markiert — der Interceptor ersetzt ihren Wert im Log durch "***redacted***". Neue sensible Felder in künftigen Entitäten müssen dieses Attribut bekommen, sonst landen sie im Klartext im Audit-Log.
  2. Explizit, Verhaltens-Ereignisse ohne Entity-Änderung: AuditEvent (src/OmsorgCore.Engine/Events/AuditEvent.cs) über den bestehenden IDomainEventDispatcher dispatcht, analog zu EmployeeCreatedEvent (siehe "Event-Schicht" unten) — für Aktionen wie Login/Logout/Session-Kill, die keine AuditableEntity verändern. AuditEventHandler (Engine/Handlers/) persistiert das Event als AuditLogEntry mit Category = BehavioralEvent. Aktuelle Dispatch-Stellen: AuthController.Login ("Login"/"LoginFailed"), AuthController.Logout ("Logout"), AdminSessionsController.Revoke/RevokeAll ("SessionRevoked"/"AllSessionsRevoked"). Neue Aktionen folgen demselben Muster: eine Zeile await _dispatcher.DispatchAsync(new AuditEvent(actorUserId, actorUsername, ipAddress, "MeineAktion", details)) an der auslösenden Stelle im Api-Layer. Bewusst kein Event bei AuthController.Refresh (zu häufig/geräuschig für eine sliding Session, kein eigenständiges "wer hat was getan"-Faktum).

Aktor-Informationen (Username/RoleName/IpAddress) kommen über ICurrentUserService (erweitert um diese drei Properties, Implementierung CurrentUserService liest sie aus JWT-Claims bzw. HttpContext.Connection.RemoteIpAddress).

Einsicht: GET /api/audit-log (AuditLogController, Query-Filter entityType/entityId/actorUserId/fromUtc/toUtc + Pagination), gegated über [RequirePermission(ModuleType.AuditLog, PermissionAction.View)]. Per Default nur die Rolle Geschäftsführung (DbSeeder.SeedBaseRolesAsync iteriert für sie generisch alle ModuleType-Werte, siehe dort) — alle anderen Basis-Rollen sehen das Audit-Log nicht.

Auth-Flow

  1. POST /api/auth/login (AuthController) → AuthService.LoginAsync prüft Username/Passwort-Hash, widerruft alle bisherigen aktiven Refresh-Tokens dieses Users und würfelt seinen SecurityStamp neu (EndOtherSessionsAsync — ein User hat immer nur eine aktive Session; ältere Sessions werden per Killswitch sofort ungültig, siehe "Session-Killswitch" unten), dann erzeugt JwtTokenGenerator ein Access-Token (Claims sub/name/role) + RefreshTokenGenerator einen langlebigen Refresh-Token. Response (LoginResponse): nur noch accessToken, expiresAt, mustChangePassword (camelCase, Default-JSON-Serialisierung von ASP.NET Core) — der Refresh-Token selbst geht nicht im Body raus, sondern als HttpOnly-Secure-Cookie (refreshToken, Path=/api/auth, AuthController.SetRefreshTokenCookie, Secure nur außerhalb von Development, da der lokale Dev-Server standardmäßig nur über http:// läuft), seit omsorgapp als Browser-SPA (nicht mehr Electron) läuft und ein Browser-Frontend den Token sonst nie sicher clientseitig halten könnte (kein safeStorage-Äquivalent im Browser). Das setzt CORS mit AllowCredentials() voraus (Program.cs, Cors:AllowedOrigins-Config), sonst schickt der Browser die Cookie nicht mit.
  2. Client sendet Access-Token als Authorization: Bearer <token>.
  3. Program.cs validiert das Token gegen Jwt:Issuer/Jwt:Audience/Jwt:Secret aus der Konfiguration.
  4. POST /api/auth/refresh (kein [Authorize], kein Body — der Refresh-Token kommt aus der Cookie, die selbst das Credential ist): AuthService.RefreshAsync prüft den Refresh-Token per Hash-Lookup, rotiert ihn (alten Token widerrufen, neuen ausstellen, per ReplacedByTokenId verkettet, neue Cookie gesetzt) und liefert ein neues Token-Paar. Erlaubt langlebige Sessions ohne täglichen Passwort-Login (siehe omsorgapp/CLAUDE.md).
  5. POST /api/auth/logout (kein Body) widerruft den Refresh-Token aus der Cookie (AuthService.RevokeAsync, idempotent) und löscht die Cookie (Response.Cookies.Delete).

Refresh-Token: kein Rohtoken wird gespeichert, nur sein SHA-256-Hash (RefreshToken-Entity, IRefreshTokenGenerator). Gültigkeit über RefreshToken:ExpiryDays in appsettings.json (Default 60 Tage, sliding — jede Nutzung verlängert effektiv die Session), überschreibbar per RefreshToken__ExpiryDays.

Passwort-Mindestlänge: zentral über IPasswordPolicy/PasswordPolicy (src/OmsorgCore.Infrastructure/Security/PasswordPolicy.cs), gebunden an PasswordPolicy:MinLength in appsettings.json (Default 8, überschreibbar per PasswordPolicy__MinLength, siehe CONFIGURATION.md). Wird von UserService (Account-Anlage im Direct-Modus, Admin-Reset, ChangeOwnPasswordAsync) und PasswordResetService.ResetPasswordAsync injiziert geprüft — nicht mehr in den Controllern dupliziert. Bei Verstoß liefern die jeweiligen *Result-Typen einen PasswordTooShort-Wert, den UsersController/AuthController auf 400 BadRequest mit der aktuell konfigurierten Zahl im Fehlertext abbilden. GET /api/auth/password-policy (kein [Authorize]) liefert { minLength } für Clients, die denselben Wert für Hinweistexte/Vorab-Validierung brauchen (omsorgapp: AuthContext.jsx; omsorgWeb/mitarbeiter-app: omsorgcore_password_policy() in lib/omsorgCoreClient.php).

Secret-Handling: appsettings.json enthält nur Issuer/Audience/ExpiryMinutes/RefreshToken:ExpiryDays. appsettings.Development.json enthält einen lokalen Platzhalter für Jwt:Secret und den Connection-String (CHANGE_ME_...) — für echte Umgebungen über Umgebungsvariable (Jwt__Secret) oder dotnet user-secrets überschreiben, nie ein echtes Secret einchecken.

Basis-Rollen-Seed (alle Umgebungen): DbSeeder.SeedBaseRolesAsync (src/OmsorgCore.Infrastructure/Persistence/DbSeeder.cs) legt bei jedem Start die vier in REQUIREMENTS.md Abschnitt 3 ("Akteure & Rollen") und Abschnitt 7 ("Rechtematrix") beschriebenen Basis-Rollen an — Geschäftsführung (Sabina/Malik, voller Zugriff auf alle Module), Disposition/Buchhaltung (Sabrina, u. a. Absences mit allen Aktionen), Recruiting (Sascha), Außendienst (Employees.View + Contracts.View + Absences.{Create,View}, alle drei mit PermissionScope.Own — siehe "Datenebenen-Scope" oben; Absences ist dabei bereits an omsorgWeb/mitarbeiter-app angebunden, siehe "Abwesenheits-/Urlaubs-/Krankmeldungsanträge" unten, während Employees/Contracts dort noch nicht konsumiert werden). Läuft in Program.cs direkt nach den Migrationen, nicht auf IsDevelopment() beschränkt (im Gegensatz zum Admin-Seed unten) — enthält keine Zugangsdaten, nur Rollen-Stammdaten. Idempotent pro Rollenname: existiert eine Rolle schon (z. B. weil sie über die Rechte-Matrix-UI unter "Einstellungen" angepasst wurde), fasst der Seed sie nicht an.

Wichtig: Die Zuordnung in SeedBaseRolesAsync ist eine Übersetzung der Rechtematrix aus REQUIREMENTS.md auf die aktuellen ModuleType/PermissionAction-Werte. Kommt ein neuer ModuleType/eine neue PermissionAction dazu, oder ändert sich die Rechtematrix in REQUIREMENTS.md, muss dieser Seed im selben Change mit aktualisiert werden — er ist keine Momentaufnahme, sondern muss mit der Software mitwachsen (siehe auch den allgemeinen Pflegehinweis am Ende dieses Abschnitts).

Standard-Admin-Seed: DbSeeder.SeedDefaultAdminAsync (src/OmsorgCore.Infrastructure/Persistence/DbSeeder.cs) legt beim Start nur im Development-Modus (Program.cs, app.Environment.IsDevelopment()) einen Benutzer admin/abersicher mit einer neuen Rolle Administrator (alle ModuleType×PermissionAction-Kombinationen als RolePermission) an — idempotent, läuft nur wenn noch kein User existiert. DB-Fehler dabei (z. B. keine Verbindung — Migrationen sind zu diesem Zeitpunkt bereits automatisch angewendet, siehe "Datenbank" unten) sind nicht fatal, werden nur geloggt (try/catch um den Seed-Aufruf). Bewusst nicht in Produktion aktiv, um kein bekanntes Standard-Passwort auszuliefern — für einen echten Produktivbetrieb muss ein richtiger User-Anlage-Flow her.

Session-Killswitch (SecurityStamp)

Ein Access-Token ist als JWT zustandslos gültig bis zum Ablauf (60 Min) — ein reiner Refresh-Token-Widerruf verhindert nur das stille Verlängern, der bereits ausgestellte Access-Token bliebe sonst bis zu 60 Minuten gültig. Für einen echten Not-Aus-Schalter (Debug-Sicht in omsorgapp, "alle Nutzer sofort zum Neu-Login zwingen") trägt User.SecurityStamp (Guid) einen Wert, der:

  1. bei jedem Access-Token-Ausstellen als Claim "sst" mit eingebettet wird (JwtTokenGenerator),
  2. bei jedem authentifizierten Request per JwtBearerEvents.OnTokenValidated (Program.cs) gegen den aktuellen User.SecurityStamp in der DB geprüft wird (IUserRepository.GetByIdAsync, schlanker Lookup ohne Includes) — bei Mismatch oder fehlendem Claim context.Fail(...), sofort 401, unabhängig von der Token-Restlaufzeit.

ISessionAdminService/SessionAdminService (src/OmsorgCore.Application/Services/) kapselt die Admin-Operationen:

  • GetActiveSessionsAsync() — alle nicht widerrufenen/nicht abgelaufenen Refresh-Tokens (IRefreshTokenRepository.GetAllActiveAsync) als SessionInfo (Id, Username, CreatedAt, ExpiresAt).
  • RevokeSessionAsync(sessionId) — widerruft genau diesen Refresh-Token und würfelt den SecurityStamp nur des zugehörigen Users neu (sofortiger Kick für genau diesen Nutzer).
  • RevokeAllSessionsAsync() — widerruft alle aktiven Refresh-Tokens und würfelt SecurityStamp aller Nutzer neu (globaler Killswitch).

Exponiert über AdminSessionsController (GET /api/admin/sessions, POST /api/admin/sessions/{id}/revoke, POST /api/admin/sessions/revoke-all), geschützt über [RequirePermission(ModuleType.UserManagement, ...)] — per Default nur die Administrator-Rolle aus dem DbSeeder. Im Frontend: omsorgapp/src/modules/debug/DebugSessionsPage.jsx, im Sidebar-Menü nur sichtbar, wenn hasPermission("UserManagement", "View") (siehe GET /api/auth/me unten) — dieselbe Rechteprüfung wie serverseitig, kein reiner Rollennamen-Vergleich mehr im Client.

Revoke/RevokeAll dispatchen jeweils zusätzlich ein AuditEvent ("SessionRevoked"/"AllSessionsRevoked", siehe "Audit-Log" oben) — ein Session-Kill hinterlässt damit nachvollziehbar, welcher Aktor ihn wann ausgelöst hat.

Kosten: ein zusätzlicher DB-Read pro authentifiziertem Request (GetByIdAsync). Für die aktuelle Nutzerzahl vernachlässigbar — bei relevantem Traffic-Wachstum wäre ein Cache (z. B. In-Memory mit kurzer TTL) der nächste Schritt, aber kein Caching ohne echten Bedarf, um die Sofortigkeit des Killswitches nicht zu unterlaufen.

Passwort-Reset / E-Mail-Versand

Vollständig implementiert: PasswordResetCode-Entity + PasswordResetService (PIN anfordern/verifizieren, Passwort setzen, siehe Auth-Flow-Analogie: Session-Revoke + SecurityStamp-Rotation nach erfolgreichem Reset), exponiert über AuthController (POST /api/auth/forgot-password/{request,verify,reset}).

E-Mail-Versand: IEmailSender (OmsorgCore.Application.Abstractions) hat zwei Implementierungen in OmsorgCore.Email, Auswahl über Email:Provider zur Startzeit (DependencyInjection.AddEmail):

  • "Console" (Default) — schreibt nur ins Log, kein echter Versand. Sicherer Default für Umgebungen ohne SMTP-Konfiguration.
  • "Smtp" — echter Versand über MailKit (SmtpEmailSender). Ein gemeinsamer SMTP-Server (Email:Smtp:*), darüber mehrere benannte Accounts (Email:Accounts:<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.

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). AddPermissionScope (fügt Scope auf role_permissions/user_permission_overrides hinzu, siehe "Rechtesystem") wurde per dotnet ef database update erfolgreich gegen die echte Instanz angewendet. AddFacilityConditionsAndQualificationRates (Konditionen-Felder auf Facility + Tabelle facility_qualification_rates, FR-EIN-4), AddAbsences (Tabelle absences, FR-CON-1/FR-EM-3) und AddTimeEntryStatusAndSurchargeHours (TimeEntry.StatusId+Zuschlagsstunden, ValueListItem.IsEditableByOwner, ValueListItemTransition.RequiresApproval, FR-ZE-1/FR-ZE-2) wurden beim automatischen API-Start erfolgreich gegen die echte Instanz angewendet. AddTravelCostModeAndEmployeeFacilityDistances (Facility.TravelCostMode/TravelCostPerKm + Tabelle employee_facility_distances, siehe "Konditionen einer Einrichtung") und RemoveFacilityBreakPolicy (löscht die Spalte BreakPolicy von facilities wieder) sind erzeugt (dotnet build/dotnet test grün), aber noch nicht gegen eine echte Instanz verifiziert.

Build- und Run-Befehle

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).
  • omsorgapps authClient.cjs erfolgreich gegen den laufenden Server getestet (Login/Refresh/Logout-Fehlerfälle).
  • Kompletter Login-Flow end-to-end mit echtem Postgres verifiziert: Login mit admin/abersicher (Seed) → gültiges Token-Paar; refresh rotiert korrekt (neues Paar, alter Refresh-Token danach 401 bei Wiederverwendung); GET /api/employees mit frischem Access-Token → 200 (Administrator-Rolle hat volle Rechte über den Seed).
  • Session-Killswitch end-to-end verifiziert: GET /api/admin/sessions liefert aktive Sessions; POST /api/admin/sessions/revoke-all → 204, danach liefert derselbe, zuvor gültige Access-Token sofort 401 (nicht erst nach Ablauf) und der zugehörige Refresh-Token liefert bei POST /api/auth/refresh ebenfalls 401. Erneuter Login mit admin/abersicher funktioniert danach wieder normal.
  • DbSeeder.SeedBaseRolesAsync gegen echte PostgreSQL-Instanz verifiziert: legt Geschäftsführung/Disposition/Buchhaltung/Recruiting/Außendienst mit der erwarteten Rechteanzahl an (54/33/10/0 Permissions), zweiter Lauf verändert nichts (idempotent pro Rollenname). Hinweis: Seit AddPermissionScope bekommt Außendienst neu Employees.View+Contracts.View (Scope Own), seit den Absences-/TimeEntries-/EmployeeFacilityDistances-Erweiterungen zusätzlich Absences.{Create,View,Edit}, TimeEntries.{Create,View,Edit} und EmployeeFacilityDistances.{Create,View,Edit} (alle Scope Own) — die Zahl "0" für Außendienst ist damit veraltet (jetzt 11 erwartet), aber noch nicht erneut per echtem Seed-Lauf verifiziert (der Seed läuft nur bei leerer Roles-Tabelle bzw. pro fehlendem Rollennamen, nicht erneut gegen eine bereits befüllte Instanz).

Konfigurierbare Auswahllisten

Dropdown-Werte, die früher als hartcodierte Arrays im omsorgapp-Frontend lebten (Mitarbeiterstatus, Beschäftigungsart, CRM-Status, Einrichtungstyp) plus die entsprechenden, bisher nur als freier String modellierten Felder auf Contract (Vertragstyp/-status) und der Auftragsstatus (FR-EM-2) sind jetzt eine gemeinsame, admin-editierbare Stammdaten-Struktur statt Enum/hartcodiertes Array — Ziel: Löschen/Umbenennen/Hinzufügen ohne Code-Deploy, über die "Status-Verwaltung" unter "Einstellungen" in omsorgapp.

Datenmodell (src/OmsorgCore.Domain/Entities/): ValueList (Stammdaten einer Liste — Key, eindeutig, z. B. "EmployeeStatus", "EmploymentType", "CrmStatus", "FacilityType", "ContractType", "ContractStatus", "OrderStatus", "DocumentCategory", "Qualification", "ShiftType", "Priority", "BillingInterval", "AbsenceType", "AbsenceStatus"; DisplayName für die Admin-UI) + ValueListItem (Value, SortOrder, IsDefault, IsInitial/IsTerminalIsTerminal nur für "OrderStatus" relevant, IsInitial zusätzlich für "AbsenceStatus" (markiert dort den "noch nicht entschieden"-Zustand, siehe "Abwesenheits-/Urlaubs-/Krankmeldungsanträge" unten)) + ValueListItemTransition (erlaubte Übergänge zwischen zwei Items derselben Liste, wird nur für "OrderStatus" befüllt). Ersetzt das frühere OrderStatusDefinition/OrderStatusTransition-Sondermodell — Migration ReplaceOrderStatusWithValueLists übernimmt bestehende Auftragsstatus-Zeilen 1:1 mit identischen Ids in die neuen Tabellen, damit Order.StatusId unverändert gültig bleibt.

Wo welches Feld referenziert wird:

  • Order.StatusId (FK, echte Fremdschlüsselbeziehung auf ValueListItem.Id) — einzige Liste mit Übergangsregeln. OrderService.CreateAsync/UpdateAsync nutzen IValueListRepository.GetInitialItemAsync("OrderStatus", ...)/CanTransitionAsync(...) genau wie zuvor IOrderStatusRepository.
  • Employee.Status/EmploymentType, Facility.CrmStatus/FacilityType, Contract.ContractType/Status bleiben bewusst einfache string-Spalten (kein FK, keine Schema-Migration auf diesen Tabellen nötig) — stattdessen prüfen EmployeesController/FacilitiesController/ContractsController beim Schreiben serverseitig über IValueListRepository.GetActiveValuesAsync(key, ...), dass der übergebene Wert unter den aktuell konfigurierten Werten der zugehörigen Liste ist (400 sonst) — analog zur bereits bestehenden Passwort-Policy-Validierung.
  • Employee.Qualification, Order.RequiredQualification und FacilityQualificationRate.Qualification (siehe "Konditionen einer Einrichtung" unten) referenzieren alle drei dieselbe Liste "Qualification" (ebenfalls einfache string-Spalten, gegen GetActiveValuesAsync/GetItemsAsync validiert wie oben — EmployeesController/OrdersController/FacilityQualificationRatesController). Bewusst eine gemeinsame Liste statt getrennter, damit "benötigte Qualifikation" auf einem Auftrag, "Qualifikation" eines Mitarbeiters und der qualifikationsabhängige Preis einer Einrichtung aus derselben, gleich sortierten Werteliste kommen — Voraussetzung für einen künftigen automatisierten Abgleich (FR-EM-3, Mitarbeiterzuweisung prüft Qualifikation, noch nicht umgesetzt). SortOrder bildet dabei die Rangfolge der Qualifikationsniveaus ab (aufsteigend, siehe Seed unten) — es gibt bewusst kein zusätzliches "Level"-Feld, SortOrder übernimmt diese Rolle bereits für jede Liste. Da der Wert von mehreren Entitäten referenziert wird, prüft eine eigene IValueListUsageChecker-Implementierung (QualificationValueListUsageChecker, nicht der generische StringFieldValueListUsageChecker) alle drei Tabellen — ValueListService.FindUsagesAsync befragt pro Key nur den ersten registrierten Checker, separate Registrierungen mit demselben Key hätten weitere Verwendungsstellen beim Löschschutz stillschweigend ignoriert.
  • Order.ShiftType referenziert die Liste "ShiftType" (nur eine Entität betroffen, daher regulärer StringFieldValueListUsageChecker wie bei den übrigen einfachen String-Listen).
  • Order.Priority (Pflichtfeld, kein ?) referenziert die Liste "Priority" (Niedrig/Normal/Hoch/Dringend, Default "Normal" passend zum Entity-Default). GET /api/orders unterstützt zusätzlich ?priority=/?requiredQualification=/?shiftType= als exakte Gleichheitsfilter (neben den bereits bestehenden ?statusId=/?facilityId=, OrderRepository.GetPagedAsync) — Achtung: ein neuer Query-Parameter auf einem bestehenden Endpoint ändert die OpenAPI-Spec genauso wie ein geändertes Contract-DTO, omsorgapp/api-client-ts muss danach ebenfalls neu generiert werden (siehe "Generierte API-Clients" unten), sonst kennt der generierte OrdersApi.apiOrdersGetRaw den Parameter nicht und die Filterung wirkt sich nicht aus, obwohl Backend und Frontend-Code beide "richtig" aussehen.
  • Facility.BillingInterval referenziert die Liste "BillingInterval" (Wöchentlich/Monatlich/Quartalsweise, nur eine Entität betroffen, regulärer StringFieldValueListUsageChecker), Teil der Konditionen einer Einrichtung — siehe eigener Abschnitt "Konditionen einer Einrichtung" unten.
  • Absence.Type/Status referenzieren "AbsenceType" (Urlaub/Krankmeldung/Sonstige) bzw. "AbsenceStatus" (Eingereicht/Genehmigt/Abgelehnt) — beide einfache string-Spalten wie Contract.Status, ohne ValueListItemTransition (kein Übergangsgraph nötig), aber mit IsInitial auf dem "Eingereicht"-Item, damit "noch nicht entschieden" nicht als Anzeigetext-Vergleich hartkodiert werden muss (siehe eigener Abschnitt "Abwesenheits-/Urlaubs-/Krankmeldungsanträge" unten).

Verwaltungs-API (ValueListsController, Route api/value-lists): GET /api/value-lists (alle Listen), GET /api/value-lists/{key}/items (nur [Authorize], kein Modul-Recht — die aufrufenden Formulare gehören zu unterschiedlichen Modulen), POST/PUT/DELETE .../items[/...] sowie GET/PUT .../transitions (nur für "OrderStatus") gegated über [RequirePermission(ModuleType.Configuration, PermissionAction.Edit)] — ein eigenes Admin-Recht, getrennt von Benutzer-/Rechteverwaltung (siehe "Rechtesystem" oben, Abschnitt "Drei getrennte Admin-Rechte").

Löschschutz ("erst überall entfernen"): ValueListService.DeleteItemAsync löscht ein ValueListItem nur, wenn keine Verwendung mehr existiert. Eine IValueListUsageChecker-Implementierung je Liste (Infrastructure/Repositories/StringFieldValueListUsageChecker.cs — eine generische Klasse für alle String-Feld-Listen, mehrfach mit unterschiedlicher Query registriert in Infrastructure/DependencyInjection.cs; OrderStatusValueListUsageChecker.cs für die FK-basierte "OrderStatus"-Liste inkl. Übergangsregeln) prüft, ob der Wert noch irgendwo gesetzt ist. Bei Treffern liefert DELETE .../items/{id} 409 mit den Fundstellen (EntityType/EntityId/DisplayLabel) im Body, statt zu löschen. GET .../items/{id}/usages liefert dieselbe Prüfung jederzeit (nicht nur beim Löschversuch) — für den "wo wird das noch verwendet"-Info-Button in der UI.

Seed: DbSeeder.SeedValueListsAsync (jede Umgebung, idempotent — läuft nur, solange ValueLists leer ist) legt alle zwölf Listen mit Startwerten an, inkl. der Auftragsstatus-Pipeline (Anfrage → Prüfung → offen → teilweise besetzt → vollständig besetzt → aktiv → abgeschlossen, plus Storno aus jedem nicht-terminalen Status) samt Übergangsregeln, sowie "Qualification" mit sieben Startwerten in aufsteigender Rangfolge (Ungelernte Kraft → Betreuungskraft → Pflegehelfer/in → Pflegefachassistent/in → Altenpfleger/in → Gesundheits- und Krankenpfleger/in → Pflegefachkraft mit Leitungsfunktion), "ShiftType" mit sechs Startwerten (Frühdienst, Spätdienst, Nachtdienst, Tagdienst, Bereitschaftsdienst, Sonstige), "Priority" mit vier Startwerten (Niedrig, Normal, Hoch, Dringend) und "BillingInterval" mit drei Startwerten (Wöchentlich, Monatlich (Default), Quartalsweise) — reine Startbefüllung, über "Status-Verwaltung" admin-editierbar. Wichtig: Kommt eine neue admin-editierbare Auswahlliste hinzu, gehört sie hier als weiterer SeedSimpleListAsync-Aufruf rein plus eine IValueListUsageChecker-Registrierung in Infrastructure/DependencyInjection.cs — dieser Abschnitt und der Seed müssen mit der Software mitwachsen.

Frontend (omsorgapp): src/api/valueListsApi.js kapselt /api/value-lists, src/app/useValueListItems.js (Hook) lädt die Items einer Liste für Dropdowns (ersetzt die früheren hartcodierten Arrays in EmployeeForm.jsx/FacilityForm.jsx/EmployeesPage.jsx/FacilitiesPage.jsx). Verwaltungs-UI: src/modules/settings/StatusManagementPanel.jsx, Tab "Status-Verwaltung" in SettingsPage.jsx, gegated über hasPermission("Configuration", ...).

Dokumentenarchiv (FR-MA-3)

Document (src/OmsorgCore.Domain/Entities/Document.cs, AuditableEntity) referenziert eine beliebige Kern-Entität polymorph über EntityType (String-Wert aus DocumentEntityType, aktuell nur Employee mit echtem Upload-/Validierungspfad — Facility/Contract/Order sind als Enum-Werte für ein künftiges Ausrollen vorgesehen, ohne Migration nachzuziehen) + EntityId (kein FK, da mehrere Zieltabellen). Category wird wie Employee.Status/Contract.ContractType gegen die admin-editierbare ValueList "DocumentCategory" validiert (siehe "Konfigurierbare Auswahllisten").

Bytes liegen auf dem Dateisystem, nicht als Blob in Postgres: IDocumentStorage/FileSystemDocumentStorage (Infrastructure/Storage/) legt Uploads unter Storage:DocumentsRootPath/{EntityType}/{EntityId}/{DocumentId}{Extension} ab (Guids im Pfad, kein Client-String — kein Path-Traversal-Vektor); die DB speichert nur den relativen Pfad (Document.StorageKey) + Metadaten. Größen-/Dateityp-Grenzen (Storage:MaxDocumentSizeBytes/Storage:AllowedDocumentContentTypes, siehe CONFIGURATION.md) prüft IDocumentUploadPolicy/DocumentUploadPolicy, injiziert in DocumentService.UploadAsync — analog zu IPasswordPolicy.

API (DocumentsController, Route api/documents, gegated über [RequirePermission(ModuleType.Documents, ...)]): GET ?entityType=&entityId= (Liste), POST (multipart/form-data, UploadDocumentRequest mit IFormFile; bei EntityType=Employee prüft der Controller zusätzlich per IEmployeeRepository, dass der Mitarbeiter existiert — analog zur FacilityContactId-Cross-Validierung in OrdersController), PUT /{id} (nur Metadaten — Category/Description/FileName, JSON-Body, kein erneuter Datei-Upload — PermissionAction.Edit), GET /{id}/download (streamt die Datei), DELETE /{id} (Soft-Delete).

Zugriffsprotokoll (FR-MA-3-Anforderung): Der automatische AuditSaveChangesInterceptor erfasst nur Create/Update/(Soft-)Delete an Document, nicht das lesende Herunterladen. DocumentsController.Download dispatcht deshalb zusätzlich ein AuditEvent(..., "DocumentDownloaded", ...) über IDomainEventDispatcher — derselbe Mechanismus wie Login/Logout/Session-Kill (siehe "Audit-Log" oben). Jeder Download ist damit über GET /api/audit-log nachvollziehbar.

Rechtematrix: ModuleType.Documents ist Teil der Basis-Rollen-Seed (DbSeeder.SeedBaseRolesAsync) — Geschäftsführung und Disposition/Buchhaltung (Sabrina) haben vollen Zugriff (FR-MA-3: Sabina/Malik/Sabrina), Recruiting/Außendienst bewusst nicht. Ein Außendienst-Selbstzugriff ("nur eigene Dokumente") ist noch nicht abgebildet, weil OMSORG Connect noch nicht gegen dieses Backend spricht (siehe "Offene Punkte").

Konditionen einer Einrichtung (FR-EIN-4)

Zehn der zwölf Blueprint-19.2-Konditionsfelder (Verrechnungssatz, vier Zuschläge, Fahrtkosten, Mindeststunden, 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. Pausenregelung wurde am 2026-08-10 wieder entfernt (fachlich nicht gebraucht, BreakPolicy auf Facility/UpdateFacilityRequest/FacilityResponse gelöscht, Migration RemoveFacilityBreakPolicy, beide generierten Clients neu erzeugt, FacilityForm.jsx/FacilityDetailPanel.jsx bereinigt) — falls sie doch wieder gebraucht wird, ist das ein Neuanlegen, kein Wiederherstellen (Hard-Delete der Spalte, kein Soft-Delete-Konzept für Schema-Felder):

  • BillingRate (Verrechnungssatz, EUR/Std.), NightSurchargePercent/SaturdaySurchargePercent/SundaySurchargePercent/HolidaySurchargePercent (Zuschläge als Prozent auf den Verrechnungssatz, nicht als EUR-Betrag), MealAllowanceRate (Verpflegungsmehraufwand, EUR-Pauschale je Einsatz — bewusst kein tageweises/gesetzliches Stufenmodell wie beim echten VMA, sondern wie TravelCostRate ein einziger fixer Betrag je Einrichtung), MinimumHours (Mindeststunden je Einsatz), PaymentTermDays (Zahlungsziel in Tagen), IndividualAgreements (Freitext).
  • Fahrtkosten (seit 2026-08-10 zwei Modi statt nur Pauschale): TravelCostMode ("Pauschale" oder "ProKilometer", Default "Pauschale", im Controller gegen ein festes Literal-Array geprüft — bewusst keine ValueList, da die Rechnungserstellung später hart zwischen genau diesen zwei Fällen unterscheiden muss, ein dritter admin-hinzufügbarer Wert würde die künftige FR-RE-1-Berechnungslogik lautlos brechen, analog zur Begründung bei AbsenceStatus.IsInitial) entscheidet, welches Feld gilt: TravelCostRate (Pauschale je Einsatz, EUR) oder TravelCostPerKm (EUR/km) × EmployeeFacilityDistance.DistanceKm. Eine feste Facility-Distanz reicht nicht, weil jeder Mitarbeiter von einem anderen Wohnort anfährt — daher EmployeeFacilityDistance (Facility×EmployeeDistanceKm, AuditableEntity) als zweite 1:n-Unterressource von Facility, exakt nach dem FacilityQualificationRate-Muster: GET/POST/PUT/DELETE /api/facilities/{facilityId}/employee-distances[/...] (EmployeeFacilityDistancesController), gegated über dieselben Facilities-Rechte, kein eigener ModuleType. Create validiert zusätzlich, dass EmployeeId auf einen existierenden Mitarbeiter zeigt (IEmployeeService.GetByIdAsync) und dass für das Facility/Employee-Paar noch keine (nicht gelöschte) Distanz existiert. Soft-Delete, über TrashController (api/trash/employee-facility-distances/...) wiederherstellbar.
  • Selbstbedienung durch den Außendienst (seit 2026-08-10, MyFacilityDistancesController, Route api/me/facility-distances): Mitarbeiter sollen ihre eigene Fahrtstrecke selbst über omsorgWeb/mitarbeiter-app pflegen können, statt dass das Büro jede Kilometerangabe manuell einträgt. Dafür bewusst kein Facilities-Recht (das würde Konditionen/CRM-Daten offenlegen, die dem Außendienst laut Rechtematrix nicht zustehen), sondern ein neuer, eigenständiger ModuleType.EmployeeFacilityDistances (siehe "Rechtesystem" oben) — GET /api/me/facility-distances (eigene Distanzen inkl. FacilityName), GET /api/me/facility-distances/facilities (minimale Einrichtungsauswahl, nur Id/Name über FacilityOptionResponse, für das Formular-Dropdown), POST/PUT (Anlegen/Bearbeiten). EmployeeId kommt bei jeder Aktion ausschließlich aus ICurrentUserService.EmployeeId (JWT-Claim), nie vom Client — bei fehlender Verknüpfung 400 statt eines FK-Fehlers, analog zum AbsenceService.CreateAsync-Fallback. Bewusst kein DELETE hier (Löschen bleibt Büro-Aufgabe über den Papierkorb). Nutzt intern denselben IEmployeeFacilityDistanceService/dieselbe Tabelle wie EmployeeFacilityDistancesController — zwei Controller auf demselben Application-Service, unterschiedliche Zugriffsrechte, kein Datenmodell-Unterschied.
  • Zugleich behobener Bestandsfehler: FacilityService.UpdateAsync kopierte die elf Konditionsfelder bislang gar nicht auf die getrackte Entität — der Controller validierte sie korrekt, aber PUT /api/facilities/{id} verwarf sie stillschweigend (kein Fehler, kein Log, das Feld blieb einfach null/unverändert). Betraf BillingRate/alle vier Zuschläge/TravelCostRate/MinimumHours/BillingInterval/PaymentTermDays/IndividualAgreements seit deren Einführung (damals inkl. BreakPolicy, seit 2026-08-10 entfernt, siehe oben). Jetzt behoben, Regressionstest: FacilityServiceTests.UpdateAsync_PersistsKonditionenFields.
  • BillingInterval wird wie FacilityType/ContractType gegen die admin-editierbare ValueList "BillingInterval" (Wöchentlich/Monatlich/Quartalsweise) validiert — siehe "Konfigurierbare Auswahllisten".
  • Alle zwölf Felder sind auch beim Anlegen über CreateFacilityRequest setzbar (zusätzlich zu UpdateFacilityRequest). CreateFacilityRequest enthält die gleichen Konditionsfelder wie UpdateFacilityRequestCrmStatus ist Pflicht beim Anlegen. Die Validierung für diese Felder (Wertebereich, Allowlists, FollowUp-Logik) läuft in beiden Cases, nur die CanTransitionAsync-Prüfung ("erlauber Statuswechsel") entfällt beim Anlegen, da es dort keinen Vorzustand gibt — jeder aktive CRM-Status ist beim Anlegen wählbar.

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-legacys 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.

ValueListsControllers generische GET/PUT {key}/transitions-Endpoints funktionieren unverändert für "TimeEntryStatus" (keine Sonderbehandlung im Code nötig, siehe "Konfigurierbare Auswahllisten" oben) — ValueListTransitionResponse liefert jetzt zusätzlich RequiresApproval, damit Clients (siehe omsorgapp/omsorgWeb unten) die für ihre Rolle relevanten Kanten selbst herausfiltern können.

Verwendung: omsorgWeb/mitarbeiter-app (pages/stundenerfassung.php) — Außendienst legt/bearbeitet eigene Einträge und löst submit aus. omsorgapp (TimeEntriesPage/TimeEntryDetailPanel, Sidebar-Tab "Zeiterfassung") — Büro prüft/entscheidet über decision. Bewusst offen: Order hat keine Mitarbeiter-Zuweisung (FR-EM-3), das Auftrags-Dropdown in beiden Frontends zeigt deshalb alle aktiven Aufträge statt nur zugewiesene.

Offene Punkte

  • Facility hat jetzt volles Repository/Service/Controller (FacilitiesController, GET/POST/PUT/DELETE /api/facilities) nach dem Employee-Muster, inkl. FacilityCreatedEvent. Zusätzlich FacilityContact (FR-EIN-2, Ansprechpartner), FacilityQualificationRate (FR-EIN-4, qualifikationsabhängige Preise) und EmployeeFacilityDistance (FR-EIN-4, kilometerbasierte Fahrtkosten) als 1:n-Unterressourcen unter GET/POST/PUT/DELETE /api/facilities/{facilityId}/contacts[/...], .../qualification-rates[/...] bzw. .../employee-distances[/...] (FacilityContactsController/FacilityQualificationRatesController/EmployeeFacilityDistancesController, siehe "Konditionen einer Einrichtung" oben) — alle drei bewusst kein eigener ModuleType, sondern über dieselben Facilities-Rechte gegated, da keines ein eigenständiges Core-Objekt ist. Löschen (Employee/Facility/Contract/Order/FacilityContact/FacilityQualificationRate/EmployeeFacilityDistance) ist jetzt durchgängig Soft-Delete (IsDeleted/DeletedAt, PermissionAction.Delete je Modul, DELETE-Endpoint pro Controller) und über den TrashController (api/trash/..., PermissionAction.Recover je Modul, "Papierkorb"-Seite in omsorgapp) wiederherstellbar.
  • Contract hat jetzt ebenfalls volles Repository/Service/Controller (ContractsController, GET/POST/PUT /api/contracts, gegated über [RequirePermission(ModuleType.Contracts, ...)]) nach demselben Facility-Muster, inkl. ContractCreatedEvent. Deckt FR-MA-2 auf Backend-Seite ab: WeeklyHours (Arbeitszeit), HourlyWage (Stundenlohn), AllowancesDescription (Zuschläge, Freitext), OvertimeRules (Überstundenregelung, Freitext), VacationDaysPerYear (Urlaubsanspruch), ProbationPeriodMonths (Probezeit) — alle nullable, da ein Vertrag entweder einem Mitarbeiter oder einer Einrichtung zugeordnet ist (EmployeeId/FacilityId, mindestens eins muss gesetzt sein, per Controller-Validierung erzwungen) und nicht jeder Vertragstyp alle Felder braucht. ContractConfiguration hat jetzt (wie Facility) einen HasQueryFilter(!IsDeleted). FR-MA-2 ist damit inkl. omsorgapp-UI abgeschlossen: "Verträge"-Tab in EmployeeDetailPanel (ContractsList/ContractForm/Create-/EditContractDialog.jsx), neue Verträge starten als "Entwurf", Statuswechsel nur im Bearbeiten-Formular, Löschen als Soft-Delete über den Papierkorb wiederherstellbar (siehe omsorgapp/CLAUDE.md).
  • Order hat jetzt ebenfalls volles Repository/Service/Controller (OrdersController, GET/POST/PUT /api/orders, gegated über [RequirePermission(ModuleType.Orders, ...)]) nach demselben Facility/Contract-Muster, inkl. OrderCreatedEvent. Deckt FR-EM-1 auf Backend-Seite ab: FacilityContactId (optionaler Ansprechpartner, gegen FacilityId cross-validiert — der Kontakt muss zur angegebenen Einrichtung gehören, sonst 400), ShiftType (Schichtart, Freitext), RequiredHeadcount (Anzahl Mitarbeiter, mindestens 1), Conditions (Konditionen, Freitext), Priority (Priorität, Freitext). Der Auftragsstatus (FR-EM-2, Order.StatusId) ist Teil der generischen Auswahllisten — siehe "Konfigurierbare Auswahllisten" unten. OrderConfiguration hat jetzt (wie Facility/Contract) einen HasQueryFilter(!IsDeleted). Kein omsorgapp-UI-Modul für Aufträge selbst in diesem Schritt (nur die Statuspflege über "Status-Verwaltung"). TimeEntry hat jetzt ebenfalls volles Repository/Service/Controller (FR-ZE-1/FR-ZE-2, siehe "Zeiterfassung" oben) — Invoice hat weiterhin nur Domain-Entität + DB-Konfiguration, nächster Schritt folgt demselben Muster (Repository-Interface in Application, Implementierung in Infrastructure, Service in Application, Controller in Api) und muss FR-ZE-3 (nur freigegebene Zeit fließt ein) auf Domain-/Application-Ebene erzwingen.
  • Dokumentenarchiv (FR-MA-3) hat jetzt volles Repository/Service/Controller (DocumentsController, GET/POST /api/documents, PUT /{id} für Metadaten, GET /{id}/download, DELETE /{id}) — siehe "Dokumentenarchiv" oben. omsorgapp-UI ("Dokumente"-Tab in der Personalakte) existiert jetzt ebenfalls, siehe omsorgapp/CLAUDE.md. Kein Außendienst-Selbstzugriff, kein physisches Löschen von Dateien beim Soft-Delete/Papierkorb (keine Hard-Purge-Stelle im System, die man konsistent mitziehen müsste).
  • Keine E-Mail-Verifizierung bei User-Anlage (Passwort-Reset per E-Mail ist fertig, siehe "Passwort-Reset / E-Mail-Versand" oben).
  • omsorgapp spricht seit Kurzem gegen dieses Backend (Login-Screen + Refresh-Token-Session, siehe omsorgapp/CLAUDE.md) — omsorgWeb ist noch nicht angebunden.
  • Kein Docker-/CI-Setup.

Generierte API-Clients

Aus der Swagger/OpenAPI-JSON dieses Backends (/swagger/v1/swagger.json, nur im Development-Modus aktiv) werden mit openapi-generator-cli typisierte Clients generiert — omsorgapp/api-client-ts/ (TypeScript, typescript-fetch-Template) und omsorgWeb/mitarbeiter-app/api-client-php/ (PHP).

Wichtig — beide generierten Clients sind im echten Datenpfad, nicht optional: Alle Wrapper unter omsorgapp/src/api/*Api.js (employeesApi.js, facilitiesApi.js, facilityContactsApi.js, facilityQualificationRatesApi.js, employeeFacilityDistancesApi.js, usersApi.js, rolesApi.js, valueListsApi.js, auditLogApi.js, authApi.js, absencesApi.js, timeEntriesApi.js, ...) importieren die jeweilige *Api-Klasse aus dem generierten Paket omsorgcore-client-ts (import { ... } from "omsorgcore-client-ts") und reichen Requests/Responses ungeprüft typisiert durch. omsorgWeb/mitarbeiter-app/lib/omsorgCoreClient.php nutzt seit den Absences-/Orders-/TimeEntries-Wrappern ebenfalls den generierten PHP-Client (../api-client-php/, \OmsorgCoreClient\Api\...) statt rohem cURL — beide Frontends müssen also nach einer Contract-Änderung neu generiert werden, nicht nur omsorgapp.

Verbindliche Regel: nach jeder Änderung an einem Controller oder DTO in omsorgCore.Api/Contracts müssen omsorgapp/api-client-ts und omsorgWeb/mitarbeiter-app/api-client-php neu generiert und neu gebaut werden — noch in demselben Change, nicht als Nachgang. Wird das vergessen, gibt es keinen Fehler, keine Exception, keine Warnung — der generierte Client kennt das neue/geänderte Feld schlicht nicht und lässt es beim Serialisieren/Deserialisieren stillschweigend weg. Das Symptom in der UI: Speichern/Anlegen meldet Erfolg, aber das betroffene Feld kommt nie im Backend an bzw. taucht nie in der Antwort auf — schwer zu debuggen, weil weder Backend noch Frontend-Code einen sichtbaren Fehler werfen (siehe FR-EIN-1/Website-Vorfall, 2026-08-08).

# omsorgCore muss dafür lokal im Development-Modus laufen (Swagger nur dort aktiv):
cd omsorgCore && ASPNETCORE_ENVIRONMENT=Development dotnet run --project src/OmsorgCore.Api

# in einem zweiten Terminal:
cd omsorgapp/api-client-ts
npm run generate   # entspricht ./generate.sh — überschreibt src/, README.md, package.json, tsconfig*.json
npm run build       # erzeugt dist/, das die *Client.cjs-Wrapper tatsächlich importieren

npm run generate überschreibt package.json komplett (Standard-Output von openapi-generator) — das dort eingetragene generate-Script muss danach jedes Mal erneut ergänzt werden (git diff package.json prüfen), sonst verschwindet es beim nächsten Lauf wieder. Details/Voraussetzungen: omsorgapp/api-client-ts/ANLEITUNG.md (das jeweilige README.md wird vom Generator automatisch überschrieben, ANLEITUNG.md bleibt stabil). omsorgWeb/mitarbeiter-app/api-client-php ist seit den Absences-/Orders-/TimeEntries-Wrappern ebenfalls im echten Datenpfad (siehe oben) und muss im selben Zug regeneriert werden, nicht nur "bei Gelegenheit".

Die sechs Core-Objekte (Domain-Entitäten)

Aus REQUIREMENTS.md Abschnitt 6 / Blueprint Kap. 19: Mitarbeiter, Einrichtung, Vertrag, Auftrag, Zeiterfassung, Rechnung — als Employee, Facility, Contract, Order, TimeEntry, Invoice in src/OmsorgCore.Domain/Entities/ angelegt, mit Kernfeldern (nicht vollständig ausmodelliert).

Verbindliche Regeln für das Datenmodell:

  1. Jede Entität hat eine eindeutige Guid Id (siehe Entity-Basisklasse).
  2. Beziehungen ausschließlich über Foreign Keys/IDs, keine redundante Texteingabe verwandter Daten.
  3. Stammdaten nur an einer Stelle — kein Feld, das auch in omsorgWeb oder omsorgapp unabhängig gepflegt wird, sobald die Migration dorthin begonnen hat.
  4. Änderungen an geschäftsrelevanten Daten müssen nachvollziehbar sein — AuditableEntity liefert CreatedAt/UpdatedAt; ein vollständiger Audit-Trail (wer hat was geändert) läuft automatisch über den AuditSaveChangesInterceptor (siehe "Audit-Log" oben), keine Handarbeit pro Entität nötig.
  5. Kein Hard-Delete für sensible/geschäftsrelevante Daten — Soft-Delete/Archivierung. Umgesetzt für Employee/Facility/Contract/Order/FacilityContact/FacilityQualificationRate/EmployeeFacilityDistance/Absence/TimeEntry (IsDeleted/DeletedAt, DELETE-Endpoints gegated über PermissionAction.Delete, Wiederherstellung über TrashController/PermissionAction.Recover); Invoice hat noch kein CRUD, daher hier noch nicht relevant.
  6. Berechtigungsprüfung auf Daten- und Funktionsebene (siehe Rechtesystem oben und Rechtematrix in REQUIREMENTS.md Abschnitt 7).
  7. Rechnungen entstehen ausschließlich aus freigegebener Zeiterfassung (FR-ZE-3/FR-RE-1) — muss bei Ausbau von TimeEntry/Invoice auf Domain-/Application-Ebene erzwungen werden, nicht nur als UI-Regel im Client.

Event-Schicht (Engine) — Funktionsweise

IDomainEventDispatcher (Singleton, In-Process) löst über den DI-Container alle registrierten IDomainEventHandler<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.