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

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

38 KiB
Raw Blame History

CLAUDE.md — omsorgCore (OMSORG Backend: Core + Engine)

Gilt zusätzlich zur Root-CLAUDE.md. Das Grundgerüst ist angelegt und baut (dotnet build läuft grün); der fachliche Umfang ist bewusst noch minimal (Fundament, nicht Vollständigkeit).

Auftrag dieses Projekts

omsorgCore ist die gemeinsame Datenbasis + Automatisierungsschicht für die gesamte Plattform (siehe REQUIREMENTS.md Abschnitt 2 und 6, Blueprint Kap. 19/20). Es soll schrittweise ersetzen:

  • die MySQL-Datenhaltung in omsorgWeb/mitarbeiter-app
  • die lokale JSON-Datenhaltung in omsorgapp

Beide bestehenden Projekte sollen künftig gegen dieses Backend sprechen statt eigene, getrennte Datenspeicher zu pflegen. Diese Anbindung ist noch nicht erfolgt — aktueller Schritt ist nur das Backend-Fundament selbst.

Tech-Stack (umgesetzt)

  • C# / .NET 8 (LTS)
  • ASP.NET Core Web API mit Controllern (kein Minimal-API-Stil)
  • PostgreSQL über Npgsql.EntityFrameworkCore.PostgreSQL 8.0.10 (bewusst auf net8-kompatible Version gepinnt — neuere Paketversionen zielen auf .NET 10)
  • JWT Bearer Tokens für Auth (Microsoft.AspNetCore.Authentication.JwtBearer)
  • Passwort-Hashing über Microsoft.AspNetCore.Identity.PasswordHasher<T> (PBKDF2, kein eigenes Krypto-Rad)
  • Core + Engine = eine Backend-Komponente, kein separates Deployable für die Engine — die Event-Schicht läuft im selben Prozess wie die Datenschicht (siehe Root-CLAUDE.md und REQUIREMENTS.md Abschnitt 1.2)

Reale Projektstruktur

omsorgCore/
  OmsorgCore.sln
  .config/dotnet-tools.json     # lokales dotnet-ef Tool (dotnet tool restore)
  src/
    OmsorgCore.Domain/          # Entitäten, Enums. Keine Abhängigkeit auf andere Projekte.
      Common/                   # Entity, AuditableEntity, AuditRedactedAttribute (Basisklassen)
      Enums/                    # ModuleType, PermissionAction, PermissionEffect, AuditEventCategory
      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/RemovePermissionOverrideAsyncAddPermissionOverrideAsync ist ein Upsert: existiert bereits ein Override für dasselbe Modul+Aktion bei diesem User, wird dessen Effect aktualisiert statt dupliziert). Alle diese Endpoints liegen auf RolesController/UsersController, gegated über [RequirePermission(ModuleType.UserManagement, View|Edit)] wie der Rest der Nutzerverwaltung. Admin-UI dazu: omsorgapp/src/modules/settings/ (SettingsPage, RolesPanel, RolePermissionMatrix, UserOverridesPanel).

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

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

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

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

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

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

Auth-Flow

  1. POST /api/auth/login (AuthController) → AuthService.LoginAsync prüft Username/Passwort-Hash, widerruft alle bisherigen aktiven Refresh-Tokens dieses Users und würfelt seinen SecurityStamp neu (EndOtherSessionsAsync — ein User hat immer nur eine aktive Session; ältere Sessions werden per Killswitch sofort ungültig, siehe "Session-Killswitch" unten), dann erzeugt JwtTokenGenerator ein Access-Token (Claims sub/name/role) + RefreshTokenGenerator einen langlebigen Refresh-Token. Response (LoginResponse): accessToken, refreshToken, expiresAt (camelCase, Default-JSON-Serialisierung von ASP.NET Core).
  2. Client sendet Access-Token als Authorization: Bearer <token>.
  3. Program.cs validiert das Token gegen Jwt:Issuer/Jwt:Audience/Jwt:Secret aus der Konfiguration.
  4. POST /api/auth/refresh (kein [Authorize] — der Refresh-Token selbst ist das Credential): AuthService.RefreshAsync prüft den Refresh-Token per Hash-Lookup, rotiert ihn (alten Token widerrufen, neuen ausstellen, per ReplacedByTokenId verkettet) und liefert ein neues Token-Paar. Erlaubt langlebige Sessions ohne täglichen Passwort-Login (siehe omsorgapp/CLAUDE.md).
  5. POST /api/auth/logout widerruft den vorgelegten Refresh-Token (AuthService.RevokeAsync, idempotent).

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

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

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

