Files
omsorg/omsorgCore/CLAUDE.md
T
Felix KemmlerandClaude Sonnet 5 b6c1389c55 Reorganize into monorepo layout, move mitarbeiter-app to legacy reference
Consolidates the previously separate omsorgapp and omsorgCore repos
(each had their own nested .git with GitHub history) plus the old
root-level website/mitarbeiter-app into a single monorepo, matching
the structure already documented in the root CLAUDE.md. Also moves
the PHP employee app aside as omsorgWeb/mitarbeiter-app-legacy/ to
serve as a template for a ground-up rewrite.

Fixes .gitignore in the same pass: the config-secrets/uploads/data
patterns were unanchored (relative to repo root, not depth-agnostic),
so they silently stopped matching once the app moved under omsorgWeb/.
Patterns are now **/-prefixed and cover both mitarbeiter-app and
mitarbeiter-app-legacy, keeping DB/SMTP credentials and uploaded
employee documents out of version control.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 14:21:37 +02:00

20 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 (Basisklassen)
      Enums/                    # ModuleType, PermissionAction, PermissionEffect
      Entities/                 # Employee, Facility, Contract, Order, TimeEntry, Invoice,
                                 # User, Role, RolePermission, UserPermissionOverride
    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, UserRepository, RefreshTokenRepository (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)
      DependencyInjection.cs    # AddEngine()
    OmsorgCore.Api/             # ASP.NET Core Web API. Abhängig von Application+Infrastructure+Engine.
      Controllers/               # AuthController, EmployeesController, HealthController, AdminSessionsController
      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].

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.

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.

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.

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.

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.

Offene Punkte

  • Facility/Contract/Order/TimeEntry/Invoice haben noch keine Controller/Repositories/Services — nur Domain-Entitäten + DB-Konfiguration. Nächste Schritte folgen demselben Muster wie Employee (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.

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) ist noch nicht gebaut.
  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). 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.