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>
20 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 (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
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.
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:
- 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.
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.
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.
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).
omsorgappspricht seit Kurzem gegen dieses Backend (Login-Screen + Refresh-Token-Session, sieheomsorgapp/CLAUDE.md) —omsorgWebist 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:
- 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) ist noch nicht gebaut. - 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). 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.