Basis-Rollen-Seed (alle Umgebungen): DbSeeder.SeedBaseRolesAsync (src/OmsorgCore.Infrastructure/Persistence/DbSeeder.cs) legt bei jedem Start die vier in REQUIREMENTS.md Abschnitt 3 ("Akteure & Rollen") und Abschnitt 7 ("Rechtematrix") beschriebenen Basis-Rollen an — Geschäftsführung (Sabina/Malik, voller Zugriff auf alle Module), Disposition/Buchhaltung (Sabrina), Recruiting (Sascha), Außendienst (ohne Modul-Rechte, da OMSORG Connect noch nicht gegen dieses Backend spricht und "nur eigene Daten" ohnehin Datenebene statt Modul-Recht ist). Läuft in Program.cs direkt nach den Migrationen, nicht auf IsDevelopment() beschränkt (im Gegensatz zum Admin-Seed unten) — enthält keine Zugangsdaten, nur Rollen-Stammdaten. Idempotent pro Rollenname: existiert eine Rolle schon (z. B. weil sie über die Rechte-Matrix-UI unter "Einstellungen" angepasst wurde), fasst der Seed sie nicht an.

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

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

Session-Killswitch (SecurityStamp)

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

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

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

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

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

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

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

Passwort-Reset / E-Mail-Versand

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

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

  • "Console" (Default) — schreibt nur ins Log, kein echter Versand. Sicherer Default für Umgebungen ohne SMTP-Konfiguration.
  • "Smtp" — echter Versand über MailKit (SmtpEmailSender). Ein gemeinsamer SMTP-Server (Email:Smtp:*), darüber mehrere benannte Accounts (Email:Accounts:<Name>:*) — z. B. ein Account, der exklusiv für Passwort-Reset-Mails verwendet wird, getrennt von einem künftigen allgemeinen Absender. Welcher Account für den Reset-Flow gilt, steht in Email:PasswordResetAccount (Default "PasswordReset"); EmailMessage.FromAccountKey transportiert die Auswahl vom Aufrufer bis zum SmtpEmailSender. Details der Keys: CONFIGURATION.md. Zugangsdaten (Host/Username/Passwort) kommen ausschließlich aus appsettings/Env-Var/user-secrets, nie über eine API — es gibt bewusst keinen Settings-Controller oder UI-Formular dafür.

Templating: Betreff/Text der Passwort-Reset-Mail sind über Email:PasswordResetTemplate:{Subject,BodyTemplate} konfigurierbar, Platzhalter im Format $NAME$ ($RESET_PIN$, $RESET_PIN_EXPIRY_MINUTES$), ersetzt von EmailTemplateRenderer.Render (pure Funktion, unit-getestet in EmailTemplateRendererTests). Die Zusammensetzung passiert in PasswordResetEmailComposer (OmsorgCore.Email, implementiert IPasswordResetEmailComposer aus Application.Abstractions) — bewusst nicht direkt im Engine-Handler, weil Engine nur Domain+Application kennen darf, nicht Email (Abhängigkeitsrichtung, siehe oben). PasswordResetRequestedHandler (Engine/Handlers/) ruft nur IPasswordResetEmailComposer.Compose(...) + IEmailSender.SendAsync(...) auf.

Testversand ohne Zugangsdaten-Leak: AdminEmailController (POST /api/admin/email/test-send, [RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]) verschickt eine feste Testmail über den aktuell konfigurierten IEmailSender — Host/Username/Passwort verlassen dabei nie den Server, nur Erfolg/Fehlschlag geht an den Client. Im Frontend: omsorgapp/src/modules/debug/DebugSessionsPage.jsx, gleicher Rechteschutz wie die Sessions-Verwaltung dort.

