Extends omsorgCore with full CRUD for Facility/Contract/Order plus configurable value lists and an audit trail, and wires the omsorgapp frontend up to the new facilities, settings, and audit-log modules; includes a sidebar active-nav-item highlight. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
38 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
Entities/ # Employee, Facility, Contract, Order, TimeEntry, Invoice,
# User, Role, RolePermission, UserPermissionOverride, AuditLogEntry
OmsorgCore.Application/ # Business-Logik. Abhängig von Domain.
Abstractions/ # Interfaces: IEmployeeRepository, IUserRepository,
# IPasswordHasher, IJwtTokenGenerator, ICurrentUserService,
# IPermissionService
Services/ # PermissionService, AuthService, EmployeeService, SessionAdminService
DependencyInjection.cs # AddApplication()
OmsorgCore.Infrastructure/ # Technische Umsetzung. Abhängig von Domain + Application.
Persistence/
OmsorgCoreDbContext.cs
Configurations/ # ein IEntityTypeConfiguration<T> pro Entität
Migrations/ # EF-Core-Migrationen (InitialCreate bereits erzeugt)
Repositories/ # EmployeeRepository, FacilityRepository, FacilityContactRepository, UserRepository, RefreshTokenRepository, AuditLogRepository (implementieren Application-Interfaces)
Security/ # PasswordHasher, JwtOptions, JwtTokenGenerator, RefreshTokenOptions, RefreshTokenGenerator
DependencyInjection.cs # AddInfrastructure(configuration)
OmsorgCore.Engine/ # Event-Schicht. Abhängig von Domain + Application.
Events/ # IDomainEvent, IDomainEventHandler<T>, IDomainEventDispatcher,
# DomainEventDispatcher (In-Process, kein Message-Bus), Beispiel-Event
Handlers/ # Beispiel-Handler (EmployeeCreatedHandler), AuditEventHandler
DependencyInjection.cs # AddEngine()
OmsorgCore.Api/ # ASP.NET Core Web API. Abhängig von Application+Infrastructure+Engine.
Controllers/ # AuthController, EmployeesController, FacilitiesController, FacilityContactsController, HealthController, AdminSessionsController, AuditLogController
Contracts/ # Request-/Response-DTOs (LoginRequest, EmployeeResponse, ...)
Security/ # CurrentUserService, RequirePermissionAttribute
Program.cs # einziger Ort, an dem alle Schichten verdrahtet werden
tests/
OmsorgCore.Tests/ # xUnit, referenziert Domain + Application
Konvention: eine Klasse/ein Interface/ein Enum pro Datei. Ausnahme: keine — auch kleine DTOs (Requests/Responses als record) bekommen eine eigene Datei.
Abhängigkeitsrichtung ist strikt: Domain kennt niemanden. Application kennt nur Domain und definiert Interfaces, die Infrastructure implementiert (Ports-and-Adapters). Engine kennt Domain + Application, nicht Infrastructure oder Api. Api verdrahtet alles ausschließlich in Program.cs — Controller rufen nur Application-Services und den IDomainEventDispatcher (Engine) auf, niemals direkt OmsorgCoreDbContext/EF Core.
Rechtesystem
Rolle liefert Standard-Rechte (RolePermission: Modul × Aktion), ein individueller UserPermissionOverride (Grant/Revoke) gewinnt immer gegen den Rollen-Default — siehe PermissionService.HasPermissionAsync (src/OmsorgCore.Application/Services/PermissionService.cs). Deckt Blueprint 6.5 ("Rolle als Vorlage + individuelle Rechte") ab.
Die aufgelösten Rechte eines Users (nicht nur eine einzelne Prüfung) liefert PermissionService.GetGrantedPermissionsAsync als Liste von PermissionGrant(Module, Action). Exponiert über GET /api/auth/me (AuthController.Me, [Authorize]) als MeResponse { username, role, permissions: [{ module, action }, ...] } — der einzige Weg, wie granulare Rechte den Client erreichen (das JWT trägt nur den Rollennamen). omsorgapp ruft diesen Endpunkt nach Login/Refresh auf (siehe omsorgapp/CLAUDE.md, "Rechtesystem im Client") und trifft UI-Entscheidungen darüber statt über einen Rollennamen-Vergleich.
Rechteprüfung auf Controller-Actions:
[RequirePermission(ModuleType.Employees, PermissionAction.Create)]
Das Attribut (src/OmsorgCore.Api/Security/RequirePermissionAttribute.cs) prüft serverseitig über IPermissionService — nicht nur im Client (REQUIREMENTS.md NFR-9). Jeder Controller außer AuthController trägt zusätzlich [Authorize].
Rollen-Rechte-Matrix und User-Overrides verwalten (Admin-Flow): Eine neu angelegte Rolle (RoleService.CreateAsync) hat zunächst keine RolePermission-Einträge — die Rechte-Matrix wird separat gesetzt über GET /api/roles/{id} (Rolle inkl. ihrer aktuellen RolePermission-Liste, RoleService.GetByIdWithPermissionsAsync) und PUT /api/roles/{id}/permissions (RoleService.UpdatePermissionsAsync — ersetzt die komplette RolePermission-Menge der Rolle durch die übergebene Menge, kein inkrementelles Patchen). Individuelle UserPermissionOverride-Ausnahmen eines Users werden über GET/POST/DELETE /api/users/{id}/permission-overrides[...] verwaltet (UserService.GetPermissionOverridesAsync/AddPermissionOverrideAsync/RemovePermissionOverrideAsync — AddPermissionOverrideAsync ist ein Upsert: existiert bereits ein Override für dasselbe Modul+Aktion bei diesem User, wird dessen Effect aktualisiert statt dupliziert). Alle diese Endpoints liegen auf RolesController/UsersController, gegated über [RequirePermission(ModuleType.UserManagement, View|Edit)] wie der Rest der Nutzerverwaltung. Admin-UI dazu: omsorgapp/src/modules/settings/ (SettingsPage, RolesPanel, RolePermissionMatrix, UserOverridesPanel).
Wichtig: Wird ein neuer ModuleType oder PermissionAction-Wert hinzugefügt, oder ändert sich sonst das Rollen-/Rechtesystem, muss dieser Abschnitt (Rechtesystem) im selben Change aktualisiert werden — diese Dokumentation ist keine Momentaufnahme, sondern muss mit der Software mitwachsen.
Audit-Log ("wer hat wann was verändert")
Zwei sich ergänzende Erfassungswege, beide münden in dieselbe Tabelle AuditLogEntry (src/OmsorgCore.Domain/Entities/AuditLogEntry.cs, Category unterscheidet die Herkunft):
- 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):accessToken,refreshToken,expiresAt(camelCase, Default-JSON-Serialisierung von ASP.NET Core).- 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]— der Refresh-Token selbst ist das Credential):AuthService.RefreshAsyncprüft den Refresh-Token per Hash-Lookup, rotiert ihn (alten Token widerrufen, neuen ausstellen, perReplacedByTokenIdverkettet) und liefert ein neues Token-Paar. Erlaubt langlebige Sessions ohne täglichen Passwort-Login (sieheomsorgapp/CLAUDE.md).POST /api/auth/logoutwiderruft den vorgelegten Refresh-Token (AuthService.RevokeAsync, idempotent).
Refresh-Token: kein Rohtoken wird gespeichert, nur sein SHA-256-Hash (RefreshToken-Entity, IRefreshTokenGenerator). Gültigkeit über RefreshToken:ExpiryDays in appsettings.json (Default 60 Tage, sliding — jede Nutzung verlängert effektiv die Session), überschreibbar per RefreshToken__ExpiryDays.
Passwort-Mindestlänge: zentral über IPasswordPolicy/PasswordPolicy (src/OmsorgCore.Infrastructure/Security/PasswordPolicy.cs), gebunden an PasswordPolicy:MinLength in appsettings.json (Default 8, überschreibbar per PasswordPolicy__MinLength, siehe CONFIGURATION.md). Wird von UserService (Account-Anlage im Direct-Modus, Admin-Reset, ChangeOwnPasswordAsync) und PasswordResetService.ResetPasswordAsync injiziert geprüft — nicht mehr in den Controllern dupliziert. Bei Verstoß liefern die jeweiligen *Result-Typen einen PasswordTooShort-Wert, den UsersController/AuthController auf 400 BadRequest mit der aktuell konfigurierten Zahl im Fehlertext abbilden. GET /api/auth/password-policy (kein [Authorize]) liefert { minLength } für Clients, die denselben Wert für Hinweistexte/Vorab-Validierung brauchen (omsorgapp: AuthContext.jsx; omsorgWeb/mitarbeiter-app: omsorgcore_password_policy() in lib/omsorgCoreClient.php).
Secret-Handling: appsettings.json enthält nur Issuer/Audience/ExpiryMinutes/RefreshToken:ExpiryDays. appsettings.Development.json enthält einen lokalen Platzhalter für Jwt:Secret und den Connection-String (CHANGE_ME_...) — für echte Umgebungen über Umgebungsvariable (Jwt__Secret) oder dotnet user-secrets überschreiben, nie ein echtes Secret einchecken.
Basis-Rollen-Seed (alle Umgebungen): DbSeeder.SeedBaseRolesAsync (src/OmsorgCore.Infrastructure/Persistence/DbSeeder.cs) legt bei jedem Start die vier in REQUIREMENTS.md Abschnitt 3 ("Akteure & Rollen") und Abschnitt 7 ("Rechtematrix") beschriebenen Basis-Rollen an — Geschäftsführung (Sabina/Malik, voller Zugriff auf alle Module), Disposition/Buchhaltung (Sabrina), Recruiting (Sascha), Außendienst (ohne Modul-Rechte, da OMSORG Connect noch nicht gegen dieses Backend spricht und "nur eigene Daten" ohnehin Datenebene statt Modul-Recht ist). Läuft in Program.cs direkt nach den Migrationen, nicht auf IsDevelopment() beschränkt (im Gegensatz zum Admin-Seed unten) — enthält keine Zugangsdaten, nur Rollen-Stammdaten. Idempotent pro Rollenname: existiert eine Rolle schon (z. B. weil sie über die Rechte-Matrix-UI unter "Einstellungen" angepasst wurde), fasst der Seed sie nicht an.
Wichtig: Die Zuordnung in SeedBaseRolesAsync ist eine Übersetzung der Rechtematrix aus REQUIREMENTS.md auf die aktuellen ModuleType/PermissionAction-Werte. Kommt ein neuer ModuleType/eine neue PermissionAction dazu, oder ändert sich die Rechtematrix in REQUIREMENTS.md, muss dieser Seed im selben Change mit aktualisiert werden — er ist keine Momentaufnahme, sondern muss mit der Software mitwachsen (siehe auch den allgemeinen Pflegehinweis am Ende dieses Abschnitts).
Standard-Admin-Seed: DbSeeder.SeedDefaultAdminAsync (src/OmsorgCore.Infrastructure/Persistence/DbSeeder.cs) legt beim Start nur im Development-Modus (Program.cs, app.Environment.IsDevelopment()) einen Benutzer admin/abersicher mit einer neuen Rolle Administrator (alle ModuleType×PermissionAction-Kombinationen als RolePermission) an — idempotent, läuft nur wenn noch kein User existiert. DB-Fehler dabei (z. B. keine Verbindung — Migrationen sind zu diesem Zeitpunkt bereits automatisch angewendet, siehe "Datenbank" unten) sind nicht fatal, werden nur geloggt (try/catch um den Seed-Aufruf). Bewusst nicht in Produktion aktiv, um kein bekanntes Standard-Passwort auszuliefern — für einen echten Produktivbetrieb muss ein richtiger User-Anlage-Flow her.
Session-Killswitch (SecurityStamp)
Ein Access-Token ist als JWT zustandslos gültig bis zum Ablauf (60 Min) — ein reiner Refresh-Token-Widerruf verhindert nur das stille Verlängern, der bereits ausgestellte Access-Token bliebe sonst bis zu 60 Minuten gültig. Für einen echten Not-Aus-Schalter (Debug-Sicht in omsorgapp, "alle Nutzer sofort zum Neu-Login zwingen") trägt User.SecurityStamp (Guid) einen Wert, der:
- 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.
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).
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).
Konfigurierbare Auswahllisten
Dropdown-Werte, die früher als hartcodierte Arrays im omsorgapp-Frontend lebten (Mitarbeiterstatus, Beschäftigungsart, CRM-Status, Einrichtungstyp) plus die entsprechenden, bisher nur als freier String modellierten Felder auf Contract (Vertragstyp/-status) und der Auftragsstatus (FR-EM-2) sind jetzt eine gemeinsame, admin-editierbare Stammdaten-Struktur statt Enum/hartcodiertes Array — Ziel: Löschen/Umbenennen/Hinzufügen ohne Code-Deploy, über die "Status-Verwaltung" unter "Einstellungen" in omsorgapp.
Datenmodell (src/OmsorgCore.Domain/Entities/): ValueList (Stammdaten einer Liste — Key, eindeutig, z. B. "EmployeeStatus", "EmploymentType", "CrmStatus", "FacilityType", "ContractType", "ContractStatus", "OrderStatus"; DisplayName für die Admin-UI) + ValueListItem (Value, SortOrder, IsDefault, IsInitial/IsTerminal — die letzten beiden nur für "OrderStatus" relevant) + ValueListItemTransition (erlaubte Übergänge zwischen zwei Items derselben Liste, wird nur für "OrderStatus" befüllt). Ersetzt das frühere OrderStatusDefinition/OrderStatusTransition-Sondermodell — Migration ReplaceOrderStatusWithValueLists übernimmt bestehende Auftragsstatus-Zeilen 1:1 mit identischen Ids in die neuen Tabellen, damit Order.StatusId unverändert gültig bleibt.
Wo welches Feld referenziert wird:
Order.StatusId(FK, echte Fremdschlüsselbeziehung 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.
Verwaltungs-API (ValueListsController, Route api/value-lists): GET /api/value-lists (alle Listen), GET /api/value-lists/{key}/items (nur [Authorize], kein Modul-Recht — die aufrufenden Formulare gehören zu unterschiedlichen Modulen), POST/PUT/DELETE .../items[/...] sowie GET/PUT .../transitions (nur für "OrderStatus") gegated über [RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)] — dieselbe Admin-Berechtigung wie die übrige "Einstellungen"-Seite.
Löschschutz ("erst überall entfernen"): ValueListService.DeleteItemAsync löscht ein ValueListItem nur, wenn keine Verwendung mehr existiert. Eine IValueListUsageChecker-Implementierung je Liste (Infrastructure/Repositories/StringFieldValueListUsageChecker.cs — eine generische Klasse für alle String-Feld-Listen, mehrfach mit unterschiedlicher Query registriert in Infrastructure/DependencyInjection.cs; OrderStatusValueListUsageChecker.cs für die FK-basierte "OrderStatus"-Liste inkl. Übergangsregeln) prüft, ob der Wert noch irgendwo gesetzt ist. Bei Treffern liefert DELETE .../items/{id} 409 mit den Fundstellen (EntityType/EntityId/DisplayLabel) im Body, statt zu löschen. GET .../items/{id}/usages liefert dieselbe Prüfung jederzeit (nicht nur beim Löschversuch) — für den "wo wird das noch verwendet"-Info-Button in der UI.
Seed: DbSeeder.SeedValueListsAsync (jede Umgebung, idempotent — läuft nur, solange ValueLists leer ist) legt alle sieben Listen mit Startwerten an, inkl. der Auftragsstatus-Pipeline (Anfrage → Prüfung → offen → teilweise besetzt → vollständig besetzt → aktiv → abgeschlossen, plus Storno aus jedem nicht-terminalen Status) samt Übergangsregeln. Wichtig: Kommt eine neue admin-editierbare Auswahlliste hinzu, gehört sie hier als weiterer SeedSimpleListAsync-Aufruf rein plus eine IValueListUsageChecker-Registrierung in Infrastructure/DependencyInjection.cs — dieser Abschnitt und der Seed müssen mit der Software mitwachsen.
Frontend (omsorgapp): electron/backend/valueListsClient.cjs kapselt /api/value-lists, src/app/useValueListItems.js (Hook) lädt die Items einer Liste für Dropdowns (ersetzt die früheren hartcodierten Arrays in EmployeeForm.jsx/FacilityForm.jsx/EmployeesPage.jsx/FacilitiesPage.jsx). Verwaltungs-UI: src/modules/settings/StatusManagementPanel.jsx, dritter Tab ("Status-Verwaltung") in SettingsPage.jsx, gegated wie Rollen/Benutzerrechte über hasPermission("UserManagement", ...).
Offene Punkte
Facilityhat jetzt volles Repository/Service/Controller (FacilitiesController,GET/POST/PUT /api/facilities) nach dem Employee-Muster, inkl.FacilityCreatedEvent. ZusätzlichFacilityContact(FR-EIN-2, Ansprechpartner) als 1:n-Unterressource unterGET/POST /api/facilities/{facilityId}/contacts,PUT .../contacts/{id}(FacilityContactsController) — bewusst kein eigenerModuleType, sondern über dieselbenFacilities-Rechte gegated, da Ansprechpartner kein eigenständiges Core-Objekt sind. Kein Delete-Endpoint für Ansprechpartner (konsistent mit dem noch fehlenden Soft-Delete für die übrigen Core-Objekte).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). Keinomsorgapp-UI-Modul dafür in diesem Schritt — nur das Backend-CRUD.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").TimeEntry/Invoicehaben weiterhin nur Domain-Entitäten + DB-Konfiguration — nächste Schritte folgen demselben Muster (Repository-Interface in Application, Implementierung in Infrastructure, Service in Application, Controller in Api).- Keine E-Mail-Verifizierung bei User-Anlage (Passwort-Reset per E-Mail ist fertig, siehe "Passwort-Reset / E-Mail-Versand" oben).
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 — omsorgapp/api-client-ts ist kein optionales Extra mehr, sondern im echten Datenpfad: Alle Wrapper unter omsorgapp/electron/backend/*Client.cjs (employeesClient.cjs, facilitiesClient.cjs, facilityContactsClient.cjs, usersClient.cjs, rolesClient.cjs, valueListsClient.cjs, auditLogClient.cjs, authClient.cjs, ...) importieren die jeweilige *Api-Klasse aus dem generierten Paket omsorgcore-client-ts (require('omsorgcore-client-ts')) und reichen Requests/Responses ungeprüft typisiert durch. Nur omsorgWeb/mitarbeiter-app/lib/omsorgCoreClient.php bleibt tatsächlich unabhängig vom generierten PHP-Client.
Verbindliche Regel: nach jeder Änderung an einem Controller oder DTO in omsorgCore.Api/Contracts muss omsorgapp/api-client-ts neu generiert und neu gebaut werden — noch in demselben Change, nicht als Nachgang. Wird das vergessen, gibt es keinen Fehler, keine Exception, keine Warnung — der generierte Client kennt das neue/geänderte Feld schlicht nicht und lässt es beim Serialisieren/Deserialisieren stillschweigend weg. Das Symptom in der UI: Speichern/Anlegen meldet Erfolg, aber das betroffene Feld kommt nie im Backend an bzw. taucht nie in der Antwort auf — schwer zu debuggen, weil weder Backend noch Frontend-Code einen sichtbaren Fehler werfen (siehe FR-EIN-1/Website-Vorfall, 2026-08-08).
# omsorgCore muss dafür lokal im Development-Modus laufen (Swagger nur dort aktiv):
cd omsorgCore && ASPNETCORE_ENVIRONMENT=Development dotnet run --project src/OmsorgCore.Api
# in einem zweiten Terminal:
cd omsorgapp/api-client-ts
npm run generate # entspricht ./generate.sh — überschreibt src/, README.md, package.json, tsconfig*.json
npm run build # erzeugt dist/, das die *Client.cjs-Wrapper tatsächlich importieren
npm run generate überschreibt package.json komplett (Standard-Output von openapi-generator) — das dort eingetragene generate-Script muss danach jedes Mal erneut ergänzt werden (git diff package.json prüfen), sonst verschwindet es beim nächsten Lauf wieder. Details/Voraussetzungen: omsorgapp/api-client-ts/ANLEITUNG.md (das jeweilige README.md wird vom Generator automatisch überschrieben, ANLEITUNG.md bleibt stabil). omsorgWeb/mitarbeiter-app/api-client-php ist aktuell nicht im echten Datenpfad (siehe oben), sollte aber aus Konsistenzgründen bei Gelegenheit ebenfalls regeneriert werden.
Die sechs Core-Objekte (Domain-Entitäten)
Aus REQUIREMENTS.md Abschnitt 6 / Blueprint Kap. 19: Mitarbeiter, Einrichtung, Vertrag, Auftrag, Zeiterfassung, Rechnung — als Employee, Facility, Contract, Order, TimeEntry, Invoice in src/OmsorgCore.Domain/Entities/ angelegt, mit Kernfeldern (nicht vollständig ausmodelliert).
Verbindliche Regeln für das Datenmodell:
- 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 (noch nicht implementiert, bei Bedarf einbauen statt Datensätze zu löschen).
- 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.