Adds MealAllowanceRate as a flat, nullable EUR amount on Facility, following
the same pattern as the existing TravelCostRate/BillingRate fields — a fixed
per-facility rate, not the statutory day-based VMA tiers, per explicit
request.
Both generated API clients are regenerated/rebuilt as required after any
Contract/Controller change (see omsorgCore/CLAUDE.md, "Generierte API-
Clients"). The PHP client regen also picks up the DELETE /api/roles/{id}
endpoint added in a previous backend change but never synced to this client
until now.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
75 KiB
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.PostgreSQL8.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.mdundREQUIREMENTS.mdAbschnitt 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" inSettingsPage).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).
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):
- Automatisch, Entity-Änderungen:
AuditSaveChangesInterceptor(src/OmsorgCore.Infrastructure/Persistence/AuditSaveChangesInterceptor.cs, ein EF-Core-SaveChangesInterceptor) erfasst bei jedemSaveChangesAsyncaufOmsorgCoreDbContextautomatisch jede Create/Update/(Soft-)Delete-Änderung an einer beliebigenAuditableEntity-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 inDetailsgespeichert; ein Soft-Delete (IsDeleted: false→true) wird als Action"Deleted"erkannt, nicht als"Updated". Sensible Felder (aktuellUser.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. - Explizit, Verhaltens-Ereignisse ohne Entity-Änderung:
AuditEvent(src/OmsorgCore.Engine/Events/AuditEvent.cs) über den bestehendenIDomainEventDispatcherdispatcht, analog zuEmployeeCreatedEvent(siehe "Event-Schicht" unten) — für Aktionen wie Login/Logout/Session-Kill, die keineAuditableEntityverändern.AuditEventHandler(Engine/Handlers/) persistiert das Event alsAuditLogEntrymitCategory = BehavioralEvent. Aktuelle Dispatch-Stellen:AuthController.Login("Login"/"LoginFailed"),AuthController.Logout("Logout"),AdminSessionsController.Revoke/RevokeAll("SessionRevoked"/"AllSessionsRevoked"). Neue Aktionen folgen demselben Muster: eine Zeileawait _dispatcher.DispatchAsync(new AuditEvent(actorUserId, actorUsername, ipAddress, "MeineAktion", details))an der auslösenden Stelle im Api-Layer. Bewusst kein Event beiAuthController.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
POST /api/auth/login(AuthController) →AuthService.LoginAsyncprüft Username/Passwort-Hash, widerruft alle bisherigen aktiven Refresh-Tokens dieses Users und würfelt seinenSecurityStampneu (EndOtherSessionsAsync— ein User hat immer nur eine aktive Session; ältere Sessions werden per Killswitch sofort ungültig, siehe "Session-Killswitch" unten), dann erzeugtJwtTokenGeneratorein Access-Token (Claimssub/name/role) +RefreshTokenGeneratoreinen langlebigen Refresh-Token. Response (LoginResponse): nur nochaccessToken,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,Securenur außerhalb von Development, da der lokale Dev-Server standardmäßig nur überhttp://läuft), seitomsorgappals Browser-SPA (nicht mehr Electron) läuft und ein Browser-Frontend den Token sonst nie sicher clientseitig halten könnte (keinsafeStorage-Äquivalent im Browser). Das setzt CORS mitAllowCredentials()voraus (Program.cs,Cors:AllowedOrigins-Config), sonst schickt der Browser die Cookie nicht mit.- Client sendet Access-Token als
Authorization: Bearer <token>. Program.csvalidiert das Token gegenJwt:Issuer/Jwt:Audience/Jwt:Secretaus der Konfiguration.POST /api/auth/refresh(kein[Authorize], kein Body — der Refresh-Token kommt aus der Cookie, die selbst das Credential ist):AuthService.RefreshAsyncprüft den Refresh-Token per Hash-Lookup, rotiert ihn (alten Token widerrufen, neuen ausstellen, perReplacedByTokenIdverkettet, neue Cookie gesetzt) und liefert ein neues Token-Paar. Erlaubt langlebige Sessions ohne täglichen Passwort-Login (sieheomsorgapp/CLAUDE.md).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:
- bei jedem Access-Token-Ausstellen als Claim
"sst"mit eingebettet wird (JwtTokenGenerator), - bei jedem authentifizierten Request per
JwtBearerEvents.OnTokenValidated(Program.cs) gegen den aktuellenUser.SecurityStampin der DB geprüft wird (IUserRepository.GetByIdAsync, schlanker Lookup ohne Includes) — bei Mismatch oder fehlendem Claimcontext.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) alsSessionInfo(Id, Username, CreatedAt, ExpiresAt).RevokeSessionAsync(sessionId)— widerruft genau diesen Refresh-Token und würfelt denSecurityStampnur des zugehörigen Users neu (sofortiger Kick für genau diesen Nutzer).RevokeAllSessionsAsync()— widerruft alle aktiven Refresh-Tokens und würfeltSecurityStampaller 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 inEmail:PasswordResetAccount(Default"PasswordReset");EmailMessage.FromAccountKeytransportiert die Auswahl vom Aufrufer bis zumSmtpEmailSender. 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.ForgotPasswordRequestfängt eine fehlgeschlageneIDomainEventDispatcher.DispatchAsync(PasswordResetRequestedEvent)ab (loggt viaILogger<AuthController>) und antwortet mitForgotPasswordRequestResponse("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 inomsorgapp/src/modules/auth/ForgotPasswordUsernamePage.jsxundomsorgWeb/mitarbeiter-app/pages/forgot-password.php.AdminEmailController.TestSendfängt den Fehler ebenfalls (loggt viaILogger<AdminEmailController>) und antwortet502mit{ error: "send_failed", message: <MailKit-Fehlertext> }statt eines rohen, unbehandelten500— die Exception-Message darf hier raus (nurUserManagement/Edit-Admins erreichen den Endpoint), sie enthält keine Zugangsdaten, nur Diagnoseinfos wie "Authentication failed"/"Connection refused".DebugSessionsPage.jsxzeigtresult.data.messagedirekt an.
Datenbank
- Connection-String-Key:
ConnectionStrings:OmsorgCore(FormatHost=...;Port=5432;Database=omsorg_core;Username=...;Password=...). dotnet-efist als lokales Tool eingerichtet (.config/dotnet-tools.json) — vor erster Nutzungdotnet tool restore. Wird nur noch zum Erzeugen neuer Migrationen gebraucht (dotnet ef migrations add ...), nicht mehr zum Anwenden.Program.csruft bei jedem Startdb.Database.MigrateAsync()auf, in jeder Umgebung (nicht nur Development) — ausstehende Migrationen werden automatisch angewendet, bevor der Server Requests annimmt. Ein manuellesdotnet ef database updateist 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,AddEmployeeContactFieldConstraintsundAddAuditableSoftDeleteexistieren (src/OmsorgCore.Infrastructure/Persistence/Migrations/) und wurden erfolgreich gegen eine echte PostgreSQL-Instanz angewendet.AddContractDetailsAndQueryFilterist erzeugt, aber noch nicht gegen eine echte Instanz verifiziert (wird beim nächsten API-Start automatisch angewendet).AddPermissionScope(fügtScopeaufrole_permissions/user_permission_overrideshinzu, siehe "Rechtesystem") wurde perdotnet ef database updateerfolgreich gegen die echte Instanz angewendet.AddFacilityConditionsAndQualificationRates(Konditionen-Felder aufFacility+ Tabellefacility_qualification_rates, FR-EIN-4),AddAbsences(Tabelleabsences, FR-CON-1/FR-EM-3) undAddTimeEntryStatusAndSurchargeHours(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+ Tabelleemployee_facility_distances, siehe "Konditionen einer Einrichtung") undRemoveFacilityBreakPolicy(löscht die SpalteBreakPolicyvonfacilitieswieder) sind erzeugt (dotnet build/dotnet testgrü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 buildfür alle 6 Projekte: grün.dotnet test: grün (u. a.AuthServiceTestsmit Fake-Repositories für Login/Refresh-Rotation/Revoke).dotnet tool run dotnet-ef database updateerfolgreich 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/refreshmit falschen/unbekannten Credentials → 401,POST /api/auth/logout→ 204.GET /api/employeesohne Token → 401 (Auth-Pipeline greift korrekt). omsorgappsauthClient.cjserfolgreich 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;refreshrotiert korrekt (neues Paar, alter Refresh-Token danach 401 bei Wiederverwendung);GET /api/employeesmit frischem Access-Token → 200 (Administrator-Rolle hat volle Rechte über den Seed). - Session-Killswitch end-to-end verifiziert:
GET /api/admin/sessionsliefert 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 beiPOST /api/auth/refreshebenfalls 401. Erneuter Login mitadmin/abersicherfunktioniert danach wieder normal. DbSeeder.SeedBaseRolesAsyncgegen echte PostgreSQL-Instanz verifiziert: legtGeschäftsführung/Disposition/Buchhaltung/Recruiting/Außendienstmit der erwarteten Rechteanzahl an (54/33/10/0 Permissions), zweiter Lauf verändert nichts (idempotent pro Rollenname). Hinweis: SeitAddPermissionScopebekommtAußendienstneuEmployees.View+Contracts.View(ScopeOwn), seit den Absences-/TimeEntries-/EmployeeFacilityDistances-Erweiterungen zusätzlichAbsences.{Create,View,Edit},TimeEntries.{Create,View,Edit}undEmployeeFacilityDistances.{Create,View,Edit}(alle ScopeOwn) — 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 leererRoles-Tabelle bzw. pro fehlendem Rollennamen, nicht erneut gegen eine bereits befüllte Instanz).
Konfigurierbare Auswahllisten
Dropdown-Werte, die früher als hartcodierte Arrays im omsorgapp-Frontend lebten (Mitarbeiterstatus, Beschäftigungsart, CRM-Status, Einrichtungstyp) plus die entsprechenden, bisher nur als freier String modellierten Felder auf Contract (Vertragstyp/-status) und der Auftragsstatus (FR-EM-2) sind jetzt eine gemeinsame, admin-editierbare Stammdaten-Struktur statt Enum/hartcodiertes Array — Ziel: Löschen/Umbenennen/Hinzufügen ohne Code-Deploy, über die "Status-Verwaltung" unter "Einstellungen" in omsorgapp.
Datenmodell (src/OmsorgCore.Domain/Entities/): ValueList (Stammdaten einer Liste — Key, eindeutig, z. B. "EmployeeStatus", "EmploymentType", "CrmStatus", "FacilityType", "ContractType", "ContractStatus", "OrderStatus", "DocumentCategory", "Qualification", "ShiftType", "Priority", "BillingInterval", "AbsenceType", "AbsenceStatus"; DisplayName für die Admin-UI) + ValueListItem (Value, SortOrder, IsDefault, IsInitial/IsTerminal — IsTerminal nur für "OrderStatus" relevant, IsInitial zusätzlich für "AbsenceStatus" (markiert dort den "noch nicht entschieden"-Zustand, siehe "Abwesenheits-/Urlaubs-/Krankmeldungsanträge" unten)) + ValueListItemTransition (erlaubte Übergänge zwischen zwei Items derselben Liste, wird nur für "OrderStatus" befüllt). Ersetzt das frühere OrderStatusDefinition/OrderStatusTransition-Sondermodell — Migration ReplaceOrderStatusWithValueLists übernimmt bestehende Auftragsstatus-Zeilen 1:1 mit identischen Ids in die neuen Tabellen, damit Order.StatusId unverändert gültig bleibt.
Wo welches Feld referenziert wird:
Order.StatusId(FK, echte Fremdschlüsselbeziehung aufValueListItem.Id) — einzige Liste mit Übergangsregeln.OrderService.CreateAsync/UpdateAsyncnutzenIValueListRepository.GetInitialItemAsync("OrderStatus", ...)/CanTransitionAsync(...)genau wie zuvorIOrderStatusRepository.Employee.Status/EmploymentType,Facility.CrmStatus/FacilityType,Contract.ContractType/Statusbleiben bewusst einfachestring-Spalten (kein FK, keine Schema-Migration auf diesen Tabellen nötig) — stattdessen prüfenEmployeesController/FacilitiesController/ContractsControllerbeim Schreiben serverseitig überIValueListRepository.GetActiveValuesAsync(key, ...), dass der übergebene Wert unter den aktuell konfigurierten Werten der zugehörigen Liste ist (400sonst) — analog zur bereits bestehenden Passwort-Policy-Validierung.Employee.Qualification,Order.RequiredQualificationundFacilityQualificationRate.Qualification(siehe "Konditionen einer Einrichtung" unten) referenzieren alle drei dieselbe Liste"Qualification"(ebenfalls einfachestring-Spalten, gegenGetActiveValuesAsync/GetItemsAsyncvalidiert 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).SortOrderbildet 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 eigeneIValueListUsageChecker-Implementierung (QualificationValueListUsageChecker, nicht der generischeStringFieldValueListUsageChecker) alle drei Tabellen —ValueListService.FindUsagesAsyncbefragt pro Key nur den ersten registrierten Checker, separate Registrierungen mit demselben Key hätten weitere Verwendungsstellen beim Löschschutz stillschweigend ignoriert.Order.ShiftTypereferenziert die Liste"ShiftType"(nur eine Entität betroffen, daher regulärerStringFieldValueListUsageCheckerwie 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/ordersunterstü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-tsmuss danach ebenfalls neu generiert werden (siehe "Generierte API-Clients" unten), sonst kennt der generierteOrdersApi.apiOrdersGetRawden Parameter nicht und die Filterung wirkt sich nicht aus, obwohl Backend und Frontend-Code beide "richtig" aussehen.Facility.BillingIntervalreferenziert die Liste"BillingInterval"(Wöchentlich/Monatlich/Quartalsweise, nur eine Entität betroffen, regulärerStringFieldValueListUsageChecker), Teil der Konditionen einer Einrichtung — siehe eigener Abschnitt "Konditionen einer Einrichtung" unten.Absence.Type/Statusreferenzieren"AbsenceType"(Urlaub/Krankmeldung/Sonstige) bzw."AbsenceStatus"(Eingereicht/Genehmigt/Abgelehnt) — beide einfachestring-Spalten wieContract.Status, ohneValueListItemTransition(kein Übergangsgraph nötig), aber mitIsInitialauf 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 wieTravelCostRateein 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 keineValueList, da die Rechnungserstellung später hart zwischen genau diesen zwei Fällen unterscheiden muss, ein dritter admin-hinzufügbarer Wert würde die künftigeFR-RE-1-Berechnungslogik lautlos brechen, analog zur Begründung beiAbsenceStatus.IsInitial) entscheidet, welches Feld gilt:TravelCostRate(Pauschale je Einsatz, EUR) oderTravelCostPerKm(EUR/km) ×EmployeeFacilityDistance.DistanceKm. Eine feste Facility-Distanz reicht nicht, weil jeder Mitarbeiter von einem anderen Wohnort anfährt — daherEmployeeFacilityDistance(Facility×Employee→DistanceKm,AuditableEntity) als zweite 1:n-Unterressource von Facility, exakt nach demFacilityQualificationRate-Muster:GET/POST/PUT/DELETE /api/facilities/{facilityId}/employee-distances[/...](EmployeeFacilityDistancesController), gegated über dieselbenFacilities-Rechte, kein eigenerModuleType.Createvalidiert zusätzlich, dassEmployeeIdauf einen existierenden Mitarbeiter zeigt (IEmployeeService.GetByIdAsync) und dass für das Facility/Employee-Paar noch keine (nicht gelöschte) Distanz existiert. Soft-Delete, überTrashController(api/trash/employee-facility-distances/...) wiederherstellbar. - Selbstbedienung durch den Außendienst (seit 2026-08-10,
MyFacilityDistancesController, Routeapi/me/facility-distances): Mitarbeiter sollen ihre eigene Fahrtstrecke selbst überomsorgWeb/mitarbeiter-apppflegen können, statt dass das Büro jede Kilometerangabe manuell einträgt. Dafür bewusst keinFacilities-Recht (das würde Konditionen/CRM-Daten offenlegen, die dem Außendienst laut Rechtematrix nicht zustehen), sondern ein neuer, eigenständigerModuleType.EmployeeFacilityDistances(siehe "Rechtesystem" oben) —GET /api/me/facility-distances(eigene Distanzen inkl.FacilityName),GET /api/me/facility-distances/facilities(minimale Einrichtungsauswahl, nurId/NameüberFacilityOptionResponse, für das Formular-Dropdown),POST/PUT(Anlegen/Bearbeiten).EmployeeIdkommt bei jeder Aktion ausschließlich ausICurrentUserService.EmployeeId(JWT-Claim), nie vom Client — bei fehlender Verknüpfung400statt eines FK-Fehlers, analog zumAbsenceService.CreateAsync-Fallback. Bewusst keinDELETEhier (Löschen bleibt Büro-Aufgabe über den Papierkorb). Nutzt intern denselbenIEmployeeFacilityDistanceService/dieselbe Tabelle wieEmployeeFacilityDistancesController— zwei Controller auf demselben Application-Service, unterschiedliche Zugriffsrechte, kein Datenmodell-Unterschied. - Zugleich behobener Bestandsfehler:
FacilityService.UpdateAsynckopierte die elf Konditionsfelder bislang gar nicht auf die getrackte Entität — der Controller validierte sie korrekt, aberPUT /api/facilities/{id}verwarf sie stillschweigend (kein Fehler, kein Log, das Feld blieb einfachnull/unverändert). BetrafBillingRate/alle vier Zuschläge/TravelCostRate/MinimumHours/BillingInterval/PaymentTermDays/IndividualAgreementsseit deren Einführung (damals inkl.BreakPolicy, seit 2026-08-10 entfernt, siehe oben). Jetzt behoben, Regressionstest:FacilityServiceTests.UpdateAsync_PersistsKonditionenFields. BillingIntervalwird wieFacilityType/ContractTypegegen die admin-editierbareValueList"BillingInterval"(Wöchentlich/Monatlich/Quartalsweise) validiert — siehe "Konfigurierbare Auswahllisten".- Alle zwölf Felder sind nur über
PUT /api/facilities/{id}(UpdateFacilityRequest) setzbar, nicht beim Anlegen (CreateFacilityRequest) — analog zuCrmStatus, 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 aufEmployee),Type(gegenValueList "AbsenceType": Urlaub/Krankmeldung/Sonstige),StartDate/EndDate(DateOnly,EndDate >= StartDate),Reason/Substitute/Note(alle optionaler Freitext),Status(gegenValueList "AbsenceStatus": Eingereicht/Genehmigt/Abgelehnt),AdminNote(optionaler Freitext der entscheidenden Büro-Rolle).Absence.Statusselbst hat keinen hartkodierten C#-Default (anders als z. B.Contract.Status = "Entwurf") — der initiale Wert wird inAbsenceService.CreateAsynczur Laufzeit ausGetInitialItemAsync("AbsenceStatus", ...)gelesen (DbSeedermarkiert "Eingereicht" dafür mitIsInitial = true, idempotent nachgezogen überSeedAbsenceStatusInitialFlagIfMissingAsyncauch 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 beiOrder.RequiredQualification(ValueList-Referenz statt Enum) undTriggersFollowUpbeiCrmStatusvermieden wird.- Kein
ValueListItemTransitionfürAbsenceStatus(anders alsOrderStatus) — wer wohin darf, ergibt sich vollständig aus Rechten: Außendienst hat nurCreate/View/Edit(kann also nur im initialen Status anlegen/bearbeiten, keinen bereits entschiedenen Antrag ändern), Büro-Rollen haben zusätzlichApprovefür den eigenen Entscheidungs-Endpoint (siehe unten) undDelete/Recover.IAbsenceService.GetInitialStatusValueAsync(...)ist der einzige Ort, der den initialen Statuswert auflöst —AbsenceService.UpdateAsyncundAbsencesController.Decidenutzen ihn beide, statt jeweils eigene String-Vergleiche zu pflegen. ModuleType.Absences(neuer, additiv angehängter Enum-Wert) ist das erste Modul außerEmployees/Contracts, dasPermissionScope.Owntatsächlich auswertet (Absence.EmployeeIdals Ownership-Anker, siehe "Datenebenen-Scope") —AbsenceServicefolgt exakt demEmployeeService/ContractService-Muster (ResolveOwnScopeRestrictionAsync), mit einer Ergänzung:CreateAsyncüberschreibt bei Own-Scope dieEmployeeIdimmer serverseitig ausICurrentUserService.EmployeeId(JWT-Claim) und ignoriert einen ggf. vom Client mitgeschickten Wert komplett — kein Client-Vertrauen darauf, wer die eigene Mitarbeiter-Id ist.CreateAbsenceRequesthat bewusst keinemployeeId-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üpfteEmployeeund wollen für sich selbst einen Antrag stellen können —CreateAsyncfällt für sie deshalb ebenfalls aufICurrentUserService.EmployeeIdzurück (nicht nur bei Own-Scope), und lehnt nur ab (400, ohne die Exception bis zuremployees-FK-Constraint durchzureichen wie beim ursprünglichen Vorfall), wenn wirklich kein Mitarbeiter mit dem aufrufendenUserverknüpft ist.POST /api/absencesfunktioniert 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(Routeapi/absences):GET/GET/{id}(View),POST(Create,CreateAbsenceRequestohneEmployeeId-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"), sonst400;AbsenceService.UpdateAsync/UpdateAbsenceResultprüft das serverseitig, nicht nur im UI. Own-Scope-Aufrufer dürfen dabei nur eigene Anträge bearbeiten, wie beiGetByIdAsync— ein fremder Own-Scope-Antrag liefert404, nicht403, um dessen Existenz nicht zu verraten),POST /{id}/decision(Approve,AbsenceDecisionRequest { status, adminNote },statusmuss ein Wert aus"AbsenceStatus"sein, der nicht der initiale ist — anders alsUpdategibt es hier bewusst keine Prüfung des aktuellen Status, eine Entscheidung ist jederzeit erneut änderbar (Korrektur einer versehentlichen Genehmigung/Ablehnung),AbsenceService.DecideAsyncüberschreibtStatus/AdminNoteunabhängig vom bisherigen Wert),DELETE(Delete, Soft-Delete, überTrashController(api/trash/absences/...) wiederherstellbar).PermissionAction.Editist 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/BuchhaltungbekommtAbsencesmit allen Aktionen/Scope.All,AußendienstbekommtAbsences.{Create,View}mitScope.Own(DbSeeder.SeedBaseRolesAsync). - Verwendung von
omsorgWeb/mitarbeiter-app: der Außendienst stellt Anträge überpages/urlaubsantrag.php(neue Seite in der Connect-Neuauflage, nicht in der Legacy-App) gegen genau diesen Endpoint — sieheomsorgWeb/CLAUDE.md.omsorgappbekommt die Prüfen/Genehmigen-Seite (AbsencesPage/AbsenceDetailPanel, Sidebar-Tab "Abwesenheiten") — sieheomsorgapp/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-1oben. Keine Migration/Portierung der alten Legacy-Anträge ausmitarbeiter-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 analogIsInitial/IsTerminal) — markiert, in welchen Status-Werten der Ersteller den Datensatz noch inhaltlich bearbeiten darf. Für"TimeEntryStatus":trueauf Entwurf/Eingereicht/Rückfrage, sonstfalse. Für alle anderen Listen bleibt esfalse(keine Verhaltensänderung).ValueListItemTransition.RequiresApproval(neues Flag, Defaulttrue) — unterscheidet Selbst-Einreichungs-Kanten (Entwurf→Eingereicht, Rückfrage→Eingereicht,false) von Büro-Entscheidungen (alle übrigen Kanten,true)."OrderStatus"-Transitionen bleiben beim Defaulttrue, 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), keinStatusIdim DTO.TimeEntryService.UpdateAsynclehnt ab (400,NotEditable), sobald der aktuelle StatusIsEditableByOwner == falseist.POST /api/time-entries/{id}/submit(Edit, kein Body) —TimeEntryService.SubmitAsyncsucht überIValueListRepository.GetSelfServiceTransitionAsync(statusId)die eine ausgehende Kante mitRequiresApproval == falseund wendet sie an;400falls 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.DecideAsyncvalidiert die Ziel-Transition überCanTransitionAsyncund dass sieRequiresApproval == trueist (verhindert, dass dieser Endpoint für die Selbst-Einreichungs-Kante missbraucht wird), keine Prüfung des bisherigen Status (wie beiAbsence.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
Facilityhat jetzt volles Repository/Service/Controller (FacilitiesController,GET/POST/PUT/DELETE /api/facilities) nach dem Employee-Muster, inkl.FacilityCreatedEvent. ZusätzlichFacilityContact(FR-EIN-2, Ansprechpartner),FacilityQualificationRate(FR-EIN-4, qualifikationsabhängige Preise) undEmployeeFacilityDistance(FR-EIN-4, kilometerbasierte Fahrtkosten) als 1:n-Unterressourcen unterGET/POST/PUT/DELETE /api/facilities/{facilityId}/contacts[/...],.../qualification-rates[/...]bzw..../employee-distances[/...](FacilityContactsController/FacilityQualificationRatesController/EmployeeFacilityDistancesController, siehe "Konditionen einer Einrichtung" oben) — alle drei bewusst kein eigenerModuleType, sondern über dieselbenFacilities-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.Deleteje Modul,DELETE-Endpoint pro Controller) und über denTrashController(api/trash/...,PermissionAction.Recoverje Modul, "Papierkorb"-Seite inomsorgapp) wiederherstellbar.Contracthat 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.ContractConfigurationhat jetzt (wieFacility) einenHasQueryFilter(!IsDeleted). FR-MA-2 ist damit inkl.omsorgapp-UI abgeschlossen: "Verträge"-Tab inEmployeeDetailPanel(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 (sieheomsorgapp/CLAUDE.md).Orderhat 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, gegenFacilityIdcross-validiert — der Kontakt muss zur angegebenen Einrichtung gehören, sonst400),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.OrderConfigurationhat jetzt (wieFacility/Contract) einenHasQueryFilter(!IsDeleted). Keinomsorgapp-UI-Modul für Aufträge selbst in diesem Schritt (nur die Statuspflege über "Status-Verwaltung").TimeEntryhat jetzt ebenfalls volles Repository/Service/Controller (FR-ZE-1/FR-ZE-2, siehe "Zeiterfassung" oben) —Invoicehat 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, sieheomsorgapp/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).
omsorgappspricht seit Kurzem gegen dieses Backend (Login-Screen + Refresh-Token-Session, sieheomsorgapp/CLAUDE.md) —omsorgWebist 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:
- Jede Entität hat eine eindeutige
Guid Id(sieheEntity-Basisklasse). - Beziehungen ausschließlich über Foreign Keys/IDs, keine redundante Texteingabe verwandter Daten.
- Stammdaten nur an einer Stelle — kein Feld, das auch in
omsorgWeboderomsorgappunabhängig gepflegt wird, sobald die Migration dorthin begonnen hat. - Änderungen an geschäftsrelevanten Daten müssen nachvollziehbar sein —
AuditableEntityliefertCreatedAt/UpdatedAt; ein vollständiger Audit-Trail (wer hat was geändert) läuft automatisch über denAuditSaveChangesInterceptor(siehe "Audit-Log" oben), keine Handarbeit pro Entität nötig. - 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 überPermissionAction.Delete, Wiederherstellung überTrashController/PermissionAction.Recover);Invoicehat noch kein CRUD, daher hier noch nicht relevant. - Berechtigungsprüfung auf Daten- und Funktionsebene (siehe Rechtesystem oben und Rechtematrix in
REQUIREMENTS.mdAbschnitt 7). - Rechnungen entstehen ausschließlich aus freigegebener Zeiterfassung (FR-ZE-3/FR-RE-1) — muss bei Ausbau von
TimeEntry/Invoiceauf 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.