Datenbank

  • Connection-String-Key: ConnectionStrings:OmsorgCore (Format Host=...;Port=5432;Database=omsorg_core;Username=...;Password=...).
  • dotnet-ef ist als lokales Tool eingerichtet (.config/dotnet-tools.json) — vor erster Nutzung dotnet tool restore. Wird nur noch zum Erzeugen neuer Migrationen gebraucht (dotnet ef migrations add ...), nicht mehr zum Anwenden.
  • Program.cs ruft bei jedem Start db.Database.MigrateAsync() auf, in jeder Umgebung (nicht nur Development) — ausstehende Migrationen werden automatisch angewendet, bevor der Server Requests annimmt. Ein manuelles dotnet ef database update ist dadurch nur noch zum gezielten Vorab-Prüfen/Debuggen einer Migration nötig, nicht mehr für den normalen Start/Deploy. Schlägt die Migration fehl, crasht der Start bewusst fatal (fail-fast) statt mit einem veralteten Schema weiterzulaufen.
  • Migrationen InitialCreate, AddRefreshTokens, AddUserSecurityStamp, AddEmployeeContactFieldConstraints und AddAuditableSoftDelete existieren (src/OmsorgCore.Infrastructure/Persistence/Migrations/) und wurden erfolgreich gegen eine echte PostgreSQL-Instanz angewendet. AddContractDetailsAndQueryFilter ist erzeugt, aber noch nicht gegen eine echte Instanz verifiziert (wird beim nächsten API-Start automatisch angewendet).

Build- und Run-Befehle

cd omsorgCore
dotnet build                                    # ganze Solution
dotnet tool restore                             # einmalig, für dotnet-ef

# Neue Migration erzeugen, nachdem sich das Entity-Modell geändert hat (wird NICHT automatisch angewendet):
dotnet tool run dotnet-ef migrations add <Name> \
  --project src/OmsorgCore.Infrastructure/OmsorgCore.Infrastructure.csproj \
  --startup-project src/OmsorgCore.Api/OmsorgCore.Api.csproj
# Angewendet wird sie automatisch beim nächsten Start der API (siehe oben) — kein manueller
# `database update`-Schritt im Normalfall mehr nötig.

# API starten (lädt appsettings.Development.json):
ASPNETCORE_ENVIRONMENT=Development dotnet run --project src/OmsorgCore.Api
# → GET /api/health, POST /api/auth/login, GET/POST /api/employees (Bearer-Token nötig)
# Swagger UI unter /swagger im Development-Modus

Verifiziert

  • dotnet build für alle 6 Projekte: grün. dotnet test: grün (u. a. AuthServiceTests mit Fake-Repositories für Login/Refresh-Rotation/Revoke).
  • dotnet tool run dotnet-ef database update erfolgreich gegen echte PostgreSQL-Instanz ausgeführt (InitialCreate + AddRefreshTokens).
  • API-Start mit ASPNETCORE_ENVIRONMENT=Development: GET /api/health{"status":"ok","databaseReachable":true}. POST /api/auth/login/refresh mit falschen/unbekannten Credentials → 401, POST /api/auth/logout → 204. GET /api/employees ohne Token → 401 (Auth-Pipeline greift korrekt).
  • omsorgapps authClient.cjs erfolgreich gegen den laufenden Server getestet (Login/Refresh/Logout-Fehlerfälle).
  • Kompletter Login-Flow end-to-end mit echtem Postgres verifiziert: Login mit admin/abersicher (Seed) → gültiges Token-Paar; refresh rotiert korrekt (neues Paar, alter Refresh-Token danach 401 bei Wiederverwendung); GET /api/employees mit frischem Access-Token → 200 (Administrator-Rolle hat volle Rechte über den Seed).
  • Session-Killswitch end-to-end verifiziert: GET /api/admin/sessions liefert aktive Sessions; POST /api/admin/sessions/revoke-all → 204, danach liefert derselbe, zuvor gültige Access-Token sofort 401 (nicht erst nach Ablauf) und der zugehörige Refresh-Token liefert bei POST /api/auth/refresh ebenfalls 401. Erneuter Login mit admin/abersicher funktioniert danach wieder normal.
  • DbSeeder.SeedBaseRolesAsync gegen echte PostgreSQL-Instanz verifiziert: legt Geschäftsführung/Disposition/Buchhaltung/Recruiting/Außendienst mit der erwarteten Rechteanzahl an (54/33/10/0 Permissions), zweiter Lauf verändert nichts (idempotent pro Rollenname).

Konfigurierbare Auswahllisten

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

Datenmodell (src/OmsorgCore.Domain/Entities/): ValueList (Stammdaten einer Liste — Key, eindeutig, z. B. "EmployeeStatus", "EmploymentType", "CrmStatus", "FacilityType", "ContractType", "ContractStatus", "OrderStatus"; DisplayName für die Admin-UI) + ValueListItem (Value, SortOrder, IsDefault, IsInitial/IsTerminal — die letzten beiden nur für "OrderStatus" relevant) + ValueListItemTransition (erlaubte Übergänge zwischen zwei Items derselben Liste, wird nur für "OrderStatus" befüllt). Ersetzt das frühere OrderStatusDefinition/OrderStatusTransition-Sondermodell — Migration ReplaceOrderStatusWithValueLists übernimmt bestehende Auftragsstatus-Zeilen 1:1 mit identischen Ids in die neuen Tabellen, damit Order.StatusId unverändert gültig bleibt.

Wo welches Feld referenziert wird:

  • Order.StatusId (FK, echte Fremdschlüsselbeziehung auf ValueListItem.Id) — einzige Liste mit Übergangsregeln. OrderService.CreateAsync/UpdateAsync nutzen IValueListRepository.GetInitialItemAsync("OrderStatus", ...)/CanTransitionAsync(...) genau wie zuvor IOrderStatusRepository.
  • Employee.Status/EmploymentType, Facility.CrmStatus/FacilityType, Contract.ContractType/Status bleiben bewusst einfache string-Spalten (kein FK, keine Schema-Migration auf diesen Tabellen nötig) — stattdessen prüfen EmployeesController/FacilitiesController/ContractsController beim Schreiben serverseitig über IValueListRepository.GetActiveValuesAsync(key, ...), dass der übergebene Wert unter den aktuell konfigurierten Werten der zugehörigen Liste ist (400 sonst) — analog zur bereits bestehenden Passwort-Policy-Validierung.

Verwaltungs-API (ValueListsController, Route api/value-lists): GET /api/value-lists (alle Listen), GET /api/value-lists/{key}/items (nur [Authorize], kein Modul-Recht — die aufrufenden Formulare gehören zu unterschiedlichen Modulen), POST/PUT/DELETE .../items[/...] sowie GET/PUT .../transitions (nur für "OrderStatus") gegated über [RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)] — dieselbe Admin-Berechtigung wie die übrige "Einstellungen"-Seite.

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

Seed: DbSeeder.SeedValueListsAsync (jede Umgebung, idempotent — läuft nur, solange ValueLists leer ist) legt alle sieben Listen mit Startwerten an, inkl. der Auftragsstatus-Pipeline (Anfrage → Prüfung → offen → teilweise besetzt → vollständig besetzt → aktiv → abgeschlossen, plus Storno aus jedem nicht-terminalen Status) samt Übergangsregeln. Wichtig: Kommt eine neue admin-editierbare Auswahlliste hinzu, gehört sie hier als weiterer SeedSimpleListAsync-Aufruf rein plus eine IValueListUsageChecker-Registrierung in Infrastructure/DependencyInjection.cs — dieser Abschnitt und der Seed müssen mit der Software mitwachsen.

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

Offene Punkte

  • Facility hat jetzt volles Repository/Service/Controller (FacilitiesController, GET/POST/PUT /api/facilities) nach dem Employee-Muster, inkl. FacilityCreatedEvent. Zusätzlich FacilityContact (FR-EIN-2, Ansprechpartner) als 1:n-Unterressource unter GET/POST /api/facilities/{facilityId}/contacts, PUT .../contacts/{id} (FacilityContactsController) — bewusst kein eigener ModuleType, sondern über dieselben Facilities-Rechte gegated, da Ansprechpartner kein eigenständiges Core-Objekt sind. Kein Delete-Endpoint für Ansprechpartner (konsistent mit dem noch fehlenden Soft-Delete für die übrigen Core-Objekte).
  • Contract hat jetzt ebenfalls volles Repository/Service/Controller (ContractsController, GET/POST/PUT /api/contracts, gegated über [RequirePermission(ModuleType.Contracts, ...)]) nach demselben Facility-Muster, inkl. ContractCreatedEvent. Deckt FR-MA-2 auf Backend-Seite ab: WeeklyHours (Arbeitszeit), HourlyWage (Stundenlohn), AllowancesDescription (Zuschläge, Freitext), OvertimeRules (Überstundenregelung, Freitext), VacationDaysPerYear (Urlaubsanspruch), ProbationPeriodMonths (Probezeit) — alle nullable, da ein Vertrag entweder einem Mitarbeiter oder einer Einrichtung zugeordnet ist (EmployeeId/FacilityId, mindestens eins muss gesetzt sein, per Controller-Validierung erzwungen) und nicht jeder Vertragstyp alle Felder braucht. ContractConfiguration hat jetzt (wie Facility) einen HasQueryFilter(!IsDeleted). Kein omsorgapp-UI-Modul dafür in diesem Schritt — nur das Backend-CRUD.
  • Order hat jetzt ebenfalls volles Repository/Service/Controller (OrdersController, GET/POST/PUT /api/orders, gegated über [RequirePermission(ModuleType.Orders, ...)]) nach demselben Facility/Contract-Muster, inkl. OrderCreatedEvent. Deckt FR-EM-1 auf Backend-Seite ab: FacilityContactId (optionaler Ansprechpartner, gegen FacilityId cross-validiert — der Kontakt muss zur angegebenen Einrichtung gehören, sonst 400), ShiftType (Schichtart, Freitext), RequiredHeadcount (Anzahl Mitarbeiter, mindestens 1), Conditions (Konditionen, Freitext), Priority (Priorität, Freitext). Der Auftragsstatus (FR-EM-2, Order.StatusId) ist Teil der generischen Auswahllisten — siehe "Konfigurierbare Auswahllisten" unten. OrderConfiguration hat jetzt (wie Facility/Contract) einen HasQueryFilter(!IsDeleted). Kein omsorgapp-UI-Modul für Aufträge selbst in diesem Schritt (nur die Statuspflege über "Status-Verwaltung"). TimeEntry/Invoice haben weiterhin nur Domain-Entitäten + DB-Konfiguration — nächste Schritte folgen demselben Muster (Repository-Interface in Application, Implementierung in Infrastructure, Service in Application, Controller in Api).
  • Keine E-Mail-Verifizierung bei User-Anlage (Passwort-Reset per E-Mail ist fertig, siehe "Passwort-Reset / E-Mail-Versand" oben).
  • omsorgapp spricht seit Kurzem gegen dieses Backend (Login-Screen + Refresh-Token-Session, siehe omsorgapp/CLAUDE.md) — omsorgWeb ist noch nicht angebunden.
  • Kein Docker-/CI-Setup.

Generierte API-Clients

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

Wichtig — omsorgapp/api-client-ts ist kein optionales Extra mehr, sondern im echten Datenpfad: Alle Wrapper unter omsorgapp/electron/backend/*Client.cjs (employeesClient.cjs, facilitiesClient.cjs, facilityContactsClient.cjs, usersClient.cjs, rolesClient.cjs, valueListsClient.cjs, auditLogClient.cjs, authClient.cjs, ...) importieren die jeweilige *Api-Klasse aus dem generierten Paket omsorgcore-client-ts (require('omsorgcore-client-ts')) und reichen Requests/Responses ungeprüft typisiert durch. Nur omsorgWeb/mitarbeiter-app/lib/omsorgCoreClient.php bleibt tatsächlich unabhängig vom generierten PHP-Client.

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

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

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

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

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

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

Verbindliche Regeln für das Datenmodell:

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

Event-Schicht (Engine) — Funktionsweise

IDomainEventDispatcher (Singleton, In-Process) löst über den DI-Container alle registrierten IDomainEventHandler<TEvent> für ein Event auf und ruft sie auf. Beispiel: EmployeesController.Create ruft nach dem Speichern _dispatcher.DispatchAsync(new EmployeeCreatedEvent(created.Id)) auf, EmployeeCreatedHandler reagiert darauf (aktuell nur Logging). Zweites Beispiel, generisch statt fachspezifisch: AuditEvent (siehe "Audit-Log" oben) wird von mehreren Stellen im Api-Layer für beliebige Verhaltens-Ereignisse gefeuert, AuditEventHandler persistiert sie einheitlich. Neue Trigger aus REQUIREMENTS.md Abschnitt 4.10 (Krankmeldung, fehlender Tätigkeitsnachweis, Vertragsende, ...) folgen demselben Muster: Event-Klasse in Engine/Events/, Handler in Engine/Handlers/, Registrierung in Engine/DependencyInjection.cs, Dispatch-Aufruf an der Stelle im Api-Layer, wo das auslösende Ereignis passiert.

Wichtig: Application-Services dispatchen bewusst nicht selbst (sie kennen Engine nicht, das würde die Abhängigkeitsrichtung verletzen) — das Dispatchen passiert im Api-Layer, der als einziger alle Schichten kennt.