Migrate omsorgapp to browser SPA, add Docker/CI build setup
Docker-Images bauen und veröffentlichen / build (, omsorgCore/Dockerfile, omsorgcore) (push) Successful in 23s
Docker-Images bauen und veröffentlichen / build (VITE_OMSORG_CORE_URL=${{ vars.OMSORG_CORE_PUBLIC_URL }}, omsorgapp/Dockerfile, omsorgapp) (push) Failing after 2s
Docker-Images bauen und veröffentlichen / build (, omsorgWeb/Dockerfile, omsorgweb) (push) Failing after 39s
Docker-Images bauen und veröffentlichen / build (, omsorgCore/Dockerfile, omsorgcore) (push) Successful in 23s
Docker-Images bauen und veröffentlichen / build (VITE_OMSORG_CORE_URL=${{ vars.OMSORG_CORE_PUBLIC_URL }}, omsorgapp/Dockerfile, omsorgapp) (push) Failing after 2s
Docker-Images bauen und veröffentlichen / build (, omsorgWeb/Dockerfile, omsorgweb) (push) Failing after 39s
- omsorgapp: drop Electron, run as a plain Vite/React browser app; refresh token moves to an HttpOnly cookie (omsorgCore), CORS added for the new browser origin, document download/preview switched to Blob-based browser APIs. - Add Dockerfiles for omsorgCore, omsorgapp, and omsorgWeb, a docker-compose.yml wiring Postgres/MySQL/all three apps together, and a Gitea Actions workflow that builds and pushes images to the repo's container registry on push to main and on version tags. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e9e96a57dc
commit
598dfcd38a
+101
-23
@@ -28,8 +28,8 @@ omsorgCore/
|
||||
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,
|
||||
Enums/ # ModuleType, PermissionAction, PermissionEffect, AuditEventCategory, DocumentEntityType
|
||||
Entities/ # Employee, Facility, Contract, Order, TimeEntry, Invoice, Document,
|
||||
# User, Role, RolePermission, UserPermissionOverride, AuditLogEntry
|
||||
OmsorgCore.Application/ # Business-Logik. Abhängig von Domain.
|
||||
Abstractions/ # Interfaces: IEmployeeRepository, IUserRepository,
|
||||
@@ -42,7 +42,8 @@ omsorgCore/
|
||||
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)
|
||||
Repositories/ # EmployeeRepository, FacilityRepository, FacilityContactRepository, DocumentRepository, UserRepository, RefreshTokenRepository, AuditLogRepository (implementieren Application-Interfaces)
|
||||
Storage/ # StorageOptions, DocumentUploadPolicy, FileSystemDocumentStorage (Dokument-Bytes auf Disk, siehe "Dokumentenarchiv" unten)
|
||||
Security/ # PasswordHasher, JwtOptions, JwtTokenGenerator, RefreshTokenOptions, RefreshTokenGenerator
|
||||
DependencyInjection.cs # AddInfrastructure(configuration)
|
||||
OmsorgCore.Engine/ # Event-Schicht. Abhängig von Domain + Application.
|
||||
@@ -51,7 +52,7 @@ omsorgCore/
|
||||
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
|
||||
Controllers/ # AuthController, EmployeesController, FacilitiesController, FacilityContactsController, DocumentsController, HealthController, AdminSessionsController, AuditLogController
|
||||
Contracts/ # Request-/Response-DTOs (LoginRequest, EmployeeResponse, ...)
|
||||
Security/ # CurrentUserService, RequirePermissionAttribute
|
||||
Program.cs # einziger Ort, an dem alle Schichten verdrahtet werden
|
||||
@@ -67,7 +68,7 @@ omsorgCore/
|
||||
|
||||
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.
|
||||
Die aufgelösten Rechte eines Users (nicht nur eine einzelne Prüfung) liefert `PermissionService.GetGrantedPermissionsAsync` als Liste von `PermissionGrant(Module, Action, Scope)`. Exponiert über `GET /api/auth/me` (`AuthController.Me`, `[Authorize]`) als `MeResponse { username, role, permissions: [{ module, action, scope }, ...] }` — der einzige Weg, wie granulare Rechte den Client erreichen (das JWT trägt nur den Rollennamen). `omsorgapp` ruft diesen Endpunkt nach Login/Refresh auf (siehe `omsorgapp/CLAUDE.md`, "Rechtesystem im Client") und trifft UI-Entscheidungen darüber statt über einen Rollennamen-Vergleich.
|
||||
|
||||
Rechteprüfung auf Controller-Actions:
|
||||
```csharp
|
||||
@@ -75,7 +76,18 @@ Rechteprüfung auf Controller-Actions:
|
||||
```
|
||||
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`).
|
||||
**Drei getrennte Admin-Rechte statt einer Sammelkategorie (seit 2026-08-09):** `UserManagement` deckte ursprünglich die komplette "Einstellungen"-Seite ab (Benutzerkonten, Rollen-Rechte-Matrix, User-Overrides, Status-Verwaltung, Debug/Sessions) — wer irgendeinen dieser Bereiche brauchte, bekam automatisch Zugriff auf alle, inklusive der Möglichkeit, sich selbst beliebige Rechte zu geben. Jetzt drei fachlich getrennte `ModuleType`-Werte:
|
||||
- **`Users`** — Benutzerkonten sehen/anlegen/(de)aktivieren/Passwort zurücksetzen (`UsersController`: `GetAll`/`Create`/`ResetPassword`/`Update`, alle `[RequirePermission(ModuleType.Users, ...)]`). Admin-UI: `omsorgapp/src/modules/settings/UsersPanel.jsx` (Tab "Benutzer" in `SettingsPage`).
|
||||
- **`UserManagement`** (enger als zuvor) — nur noch die rechte-eskalierenden Aktionen: Rollen-Rechte-Matrix + individuelle User-Permission-Overrides + Session-Killswitch (`AdminSessionsController`) + Test-Mail (`AdminEmailController`). Bewusst weiterhin ein eigenes, sensibleres Recht, weil hierüber Rechte selbst verändert werden.
|
||||
- **`Configuration`** — Status-Verwaltung/Auswahllisten (`ValueListsController`, alle schreibenden Endpoints `[RequirePermission(ModuleType.Configuration, PermissionAction.Edit)]`), fachlich unabhängig von Nutzerverwaltung.
|
||||
|
||||
Die Sidebar-Sichtbarkeit von "Einstellungen" in `omsorgapp` ist deshalb kein einzelnes Modul mehr, sondern ein OR über alle drei (`navPermissions.js`, `SETTINGS_MODULES`, analog zum bestehenden `TRASH_MODULES`-Muster für den Papierkorb) — `SettingsPage.jsx` filtert die vier Tabs (Benutzer/Rollen/Benutzerrechte/Status-Verwaltung) zusätzlich einzeln nach ihrem jeweiligen Modul, ein Nutzer sieht also nur die Tabs, für die er tatsächlich `View` hat. **Wichtig für neue Rollen:** `GET /api/users` (Liste aller Benutzerkonten) prüft `Users`/`View` — wer individuelle `UserPermissionOverride`-Ausnahmen verwalten will (`UserManagement`/Edit), braucht zusätzlich `Users`/`View`, um überhaupt einen Nutzer zur Auswahl zu bekommen (`UserOverridesPanel.jsx`); ebenso nutzen `EmployeesPage.jsx` (Account-anlegen-Button in der Personalakte) und `AuditLogPage.jsx` (Akteur-Filter) `GET /api/users` und prüfen daher `Users`/View bzw. `Users`/Create, nicht mehr `UserManagement`.
|
||||
|
||||
Der Basis-Rollen-Seed (`DbSeeder.SeedBaseRolesAsync`, siehe unten) wurde bei diesem Split **nicht** angepasst — nur `Geschäftsführung` iteriert ohnehin generisch über `Enum.GetValues<ModuleType>()` und bekommt damit automatisch alle drei neuen Rechte, die anderen Basis-Rollen hatten vorher kein `UserManagement` und haben jetzt entsprechend auch keins der drei neuen Rechte. Eine Vergabe (z. B. `Configuration` an Disposition/Buchhaltung) ist eine bewusste, spätere Entscheidung über die Rollen-UI, kein Teil dieses technischen Splits.
|
||||
|
||||
**Rollen-Rechte-Matrix und User-Overrides verwalten (Admin-Flow):** Eine neu angelegte Rolle (`RoleService.CreateAsync`) hat zunächst keine `RolePermission`-Einträge — die Rechte-Matrix wird separat gesetzt über `GET /api/roles/{id}` (Rolle inkl. ihrer aktuellen `RolePermission`-Liste, `RoleService.GetByIdWithPermissionsAsync`) und `PUT /api/roles/{id}/permissions` (`RoleService.UpdatePermissionsAsync` — ersetzt die komplette `RolePermission`-Menge der Rolle durch die übergebene Menge, kein inkrementelles Patchen). Individuelle `UserPermissionOverride`-Ausnahmen eines Users werden über `GET/POST/DELETE /api/users/{id}/permission-overrides[...]` verwaltet (`UserService.GetPermissionOverridesAsync`/`AddPermissionOverrideAsync`/`RemovePermissionOverrideAsync` — `AddPermissionOverrideAsync` ist ein Upsert: existiert bereits ein Override für dasselbe Modul+Aktion bei diesem User, werden dessen `Effect` **und** `Scope` aktualisiert statt dupliziert). Diese Endpoints liegen auf `RolesController`/`UsersController` (die `.../permission-overrides`-Routen), gegated über `[RequirePermission(ModuleType.UserManagement, View|Edit)]`. Admin-UI dazu: `omsorgapp/src/modules/settings/` (`SettingsPage`, `RolesPanel`, `RolePermissionMatrix`, `UserOverridesPanel`).
|
||||
|
||||
**Datenebenen-Scope (`PermissionScope`, "nur eigene Daten"):** Dritte Dimension neben Modul×Aktion — jede `RolePermission`/`UserPermissionOverride`-Zeile trägt zusätzlich `Scope` (`All` oder `Own`, `src/OmsorgCore.Domain/Enums/PermissionScope.cs`). `PermissionService.GetScopeAsync(userId, module, action)` löst das auf und liefert `PermissionScope?` (`null` = gar nicht gewährt) — ein Override ersetzt dabei die komplette Zelle (Grant **und** Scope) der Rolle, es wird nicht gemergt, analog zur bestehenden Effect-Semantik. `HasPermissionAsync`/`RequirePermissionAttribute` bleiben bewusst scope-unabhängig (ein Own-User muss den Endpunkt-Gate trotzdem passieren) — die eigentliche Einschränkung passiert in den Application-Services: `EmployeeService`/`ContractService` konsultieren `GetScopeAsync` vor `GetPagedAsync`/`GetByIdAsync` und filtern bei `Own` auf `ICurrentUserService.EmployeeId` (neuer JWT-Claim `"employeeId"`, aus `User.EmployeeId`, nur eingebettet wenn gesetzt — wirkt daher erst mit dem nächsten Token-Refresh, wenn die Verknüpfung sich ändert). Own ohne verknüpfte `EmployeeId` liefert bewusst keine Datensätze (nicht "alle", nicht 500). **`Employees`, `Contracts`, `Absences` und `TimeEntries` werten den Scope aktuell aus** (alle vier haben einen Ownership-Anker: `User.EmployeeId`, `Contract.EmployeeId`, `Absence.EmployeeId`, `TimeEntry.EmployeeId` — siehe "Abwesenheits-/Urlaubs-/Krankmeldungsanträge" und "Zeiterfassung" unten) — alle anderen Module ignorieren `Scope` faktisch, weil ihnen kein Ownership-Anker zugrunde liegt (`Order` z. B. hat noch keinen Mitarbeiter-Bezug).
|
||||
|
||||
**Wichtig:** Wird ein neuer `ModuleType` oder `PermissionAction`-Wert hinzugefügt, oder ändert sich sonst das Rollen-/Rechtesystem, muss dieser Abschnitt (Rechtesystem) im selben Change aktualisiert werden — diese Dokumentation ist keine Momentaufnahme, sondern muss mit der Software mitwachsen.
|
||||
|
||||
@@ -92,11 +104,11 @@ Aktor-Informationen (`Username`/`RoleName`/`IpAddress`) kommen über `ICurrentUs
|
||||
|
||||
## 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).
|
||||
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`): nur noch `accessToken`, `expiresAt`, `mustChangePassword` (camelCase, Default-JSON-Serialisierung von ASP.NET Core) — der Refresh-Token selbst geht **nicht** im Body raus, sondern als **HttpOnly-Secure-Cookie** (`refreshToken`, `Path=/api/auth`, `AuthController.SetRefreshTokenCookie`, `Secure` nur außerhalb von Development, da der lokale Dev-Server standardmäßig nur über `http://` läuft), seit `omsorgapp` als Browser-SPA (nicht mehr Electron) läuft und ein Browser-Frontend den Token sonst nie sicher clientseitig halten könnte (kein `safeStorage`-Äquivalent im Browser). Das setzt CORS mit `AllowCredentials()` voraus (`Program.cs`, `Cors:AllowedOrigins`-Config), sonst schickt der Browser die Cookie nicht mit.
|
||||
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).
|
||||
4. `POST /api/auth/refresh` (kein `[Authorize]`, kein Body — der Refresh-Token kommt aus der Cookie, die selbst das Credential ist): `AuthService.RefreshAsync` prüft den Refresh-Token per Hash-Lookup, **rotiert** ihn (alten Token widerrufen, neuen ausstellen, per `ReplacedByTokenId` verkettet, neue Cookie gesetzt) und liefert ein neues Token-Paar. Erlaubt langlebige Sessions ohne täglichen Passwort-Login (siehe `omsorgapp/CLAUDE.md`).
|
||||
5. `POST /api/auth/logout` (kein Body) widerruft den Refresh-Token aus der Cookie (`AuthService.RevokeAsync`, idempotent) und löscht die Cookie (`Response.Cookies.Delete`).
|
||||
|
||||
**Refresh-Token:** kein Rohtoken wird gespeichert, nur sein SHA-256-Hash (`RefreshToken`-Entity, `IRefreshTokenGenerator`). Gültigkeit über `RefreshToken:ExpiryDays` in `appsettings.json` (Default 60 Tage, sliding — jede Nutzung verlängert effektiv die Session), überschreibbar per `RefreshToken__ExpiryDays`.
|
||||
|
||||
@@ -104,7 +116,7 @@ Aktor-Informationen (`Username`/`RoleName`/`IpAddress`) kommen über `ICurrentUs
|
||||
|
||||
**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.
|
||||
**Basis-Rollen-Seed (alle Umgebungen):** `DbSeeder.SeedBaseRolesAsync` (`src/OmsorgCore.Infrastructure/Persistence/DbSeeder.cs`) legt bei jedem Start die vier in `REQUIREMENTS.md` Abschnitt 3 ("Akteure & Rollen") und Abschnitt 7 ("Rechtematrix") beschriebenen Basis-Rollen an — `Geschäftsführung` (Sabina/Malik, voller Zugriff auf alle Module), `Disposition/Buchhaltung` (Sabrina, u. a. `Absences` mit allen Aktionen), `Recruiting` (Sascha), `Außendienst` (`Employees.View` + `Contracts.View` + `Absences.{Create,View}`, alle drei mit `PermissionScope.Own` — siehe "Datenebenen-Scope" oben; `Absences` ist dabei bereits an `omsorgWeb/mitarbeiter-app` angebunden, siehe "Abwesenheits-/Urlaubs-/Krankmeldungsanträge" unten, während `Employees`/`Contracts` dort noch nicht konsumiert werden). Läuft in Program.cs direkt nach den Migrationen, **nicht** auf `IsDevelopment()` beschränkt (im Gegensatz zum Admin-Seed unten) — enthält keine Zugangsdaten, nur Rollen-Stammdaten. Idempotent pro Rollenname: existiert eine Rolle schon (z. B. weil sie über die Rechte-Matrix-UI unter "Einstellungen" angepasst wurde), fasst der Seed sie nicht an.
|
||||
|
||||
**Wichtig:** Die Zuordnung in `SeedBaseRolesAsync` ist eine Übersetzung der Rechtematrix aus `REQUIREMENTS.md` auf die aktuellen `ModuleType`/`PermissionAction`-Werte. Kommt ein neuer `ModuleType`/eine neue `PermissionAction` dazu, oder ändert sich die Rechtematrix in `REQUIREMENTS.md`, muss dieser Seed im selben Change mit aktualisiert werden — er ist keine Momentaufnahme, sondern muss mit der Software mitwachsen (siehe auch den allgemeinen Pflegehinweis am Ende dieses Abschnitts).
|
||||
|
||||
@@ -139,12 +151,16 @@ Vollständig implementiert: `PasswordResetCode`-Entity + `PasswordResetService`
|
||||
|
||||
**Testversand ohne Zugangsdaten-Leak:** `AdminEmailController` (`POST /api/admin/email/test-send`, `[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]`) verschickt eine feste Testmail über den aktuell konfigurierten `IEmailSender` — Host/Username/Passwort verlassen dabei nie den Server, nur Erfolg/Fehlschlag geht an den Client. Im Frontend: `omsorgapp/src/modules/debug/DebugSessionsPage.jsx`, gleicher Rechteschutz wie die Sessions-Verwaltung dort.
|
||||
|
||||
**Fehlerbehandlung bei SMTP-Fehlschlag (seit 2026-08-10):** `SmtpEmailSender.SendAsync` selbst hat bewusst **kein** try/catch (MailKit-Exceptions sollen bis zum Aufrufer durchschlagen, nicht dort verschluckt werden) — die Behandlung passiert an den beiden Stellen, wo der Versand tatsächlich ausgelöst wird:
|
||||
- `AuthController.ForgotPasswordRequest` fängt eine fehlgeschlagene `IDomainEventDispatcher.DispatchAsync(PasswordResetRequestedEvent)` ab (loggt via `ILogger<AuthController>`) und antwortet mit `ForgotPasswordRequestResponse("email_unavailable")` statt `"sent"` — der Reset-Code selbst wurde da schon in der DB angelegt (`PasswordResetService.RequestResetAsync`), nur die Mail kam nicht raus. **Kein zusätzliches Enumeration-Risiko** ggü. dem Status quo: `"sent"` vs. `"cannot_reset"` unterscheidet bereits heute, ob der Username existiert — `"email_unavailable"` ist nur ein dritter, ebenso ehrlicher Zustand für "Username existiert, aber die Mail-Infrastruktur ist gerade kaputt". Beide Frontends müssen `"email_unavailable"` separat von `"sent"` behandeln (nicht zum PIN-Eingabe-Schritt weitergehen) — umgesetzt in `omsorgapp/src/modules/auth/ForgotPasswordUsernamePage.jsx` und `omsorgWeb/mitarbeiter-app/pages/forgot-password.php`.
|
||||
- `AdminEmailController.TestSend` fängt den Fehler ebenfalls (loggt via `ILogger<AdminEmailController>`) und antwortet `502` mit `{ error: "send_failed", message: <MailKit-Fehlertext> }` statt eines rohen, unbehandelten `500` — die Exception-Message darf hier raus (nur `UserManagement`/`Edit`-Admins erreichen den Endpoint), sie enthält keine Zugangsdaten, nur Diagnoseinfos wie "Authentication failed"/"Connection refused". `DebugSessionsPage.jsx` zeigt `result.data.message` direkt an.
|
||||
|
||||
## 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).
|
||||
- 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). `AddPermissionScope` (fügt `Scope` auf `role_permissions`/`user_permission_overrides` hinzu, siehe "Rechtesystem") wurde per `dotnet ef database update` erfolgreich gegen die echte Instanz angewendet. `AddFacilityConditionsAndQualificationRates` (Konditionen-Felder auf `Facility` + Tabelle `facility_qualification_rates`, FR-EIN-4), `AddAbsences` (Tabelle `absences`, FR-CON-1/FR-EM-3) und `AddTimeEntryStatusAndSurchargeHours` (`TimeEntry.StatusId`+Zuschlagsstunden, `ValueListItem.IsEditableByOwner`, `ValueListItemTransition.RequiresApproval`, FR-ZE-1/FR-ZE-2) wurden beim automatischen API-Start erfolgreich gegen die echte Instanz angewendet.
|
||||
|
||||
## Build- und Run-Befehle
|
||||
|
||||
@@ -174,31 +190,93 @@ ASPNETCORE_ENVIRONMENT=Development dotnet run --project src/OmsorgCore.Api
|
||||
- `omsorgapp`s `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).
|
||||
- `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). **Hinweis:** Seit `AddPermissionScope` bekommt `Außendienst` neu `Employees.View`+`Contracts.View` (Scope `Own`) — die Zahl "0" für Außendienst ist damit veraltet (jetzt 2 erwartet), aber noch nicht erneut per echtem Seed-Lauf verifiziert (der Seed läuft nur bei leerer `Roles`-Tabelle bzw. pro fehlendem Rollennamen, nicht erneut gegen eine bereits befüllte Instanz).
|
||||
|
||||
## Konfigurierbare Auswahllisten
|
||||
|
||||
Dropdown-Werte, die früher als hartcodierte Arrays im `omsorgapp`-Frontend lebten (Mitarbeiterstatus, Beschäftigungsart, CRM-Status, Einrichtungstyp) plus die entsprechenden, bisher nur als freier String modellierten Felder auf `Contract` (Vertragstyp/-status) und der Auftragsstatus (FR-EM-2) sind jetzt eine gemeinsame, admin-editierbare Stammdaten-Struktur statt Enum/hartcodiertes Array — Ziel: Löschen/Umbenennen/Hinzufügen ohne Code-Deploy, über die "Status-Verwaltung" unter "Einstellungen" in `omsorgapp`.
|
||||
|
||||
**Datenmodell** (`src/OmsorgCore.Domain/Entities/`): `ValueList` (Stammdaten einer Liste — `Key`, eindeutig, z. B. `"EmployeeStatus"`, `"EmploymentType"`, `"CrmStatus"`, `"FacilityType"`, `"ContractType"`, `"ContractStatus"`, `"OrderStatus"`; `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.
|
||||
**Datenmodell** (`src/OmsorgCore.Domain/Entities/`): `ValueList` (Stammdaten einer Liste — `Key`, eindeutig, z. B. `"EmployeeStatus"`, `"EmploymentType"`, `"CrmStatus"`, `"FacilityType"`, `"ContractType"`, `"ContractStatus"`, `"OrderStatus"`, `"DocumentCategory"`, `"Qualification"`, `"ShiftType"`, `"Priority"`, `"BillingInterval"`, `"AbsenceType"`, `"AbsenceStatus"`; `DisplayName` für die Admin-UI) + `ValueListItem` (`Value`, `SortOrder`, `IsDefault`, `IsInitial`/`IsTerminal` — `IsTerminal` nur für `"OrderStatus"` relevant, `IsInitial` zusätzlich für `"AbsenceStatus"` (markiert dort den "noch nicht entschieden"-Zustand, siehe "Abwesenheits-/Urlaubs-/Krankmeldungsanträge" unten)) + `ValueListItemTransition` (erlaubte Übergänge zwischen zwei Items derselben Liste, wird nur für `"OrderStatus"` befüllt). Ersetzt das frühere `OrderStatusDefinition`/`OrderStatusTransition`-Sondermodell — Migration `ReplaceOrderStatusWithValueLists` übernimmt bestehende Auftragsstatus-Zeilen 1:1 mit identischen Ids in die neuen Tabellen, damit `Order.StatusId` unverändert gültig bleibt.
|
||||
|
||||
**Wo welches Feld referenziert wird:**
|
||||
- `Order.StatusId` (FK, echte Fremdschlüsselbeziehung 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.
|
||||
- `Employee.Qualification`, `Order.RequiredQualification` **und** `FacilityQualificationRate.Qualification` (siehe "Konditionen einer Einrichtung" unten) referenzieren alle drei dieselbe Liste `"Qualification"` (ebenfalls einfache `string`-Spalten, gegen `GetActiveValuesAsync`/`GetItemsAsync` validiert wie oben — `EmployeesController`/`OrdersController`/`FacilityQualificationRatesController`). Bewusst eine gemeinsame Liste statt getrennter, damit "benötigte Qualifikation" auf einem Auftrag, "Qualifikation" eines Mitarbeiters und der qualifikationsabhängige Preis einer Einrichtung aus derselben, gleich sortierten Werteliste kommen — Voraussetzung für einen künftigen automatisierten Abgleich (FR-EM-3, Mitarbeiterzuweisung prüft Qualifikation, noch nicht umgesetzt). `SortOrder` bildet dabei die Rangfolge der Qualifikationsniveaus ab (aufsteigend, siehe Seed unten) — es gibt bewusst kein zusätzliches "Level"-Feld, `SortOrder` übernimmt diese Rolle bereits für jede Liste. Da der Wert von mehreren Entitäten referenziert wird, prüft **eine eigene** `IValueListUsageChecker`-Implementierung (`QualificationValueListUsageChecker`, nicht der generische `StringFieldValueListUsageChecker`) alle drei Tabellen — `ValueListService.FindUsagesAsync` befragt pro Key nur den ersten registrierten Checker, separate Registrierungen mit demselben Key hätten weitere Verwendungsstellen beim Löschschutz stillschweigend ignoriert.
|
||||
- `Order.ShiftType` referenziert die Liste `"ShiftType"` (nur eine Entität betroffen, daher regulärer `StringFieldValueListUsageChecker` wie bei den übrigen einfachen String-Listen).
|
||||
- `Order.Priority` (Pflichtfeld, kein `?`) referenziert die Liste `"Priority"` (Niedrig/Normal/Hoch/Dringend, Default "Normal" passend zum Entity-Default). `GET /api/orders` unterstützt zusätzlich `?priority=`/`?requiredQualification=`/`?shiftType=` als exakte Gleichheitsfilter (neben den bereits bestehenden `?statusId=`/`?facilityId=`, `OrderRepository.GetPagedAsync`) — **Achtung:** ein neuer Query-Parameter auf einem bestehenden Endpoint ändert die OpenAPI-Spec genauso wie ein geändertes Contract-DTO, `omsorgapp/api-client-ts` muss danach ebenfalls neu generiert werden (siehe "Generierte API-Clients" unten), sonst kennt der generierte `OrdersApi.apiOrdersGetRaw` den Parameter nicht und die Filterung wirkt sich nicht aus, obwohl Backend und Frontend-Code beide "richtig" aussehen.
|
||||
- `Facility.BillingInterval` referenziert die Liste `"BillingInterval"` (Wöchentlich/Monatlich/Quartalsweise, nur eine Entität betroffen, regulärer `StringFieldValueListUsageChecker`), Teil der Konditionen einer Einrichtung — siehe eigener Abschnitt "Konditionen einer Einrichtung" unten.
|
||||
- `Absence.Type`/`Status` referenzieren `"AbsenceType"` (Urlaub/Krankmeldung/Sonstige) bzw. `"AbsenceStatus"` (Eingereicht/Genehmigt/Abgelehnt) — beide einfache `string`-Spalten wie `Contract.Status`, **ohne** `ValueListItemTransition` (kein Übergangsgraph nötig), aber mit `IsInitial` auf dem "Eingereicht"-Item, damit "noch nicht entschieden" nicht als Anzeigetext-Vergleich hartkodiert werden muss (siehe eigener Abschnitt "Abwesenheits-/Urlaubs-/Krankmeldungsanträge" unten).
|
||||
|
||||
**Verwaltungs-API** (`ValueListsController`, Route `api/value-lists`): `GET /api/value-lists` (alle Listen), `GET /api/value-lists/{key}/items` (nur `[Authorize]`, kein Modul-Recht — die aufrufenden Formulare gehören zu unterschiedlichen Modulen), `POST`/`PUT/DELETE .../items[/...]` sowie `GET/PUT .../transitions` (nur für `"OrderStatus"`) gegated über `[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]` — dieselbe Admin-Berechtigung wie die übrige "Einstellungen"-Seite.
|
||||
**Verwaltungs-API** (`ValueListsController`, Route `api/value-lists`): `GET /api/value-lists` (alle Listen), `GET /api/value-lists/{key}/items` (nur `[Authorize]`, kein Modul-Recht — die aufrufenden Formulare gehören zu unterschiedlichen Modulen), `POST`/`PUT/DELETE .../items[/...]` sowie `GET/PUT .../transitions` (nur für `"OrderStatus"`) gegated über `[RequirePermission(ModuleType.Configuration, PermissionAction.Edit)]` — ein eigenes Admin-Recht, getrennt von Benutzer-/Rechteverwaltung (siehe "Rechtesystem" oben, Abschnitt "Drei getrennte Admin-Rechte").
|
||||
|
||||
**Löschschutz ("erst überall entfernen"):** `ValueListService.DeleteItemAsync` löscht ein `ValueListItem` nur, wenn keine Verwendung mehr existiert. Eine `IValueListUsageChecker`-Implementierung je Liste (`Infrastructure/Repositories/StringFieldValueListUsageChecker.cs` — eine generische Klasse für alle String-Feld-Listen, mehrfach mit unterschiedlicher Query registriert in `Infrastructure/DependencyInjection.cs`; `OrderStatusValueListUsageChecker.cs` für die FK-basierte `"OrderStatus"`-Liste inkl. Übergangsregeln) prüft, ob der Wert noch irgendwo gesetzt ist. Bei Treffern liefert `DELETE .../items/{id}` `409` mit den Fundstellen (`EntityType`/`EntityId`/`DisplayLabel`) im Body, statt zu löschen. `GET .../items/{id}/usages` liefert dieselbe Prüfung jederzeit (nicht nur beim Löschversuch) — für den "wo wird das noch verwendet"-Info-Button in der UI.
|
||||
|
||||
**Seed:** `DbSeeder.SeedValueListsAsync` (jede Umgebung, idempotent — läuft nur, solange `ValueLists` leer ist) legt alle 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.
|
||||
**Seed:** `DbSeeder.SeedValueListsAsync` (jede Umgebung, idempotent — läuft nur, solange `ValueLists` leer ist) legt alle zwölf Listen mit Startwerten an, inkl. der Auftragsstatus-Pipeline (Anfrage → Prüfung → offen → teilweise besetzt → vollständig besetzt → aktiv → abgeschlossen, plus Storno aus jedem nicht-terminalen Status) samt Übergangsregeln, sowie `"Qualification"` mit sieben Startwerten in aufsteigender Rangfolge (Ungelernte Kraft → Betreuungskraft → Pflegehelfer/in → Pflegefachassistent/in → Altenpfleger/in → Gesundheits- und Krankenpfleger/in → Pflegefachkraft mit Leitungsfunktion), `"ShiftType"` mit sechs Startwerten (Frühdienst, Spätdienst, Nachtdienst, Tagdienst, Bereitschaftsdienst, Sonstige), `"Priority"` mit vier Startwerten (Niedrig, Normal, Hoch, Dringend) und `"BillingInterval"` mit drei Startwerten (Wöchentlich, Monatlich (Default), Quartalsweise) — reine Startbefüllung, über "Status-Verwaltung" admin-editierbar. **Wichtig:** Kommt eine neue admin-editierbare Auswahlliste hinzu, gehört sie hier als weiterer `SeedSimpleListAsync`-Aufruf rein plus eine `IValueListUsageChecker`-Registrierung in `Infrastructure/DependencyInjection.cs` — dieser Abschnitt und der Seed müssen mit der Software mitwachsen.
|
||||
|
||||
**Frontend (`omsorgapp`):** `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", ...)`.
|
||||
**Frontend (`omsorgapp`):** `src/api/valueListsApi.js` kapselt `/api/value-lists`, `src/app/useValueListItems.js` (Hook) lädt die Items einer Liste für Dropdowns (ersetzt die früheren hartcodierten Arrays in `EmployeeForm.jsx`/`FacilityForm.jsx`/`EmployeesPage.jsx`/`FacilitiesPage.jsx`). Verwaltungs-UI: `src/modules/settings/StatusManagementPanel.jsx`, Tab "Status-Verwaltung" in `SettingsPage.jsx`, gegated über `hasPermission("Configuration", ...)`.
|
||||
|
||||
## Dokumentenarchiv (FR-MA-3)
|
||||
|
||||
`Document` (`src/OmsorgCore.Domain/Entities/Document.cs`, `AuditableEntity`) referenziert eine beliebige Kern-Entität polymorph über `EntityType` (String-Wert aus `DocumentEntityType`, aktuell nur `Employee` mit echtem Upload-/Validierungspfad — `Facility`/`Contract`/`Order` sind als Enum-Werte für ein künftiges Ausrollen vorgesehen, ohne Migration nachzuziehen) + `EntityId` (kein FK, da mehrere Zieltabellen). `Category` wird wie `Employee.Status`/`Contract.ContractType` gegen die admin-editierbare ValueList `"DocumentCategory"` validiert (siehe "Konfigurierbare Auswahllisten").
|
||||
|
||||
**Bytes liegen auf dem Dateisystem, nicht als Blob in Postgres:** `IDocumentStorage`/`FileSystemDocumentStorage` (`Infrastructure/Storage/`) legt Uploads unter `Storage:DocumentsRootPath/{EntityType}/{EntityId}/{DocumentId}{Extension}` ab (Guids im Pfad, kein Client-String — kein Path-Traversal-Vektor); die DB speichert nur den relativen Pfad (`Document.StorageKey`) + Metadaten. Größen-/Dateityp-Grenzen (`Storage:MaxDocumentSizeBytes`/`Storage:AllowedDocumentContentTypes`, siehe `CONFIGURATION.md`) prüft `IDocumentUploadPolicy`/`DocumentUploadPolicy`, injiziert in `DocumentService.UploadAsync` — analog zu `IPasswordPolicy`.
|
||||
|
||||
**API** (`DocumentsController`, Route `api/documents`, gegated über `[RequirePermission(ModuleType.Documents, ...)]`): `GET ?entityType=&entityId=` (Liste), `POST` (multipart/form-data, `UploadDocumentRequest` mit `IFormFile`; bei `EntityType=Employee` prüft der Controller zusätzlich per `IEmployeeRepository`, dass der Mitarbeiter existiert — analog zur `FacilityContactId`-Cross-Validierung in `OrdersController`), `PUT /{id}` (nur Metadaten — `Category`/`Description`/`FileName`, JSON-Body, kein erneuter Datei-Upload — `PermissionAction.Edit`), `GET /{id}/download` (streamt die Datei), `DELETE /{id}` (Soft-Delete).
|
||||
|
||||
**Zugriffsprotokoll (FR-MA-3-Anforderung):** Der automatische `AuditSaveChangesInterceptor` erfasst nur Create/Update/(Soft-)Delete an `Document`, nicht das lesende Herunterladen. `DocumentsController.Download` dispatcht deshalb zusätzlich ein `AuditEvent(..., "DocumentDownloaded", ...)` über `IDomainEventDispatcher` — derselbe Mechanismus wie Login/Logout/Session-Kill (siehe "Audit-Log" oben). Jeder Download ist damit über `GET /api/audit-log` nachvollziehbar.
|
||||
|
||||
**Rechtematrix:** `ModuleType.Documents` ist Teil der Basis-Rollen-Seed (`DbSeeder.SeedBaseRolesAsync`) — `Geschäftsführung` und `Disposition/Buchhaltung` (Sabrina) haben vollen Zugriff (FR-MA-3: Sabina/Malik/Sabrina), `Recruiting`/`Außendienst` bewusst nicht. Ein Außendienst-Selbstzugriff ("nur eigene Dokumente") ist noch nicht abgebildet, weil OMSORG Connect noch nicht gegen dieses Backend spricht (siehe "Offene Punkte").
|
||||
|
||||
## Konditionen einer Einrichtung (FR-EIN-4)
|
||||
|
||||
Elf der zwölf Blueprint-19.2-Konditionsfelder (Verrechnungssatz, vier Zuschläge, Fahrtkosten, Mindeststunden, Pausenregelung, Abrechnungsintervall, Zahlungsziel, individuelle Vereinbarungen) sind flache, nullable Felder direkt auf `Facility` — kein `OwnsOne`/keine eigene Tabelle, analog zu Adresse/Rechnungsadresse auf `Facility` selbst und den Finanzfeldern auf `Contract`:
|
||||
|
||||
- `BillingRate` (Verrechnungssatz, EUR/Std.), `NightSurchargePercent`/`SaturdaySurchargePercent`/`SundaySurchargePercent`/`HolidaySurchargePercent` (Zuschläge als **Prozent** auf den Verrechnungssatz, nicht als EUR-Betrag), `TravelCostRate` (Fahrtkosten als **Pauschale je Einsatz**, kein km-Modell), `MinimumHours` (Mindeststunden je Einsatz), `BreakPolicy` (Pausenregelung, Freitext), `PaymentTermDays` (Zahlungsziel in Tagen), `IndividualAgreements` (Freitext).
|
||||
- `BillingInterval` wird wie `FacilityType`/`ContractType` gegen die admin-editierbare `ValueList` `"BillingInterval"` (Wöchentlich/Monatlich/Quartalsweise) validiert — siehe "Konfigurierbare Auswahllisten".
|
||||
- Alle elf Felder sind nur über `PUT /api/facilities/{id}` (`UpdateFacilityRequest`) setzbar, nicht beim Anlegen (`CreateFacilityRequest`) — analog zu `CrmStatus`, der ebenfalls erst nach dem Anlegen über "Bearbeiten" gepflegt wird.
|
||||
|
||||
**Ausnahme "Qualifikationsabhängige Preise":** eine variable Liste (ein Satz je Qualifikationsstufe) lässt sich nicht als feste Spaltengruppe abbilden — dafür die neue Entität `FacilityQualificationRate` (1:n zu `Facility`, `Qualification` gegen die ValueList `"Qualification"` validiert — dieselbe Liste wie `Employee.Qualification`/`Order.RequiredQualification`, siehe "Wo welches Feld referenziert wird" oben) als 1:n-Unterressource unter `GET/POST/PUT/DELETE /api/facilities/{facilityId}/qualification-rates[/...]` (`FacilityQualificationRatesController`) — exakt nach dem Muster von `FacilityContact`, kein eigener `ModuleType`, gegated über dieselben `Facilities`-Rechte. Löschen ist Soft-Delete, über `TrashController` (`api/trash/facility-qualification-rates/...`) wiederherstellbar.
|
||||
|
||||
FR-EIN-4s Akzeptanzkriterium ("Konditionssatz ist Grundlage für Rechnungserstellung") ist damit nur zur Hälfte erfüllt — dieser Schritt legt ausschließlich die Datenbasis, `FR-RE-1`/`Invoice` (weiterhin ⬜) konsumiert die Konditionen noch nicht.
|
||||
|
||||
## Abwesenheits-/Urlaubs-/Krankmeldungsanträge (FR-CON-1, Datenbasis für FR-EM-3)
|
||||
|
||||
`Absence` (`src/OmsorgCore.Domain/Entities/Absence.cs`, `AuditableEntity`, first-class Core-Objekt wie `Contract`/`Order`, keine Unterressource) ersetzt die frühere Insellösung aus `omsorgWeb/mitarbeiter-app-legacy` (dort zwei getrennte MySQL-Tabellen `requests_urlaubsantrag`/`requests_abwesenheitsantrag`, verknüpft an lokale `user_id` statt an `omsorgCore`-IDs). Ein gemeinsames Objekt statt zwei getrennter Stacks, weil bis auf die Art alle Felder identisch sind (Root-`CLAUDE.md`: "jede Information wird nur einmal gespeichert"):
|
||||
|
||||
- `EmployeeId` (Pflicht, FK auf `Employee`), `Type` (gegen `ValueList "AbsenceType"`: Urlaub/Krankmeldung/Sonstige), `StartDate`/`EndDate` (`DateOnly`, `EndDate >= StartDate`), `Reason`/`Substitute`/`Note` (alle optionaler Freitext), `Status` (gegen `ValueList "AbsenceStatus"`: Eingereicht/Genehmigt/Abgelehnt), `AdminNote` (optionaler Freitext der entscheidenden Büro-Rolle). `Absence.Status` selbst hat **keinen** hartkodierten C#-Default (anders als z. B. `Contract.Status = "Entwurf"`) — der initiale Wert wird in `AbsenceService.CreateAsync` zur Laufzeit aus `GetInitialItemAsync("AbsenceStatus", ...)` gelesen (`DbSeeder` markiert "Eingereicht" dafür mit `IsInitial = true`, idempotent nachgezogen über `SeedAbsenceStatusInitialFlagIfMissingAsync` auch für bereits existierende Umgebungen). Grund: würde man `"Eingereicht"` als String an mehreren Stellen (Entity-Default, `UpdateAsync`-Prüfung, Decision-Endpoint, beide Frontends) fest verdrahten, würde ein Umbenennen dieses Werts über die Status-Verwaltung die Logik lautlos brechen, ohne dass Backend oder Frontend einen Fehler zeigen — exakt das Muster, das schon bei `Order.RequiredQualification` (ValueList-Referenz statt Enum) und `TriggersFollowUp` bei `CrmStatus` vermieden wird.
|
||||
- **Kein `ValueListItemTransition`** für `AbsenceStatus` (anders als `OrderStatus`) — wer wohin darf, ergibt sich vollständig aus Rechten: Außendienst hat nur `Create`/`View`/`Edit` (kann also nur im initialen Status anlegen/bearbeiten, keinen bereits entschiedenen Antrag ändern), Büro-Rollen haben zusätzlich `Approve` für den eigenen Entscheidungs-Endpoint (siehe unten) und `Delete`/`Recover`. `IAbsenceService.GetInitialStatusValueAsync(...)` ist der einzige Ort, der den initialen Statuswert auflöst — `AbsenceService.UpdateAsync` und `AbsencesController.Decide` nutzen ihn beide, statt jeweils eigene String-Vergleiche zu pflegen.
|
||||
- `ModuleType.Absences` (neuer, additiv angehängter Enum-Wert) ist das erste Modul außer `Employees`/`Contracts`, das `PermissionScope.Own` tatsächlich auswertet (`Absence.EmployeeId` als Ownership-Anker, siehe "Datenebenen-Scope") — `AbsenceService` folgt exakt dem `EmployeeService`/`ContractService`-Muster (`ResolveOwnScopeRestrictionAsync`), mit einer Ergänzung: `CreateAsync` überschreibt bei Own-Scope die `EmployeeId` **immer** serverseitig aus `ICurrentUserService.EmployeeId` (JWT-Claim) und ignoriert einen ggf. vom Client mitgeschickten Wert komplett — kein Client-Vertrauen darauf, wer die eigene Mitarbeiter-Id ist. `CreateAbsenceRequest` hat bewusst **kein** `employeeId`-Feld ("im Namen von" anlegen ist nicht Teil dieser ersten UI).
|
||||
**Own-Scope ist dabei nur eine Sichtbarkeits-/Anlege-Einschränkung, kein Ausschlusskriterium für Selbstanträge (korrigiert 2026-08-10, ursprünglich zu eng):** auch All-Scope-Aufrufer (Büro-Rollen, `Administrator`) haben oft eine eigene verknüpfte `Employee` und wollen für sich selbst einen Antrag stellen können — `CreateAsync` fällt für sie deshalb ebenfalls auf `ICurrentUserService.EmployeeId` zurück (nicht nur bei Own-Scope), und lehnt nur ab (`400`, ohne die Exception bis zur `employees`-FK-Constraint durchzureichen wie beim ursprünglichen Vorfall), wenn wirklich **kein** Mitarbeiter mit dem aufrufenden `User` verknüpft ist. `POST /api/absences` funktioniert damit für jeden angemeldeten Nutzer mit verknüpftem Mitarbeiter, unabhängig von Rolle/Scope — nur die *Sichtbarkeit* anderer Anträge (`GET /api/absences`) bleibt weiterhin durch Own/All eingeschränkt.
|
||||
- **`AbsencesController`** (Route `api/absences`): `GET`/`GET/{id}` (`View`), `POST` (`Create`, `CreateAbsenceRequest` ohne `EmployeeId`-Feld — wird immer serverseitig gesetzt), `PUT /{id}` (`Edit`, `UpdateAbsenceRequest { type, startDate, endDate, reason, substitute, note }` — **nur solange der Status noch der initiale ist** (`IAbsenceService.GetInitialStatusValueAsync`, nicht der Literal `"Eingereicht"`), sonst `400`; `AbsenceService.UpdateAsync`/`UpdateAbsenceResult` prüft das serverseitig, nicht nur im UI. Own-Scope-Aufrufer dürfen dabei nur eigene Anträge bearbeiten, wie bei `GetByIdAsync` — ein fremder Own-Scope-Antrag liefert `404`, nicht `403`, um dessen Existenz nicht zu verraten), `POST /{id}/decision` (`Approve`, `AbsenceDecisionRequest { status, adminNote }`, `status` muss ein Wert aus `"AbsenceStatus"` sein, der **nicht** der initiale ist — **anders als `Update` gibt es hier bewusst keine Prüfung des aktuellen Status**, eine Entscheidung ist jederzeit erneut änderbar (Korrektur einer versehentlichen Genehmigung/Ablehnung), `AbsenceService.DecideAsync` überschreibt `Status`/`AdminNote` unabhängig vom bisherigen Wert), `DELETE` (`Delete`, Soft-Delete, über `TrashController` (`api/trash/absences/...`) wiederherstellbar). `PermissionAction.Edit` ist Teil des Außendienst-Basis-Rollen-Seeds (`Absences.{Create,View,Edit}`, `PermissionScope.Own`) — jeder darf also nur den eigenen, noch nicht entschiedenen Antrag ändern, nicht fremde.
|
||||
- **Basis-Rollen-Seed:** Geschäftsführung automatisch (generische Schleife), `Disposition/Buchhaltung` bekommt `Absences` mit allen Aktionen/`Scope.All`, `Außendienst` bekommt `Absences.{Create,View}` mit `Scope.Own` (`DbSeeder.SeedBaseRolesAsync`).
|
||||
- **Verwendung von `omsorgWeb/mitarbeiter-app`:** der Außendienst stellt Anträge über `pages/urlaubsantrag.php` (neue Seite in der Connect-Neuauflage, nicht in der Legacy-App) gegen genau diesen Endpoint — siehe `omsorgWeb/CLAUDE.md`. `omsorgapp` bekommt die Prüfen/Genehmigen-Seite (`AbsencesPage`/`AbsenceDetailPanel`, Sidebar-Tab "Abwesenheiten") — siehe `omsorgapp/CLAUDE.md`.
|
||||
- **Bewusst nicht Teil dieses Schritts:** kein Konsum dieser Daten in FR-EM-3 (Verfügbarkeitsprüfung bei Zuweisung) — dieser Schritt legt nur die Datenbasis, analog zu FR-EIN-4/`FR-RE-1` oben. Keine Migration/Portierung der alten Legacy-Anträge aus `mitarbeiter-app-legacy`s MySQL.
|
||||
|
||||
## Zeiterfassung (FR-ZE-1/FR-ZE-2)
|
||||
|
||||
`TimeEntry` (`src/OmsorgCore.Domain/Entities/TimeEntry.cs`, `AuditableEntity`, first-class Core-Objekt wie `Order`/`Absence`) bildet die geleistete Arbeitszeit einer Schicht ab: `EmployeeId`/`OrderId` (Pflicht-FKs), `Date`/`Start`/`End`/`BreakDuration`, vier manuell erfasste Zuschlagsstunden-Felder (`NightHours`/`SaturdayHours`/`SundayHours`/`HolidayHours`, Blueprint 19.5 listet sie als "Erfasste Daten" — keine automatische Berechnung aus Beginn/Ende, das wäre ein Nachtfenster-/Feiertagskalender-Feature, das es aktuell nicht gibt), `StatusId` (FK auf `ValueListItem`, wie `Order.StatusId`), `AdminNote`. **Einrichtung wird nicht redundant gespeichert** — sie ergibt sich über `Order.FacilityId`, `TimeEntryResponse` löst sie nur für die Anzeige mit auf (Root-`CLAUDE.md`-Prinzip "jede Information nur einmal speichern").
|
||||
|
||||
**Statuspipeline (FR-ZE-2) — Hybrid aus dem Order- und dem Absence-Muster:** Anders als `Absence` (binäre Entscheidung, kein Übergangsgraph) braucht `TimeEntry` eine echte Mehrstufen-Pipeline (Entwurf → Eingereicht → Prüfung → Rückfrage → Freigegeben → Abgerechnet), dafür wird das bestehende `OrderStatus`-Muster (`ValueListItemTransition`-Graph, `CanTransitionAsync`) wiederverwendet. Zwei zusätzliche, generische Flags lösen die Frage "wer darf welche Kante auslösen", ohne pro Endpoint eigene Statuslisten zu pflegen:
|
||||
- `ValueListItem.IsEditableByOwner` (neues, listenspezifisches Flag analog `IsInitial`/`IsTerminal`) — markiert, in welchen Status-Werten der Ersteller den Datensatz noch inhaltlich bearbeiten darf. Für `"TimeEntryStatus"`: `true` auf Entwurf/Eingereicht/Rückfrage, sonst `false`. Für alle anderen Listen bleibt es `false` (keine Verhaltensänderung).
|
||||
- `ValueListItemTransition.RequiresApproval` (neues Flag, Default `true`) — unterscheidet Selbst-Einreichungs-Kanten (Entwurf→Eingereicht, Rückfrage→Eingereicht, `false`) von Büro-Entscheidungen (alle übrigen Kanten, `true`). `"OrderStatus"`-Transitionen bleiben beim Default `true`, das Feld wird dort schlicht nicht ausgewertet.
|
||||
|
||||
Drei Endpoints statt zwei (Absence hat Edit+Decide, TimeEntry hat Edit+Submit+Decide):
|
||||
- `PUT /api/time-entries/{id}` (`Edit`) — nur Inhaltsfelder (`Order`/`Date`/`Start`/`End`/`BreakDuration`/Zuschlagsstunden), **kein** `StatusId` im DTO. `TimeEntryService.UpdateAsync` lehnt ab (`400`, `NotEditable`), sobald der aktuelle Status `IsEditableByOwner == false` ist.
|
||||
- `POST /api/time-entries/{id}/submit` (`Edit`, kein Body) — `TimeEntryService.SubmitAsync` sucht über `IValueListRepository.GetSelfServiceTransitionAsync(statusId)` die eine ausgehende Kante mit `RequiresApproval == false` und wendet sie an; `400` falls keine existiert. Das ist die einzige Möglichkeit für den Ersteller, den Status selbst zu ändern.
|
||||
- `POST /api/time-entries/{id}/decision` (`Approve`) — `TimeEntryService.DecideAsync` validiert die Ziel-Transition über `CanTransitionAsync` **und** dass sie `RequiresApproval == true` ist (verhindert, dass dieser Endpoint für die Selbst-Einreichungs-Kante missbraucht wird), keine Prüfung des bisherigen Status (wie bei `Absence.DecideAsync` — Büro kann jederzeit erneut entscheiden).
|
||||
|
||||
`ModuleType.TimeEntries` wertet `PermissionScope.Own` aus (`TimeEntry.EmployeeId` als Ownership-Anker, `TimeEntryService` folgt exakt dem `AbsenceService`-Muster inkl. `CreateAsync`-Fallback auf `ICurrentUserService.EmployeeId` für All-Scope-Aufrufer mit eigenem Mitarbeiterbezug). Basis-Rollen-Seed: Geschäftsführung automatisch, Disposition/Buchhaltung alle Aktionen/`Scope.All`, Außendienst `{Create,View,Edit}`/`Scope.Own`, Recruiting kein Zugriff.
|
||||
|
||||
`ValueListsController`s generische `GET/PUT {key}/transitions`-Endpoints funktionieren unverändert für `"TimeEntryStatus"` (keine Sonderbehandlung im Code nötig, siehe "Konfigurierbare Auswahllisten" oben) — `ValueListTransitionResponse` liefert jetzt zusätzlich `RequiresApproval`, damit Clients (siehe `omsorgapp`/`omsorgWeb` unten) die für ihre Rolle relevanten Kanten selbst herausfiltern können.
|
||||
|
||||
**Verwendung:** `omsorgWeb/mitarbeiter-app` (`pages/stundenerfassung.php`) — Außendienst legt/bearbeitet eigene Einträge und löst `submit` aus. `omsorgapp` (`TimeEntriesPage`/`TimeEntryDetailPanel`, Sidebar-Tab "Zeiterfassung") — Büro prüft/entscheidet über `decision`. **Bewusst offen:** `Order` hat keine Mitarbeiter-Zuweisung (FR-EM-3), das Auftrags-Dropdown in beiden Frontends zeigt deshalb alle aktiven Aufträge statt nur zugewiesene.
|
||||
|
||||
## Offene Punkte
|
||||
|
||||
- `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).
|
||||
- `Facility` hat jetzt volles Repository/Service/Controller (`FacilitiesController`, `GET/POST/PUT/DELETE /api/facilities`) nach dem Employee-Muster, inkl. `FacilityCreatedEvent`. Zusätzlich `FacilityContact` (FR-EIN-2, Ansprechpartner) als 1:n-Unterressource unter `GET/POST/PUT/DELETE /api/facilities/{facilityId}/contacts[/...]` (`FacilityContactsController`) und `FacilityQualificationRate` (FR-EIN-4, qualifikationsabhängige Preise) als 1:n-Unterressource unter `GET/POST/PUT/DELETE /api/facilities/{facilityId}/qualification-rates[/...]` (`FacilityQualificationRatesController`, siehe "Konditionen einer Einrichtung" oben) — beide bewusst kein eigener `ModuleType`, sondern über dieselben `Facilities`-Rechte gegated, da beides kein eigenständiges Core-Objekt ist. Löschen (`Employee`/`Facility`/`Contract`/`Order`/`FacilityContact`/`FacilityQualificationRate`) ist jetzt durchgängig Soft-Delete (`IsDeleted`/`DeletedAt`, `PermissionAction.Delete` je Modul, `DELETE`-Endpoint pro Controller) und über den `TrashController` (`api/trash/...`, `PermissionAction.Recover` je Modul, "Papierkorb"-Seite in `omsorgapp`) wiederherstellbar.
|
||||
- `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)`. FR-MA-2 ist damit inkl. `omsorgapp`-UI abgeschlossen: "Verträge"-Tab in `EmployeeDetailPanel` (`ContractsList`/`ContractForm`/`Create-`/`EditContractDialog.jsx`), neue Verträge starten als "Entwurf", Statuswechsel nur im Bearbeiten-Formular, Löschen als Soft-Delete über den Papierkorb wiederherstellbar (siehe `omsorgapp/CLAUDE.md`).
|
||||
- `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` hat jetzt ebenfalls volles Repository/Service/Controller (FR-ZE-1/FR-ZE-2, siehe "Zeiterfassung" oben) — `Invoice` hat weiterhin nur Domain-Entität + DB-Konfiguration, nächster Schritt folgt demselben Muster (Repository-Interface in Application, Implementierung in Infrastructure, Service in Application, Controller in Api) und muss FR-ZE-3 (nur freigegebene Zeit fließt ein) auf Domain-/Application-Ebene erzwingen.
|
||||
- Dokumentenarchiv (FR-MA-3) hat jetzt volles Repository/Service/Controller (`DocumentsController`, `GET/POST /api/documents`, `PUT /{id}` für Metadaten, `GET /{id}/download`, `DELETE /{id}`) — siehe "Dokumentenarchiv" oben. `omsorgapp`-UI ("Dokumente"-Tab in der Personalakte) existiert jetzt ebenfalls, siehe `omsorgapp/CLAUDE.md`. Kein Außendienst-Selbstzugriff, kein physisches Löschen von Dateien beim Soft-Delete/Papierkorb (keine Hard-Purge-Stelle im System, die man konsistent mitziehen müsste).
|
||||
- Keine E-Mail-Verifizierung bei User-Anlage (Passwort-Reset per E-Mail ist fertig, siehe "Passwort-Reset / E-Mail-Versand" oben).
|
||||
- `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.
|
||||
@@ -207,9 +285,9 @@ Dropdown-Werte, die früher als hartcodierte Arrays im `omsorgapp`-Frontend lebt
|
||||
|
||||
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.
|
||||
**Wichtig — beide generierten Clients sind im echten Datenpfad, nicht optional:** Alle Wrapper unter `omsorgapp/src/api/*Api.js` (`employeesApi.js`, `facilitiesApi.js`, `facilityContactsApi.js`, `facilityQualificationRatesApi.js`, `usersApi.js`, `rolesApi.js`, `valueListsApi.js`, `auditLogApi.js`, `authApi.js`, `absencesApi.js`, `timeEntriesApi.js`, ...) importieren die jeweilige `*Api`-Klasse aus dem generierten Paket `omsorgcore-client-ts` (`import { ... } from "omsorgcore-client-ts"`) und reichen Requests/Responses **ungeprüft typisiert** durch. `omsorgWeb/mitarbeiter-app/lib/omsorgCoreClient.php` nutzt seit den Absences-/Orders-/TimeEntries-Wrappern ebenfalls den generierten PHP-Client (`../api-client-php/`, `\OmsorgCoreClient\Api\...`) statt rohem cURL — beide Frontends müssen also nach einer Contract-Änderung neu generiert werden, nicht nur `omsorgapp`.
|
||||
|
||||
**Verbindliche Regel: nach *jeder* Änderung an einem Controller oder DTO in `omsorgCore.Api/Contracts` 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).
|
||||
**Verbindliche Regel: nach *jeder* Änderung an einem Controller oder DTO in `omsorgCore.Api/Contracts` müssen `omsorgapp/api-client-ts` **und** `omsorgWeb/mitarbeiter-app/api-client-php` neu generiert und neu gebaut werden — noch in demselben Change, nicht als Nachgang.** Wird das vergessen, gibt es **keinen Fehler, keine Exception, keine Warnung** — der generierte Client kennt das neue/geänderte Feld schlicht nicht und lässt es beim Serialisieren/Deserialisieren stillschweigend weg. Das Symptom in der UI: Speichern/Anlegen meldet Erfolg, aber das betroffene Feld kommt nie im Backend an bzw. taucht nie in der Antwort auf — schwer zu debuggen, weil weder Backend noch Frontend-Code einen sichtbaren Fehler werfen (siehe FR-EIN-1/Website-Vorfall, 2026-08-08).
|
||||
|
||||
```bash
|
||||
# omsorgCore muss dafür lokal im Development-Modus laufen (Swagger nur dort aktiv):
|
||||
@@ -221,7 +299,7 @@ npm run generate # entspricht ./generate.sh — überschreibt src/, README.md,
|
||||
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.
|
||||
`npm run generate` überschreibt `package.json` komplett (Standard-Output von openapi-generator) — das dort eingetragene `generate`-Script muss danach jedes Mal erneut ergänzt werden (`git diff package.json` prüfen), sonst verschwindet es beim nächsten Lauf wieder. Details/Voraussetzungen: `omsorgapp/api-client-ts/ANLEITUNG.md` (das jeweilige `README.md` wird vom Generator automatisch überschrieben, `ANLEITUNG.md` bleibt stabil). `omsorgWeb/mitarbeiter-app/api-client-php` ist seit den Absences-/Orders-/TimeEntries-Wrappern ebenfalls im echten Datenpfad (siehe oben) und muss im selben Zug regeneriert werden, nicht nur "bei Gelegenheit".
|
||||
|
||||
## Die sechs Core-Objekte (Domain-Entitäten)
|
||||
|
||||
@@ -232,7 +310,7 @@ Verbindliche Regeln für das Datenmodell:
|
||||
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).
|
||||
5. Kein Hard-Delete für sensible/geschäftsrelevante Daten — Soft-Delete/Archivierung. Umgesetzt für `Employee`/`Facility`/`Contract`/`Order`/`FacilityContact`/`FacilityQualificationRate`/`Absence`/`TimeEntry` (`IsDeleted`/`DeletedAt`, `DELETE`-Endpoints gegated über `PermissionAction.Delete`, Wiederherstellung über `TrashController`/`PermissionAction.Recover`); `Invoice` hat noch kein CRUD, daher hier noch nicht relevant.
|
||||
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.
|
||||
|
||||
|
||||
@@ -26,6 +26,14 @@ ASP.NET Core liest Konfiguration in dieser Reihenfolge (später gewinnt):
|
||||
| `Auth:LoginLockoutMinutes` | `10` | `Auth__LoginLockoutMinutes` | Zeitfenster, in dem Fehlversuche gezählt werden, bevor die Sperre wieder abläuft. |
|
||||
| `PasswordPolicy:MinLength` | `8` | `PasswordPolicy__MinLength` | Mindestlänge für jedes neu gesetzte Passwort (Account-Anlage, Admin-Reset, Passwort ändern, Passwort-vergessen-Reset — zentral über `IPasswordPolicy`, siehe `omsorgCore/CLAUDE.md`). Über `GET /api/auth/password-policy` auch unauthentifiziert abrufbar, damit Clients denselben Wert für Hinweistexte/Vorab-Validierung nutzen können. |
|
||||
|
||||
## Dokumentenarchiv / Storage
|
||||
|
||||
| Key | Default | Env-Var | Beschreibung |
|
||||
|---|---|---|---|
|
||||
| `Storage:DocumentsRootPath` | `"App_Data/documents"` | `Storage__DocumentsRootPath` | Wurzelverzeichnis, unter dem Dokument-Uploads (FR-MA-3) physisch auf dem Dateisystem abgelegt werden — relativ zum Arbeitsverzeichnis der `OmsorgCore.Api`, wenn kein absoluter Pfad angegeben wird. Nur der Pfad/Metadaten landen in Postgres, nicht die Bytes selbst (siehe `omsorgCore/CLAUDE.md`, "Dokumentenarchiv"). Produktiv auf einen echten, persistenten Pfad zeigen (nicht das Deployment-Verzeichnis selbst). |
|
||||
| `Storage:MaxDocumentSizeBytes` | `20971520` (20 MB) | `Storage__MaxDocumentSizeBytes` | Maximal erlaubte Dateigröße pro Upload, geprüft in `IDocumentUploadPolicy`/`DocumentService.UploadAsync`. |
|
||||
| `Storage:AllowedDocumentContentTypes` | `"application/pdf,image/jpeg,image/png"` | `Storage__AllowedDocumentContentTypes` | Kommagetrennte Liste erlaubter `Content-Type`-Werte für Uploads. |
|
||||
|
||||
## Passwort-Reset / E-Mail
|
||||
|
||||
| Key | Default | Env-Var | Beschreibung |
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Build-Kontext ist der Repo-Root (siehe .gitea/workflows/docker-build.yml und docker-compose.yml) -
|
||||
# damit bleibt der Kontext für alle drei Dockerfiles im Monorepo einheitlich, auch wenn dieses
|
||||
# Image allein aus omsorgCore/ besteht.
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
WORKDIR /src
|
||||
COPY omsorgCore/ .
|
||||
RUN dotnet restore OmsorgCore.sln
|
||||
RUN dotnet publish src/OmsorgCore.Api/OmsorgCore.Api.csproj -c Release -o /app/publish --no-restore
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
# Migrationen laufen automatisch bei jedem Start (Program.cs: db.Database.MigrateAsync()) -
|
||||
# kein separater Migrations-Schritt im Image nötig. Konfiguration (ConnectionStrings__OmsorgCore,
|
||||
# Jwt__Secret, Cors__AllowedOrigins__0, ...) kommt ausschließlich über Env-Vars zur Laufzeit,
|
||||
# nie ins Image gebacken (siehe omsorgCore/CLAUDE.md, Abschnitt "Secret-Handling").
|
||||
ENV ASPNETCORE_URLS=http://+:8080
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["dotnet", "OmsorgCore.Api.dll"]
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record AbsenceDecisionRequest(string Status, string? AdminNote);
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record AbsenceResponse(
|
||||
Guid Id,
|
||||
Guid EmployeeId,
|
||||
string EmployeeName,
|
||||
string Type,
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate,
|
||||
string? Reason,
|
||||
string? Substitute,
|
||||
string? Note,
|
||||
string Status,
|
||||
string? AdminNote,
|
||||
DateTime CreatedAt);
|
||||
@@ -2,4 +2,4 @@ using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record AddUserPermissionOverrideRequest(ModuleType Module, PermissionAction Action, PermissionEffect Effect);
|
||||
public record AddUserPermissionOverrideRequest(ModuleType Module, PermissionAction Action, PermissionEffect Effect, PermissionScope Scope);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record CreateAbsenceRequest(
|
||||
string Type,
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate,
|
||||
string? Reason,
|
||||
string? Substitute,
|
||||
string? Note);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record CreateFacilityQualificationRateRequest(
|
||||
string Qualification,
|
||||
decimal Rate);
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record CreateTimeEntryRequest(
|
||||
Guid OrderId,
|
||||
DateOnly Date,
|
||||
TimeOnly Start,
|
||||
TimeOnly End,
|
||||
TimeSpan BreakDuration,
|
||||
decimal NightHours,
|
||||
decimal SaturdayHours,
|
||||
decimal SundayHours,
|
||||
decimal HolidayHours);
|
||||
@@ -5,4 +5,5 @@ public record CreateValueListItemRequest(
|
||||
int SortOrder,
|
||||
bool IsDefault = false,
|
||||
bool IsInitial = false,
|
||||
bool IsTerminal = false);
|
||||
bool IsTerminal = false,
|
||||
bool TriggersFollowUp = false);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record DocumentResponse(
|
||||
Guid Id,
|
||||
string EntityType,
|
||||
Guid EntityId,
|
||||
string Category,
|
||||
string FileName,
|
||||
string ContentType,
|
||||
long SizeBytes,
|
||||
string? Description,
|
||||
Guid UploadedByUserId,
|
||||
string? UploadedByUsername,
|
||||
DateTime CreatedAt);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record FacilityQualificationRateResponse(
|
||||
Guid Id,
|
||||
Guid FacilityId,
|
||||
string Qualification,
|
||||
decimal Rate);
|
||||
@@ -13,4 +13,16 @@ public record FacilityResponse(
|
||||
string? BillingStreet,
|
||||
string? BillingPostalCode,
|
||||
string? BillingCity,
|
||||
string? BillingCountry);
|
||||
string? BillingCountry,
|
||||
DateTime? FollowUpDueDate,
|
||||
decimal? BillingRate,
|
||||
decimal? NightSurchargePercent,
|
||||
decimal? SaturdaySurchargePercent,
|
||||
decimal? SundaySurchargePercent,
|
||||
decimal? HolidaySurchargePercent,
|
||||
decimal? TravelCostRate,
|
||||
decimal? MinimumHours,
|
||||
string? BreakPolicy,
|
||||
string? BillingInterval,
|
||||
int? PaymentTermDays,
|
||||
string? IndividualAgreements);
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
/// <summary>"sent" | "cannot_reset" - siehe AuthController.ForgotPasswordRequest für die Anti-Enumeration-Abwägung.</summary>
|
||||
/// <summary>
|
||||
/// "sent" | "cannot_reset" | "email_unavailable" - siehe AuthController.ForgotPasswordRequest für die
|
||||
/// Anti-Enumeration-Abwägung. "email_unavailable": Reset-Code wurde angelegt, aber der E-Mail-Versand
|
||||
/// ist fehlgeschlagen (z.B. SMTP nicht erreichbar) - Client soll das ehrlich anzeigen statt zum
|
||||
/// PIN-Eingabe-Schritt weiterzuleiten.
|
||||
/// </summary>
|
||||
public record ForgotPasswordRequestResponse(string Status);
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record LoginResponse(string AccessToken, string RefreshToken, DateTime ExpiresAt, bool MustChangePassword);
|
||||
public record LoginResponse(string AccessToken, DateTime ExpiresAt, bool MustChangePassword);
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record LogoutRequest(string RefreshToken);
|
||||
@@ -2,4 +2,4 @@ using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record PermissionDto(ModuleType Module, PermissionAction Action);
|
||||
public record PermissionDto(ModuleType Module, PermissionAction Action, PermissionScope Scope);
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record RefreshRequest(string RefreshToken);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record TimeEntryDecisionRequest(Guid StatusId, string? AdminNote);
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record TimeEntryResponse(
|
||||
Guid Id,
|
||||
Guid EmployeeId,
|
||||
string EmployeeName,
|
||||
Guid OrderId,
|
||||
Guid FacilityId,
|
||||
string FacilityName,
|
||||
DateOnly Date,
|
||||
TimeOnly Start,
|
||||
TimeOnly End,
|
||||
TimeSpan BreakDuration,
|
||||
decimal NightHours,
|
||||
decimal SaturdayHours,
|
||||
decimal SundayHours,
|
||||
decimal HolidayHours,
|
||||
Guid StatusId,
|
||||
string StatusName,
|
||||
bool IsEditableByOwner,
|
||||
string? AdminNote,
|
||||
DateTime CreatedAt);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record TrashAbsenceResponse(
|
||||
Guid Id,
|
||||
string Type,
|
||||
DateTime? DeletedAt);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record TrashContractResponse(
|
||||
Guid Id,
|
||||
string ContractType,
|
||||
DateTime? DeletedAt);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record TrashEmployeeResponse(
|
||||
Guid Id,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
DateTime? DeletedAt);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record TrashFacilityContactResponse(
|
||||
Guid Id,
|
||||
Guid FacilityId,
|
||||
string Name,
|
||||
DateTime? DeletedAt);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record TrashFacilityQualificationRateResponse(
|
||||
Guid Id,
|
||||
Guid FacilityId,
|
||||
string Qualification,
|
||||
DateTime? DeletedAt);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record TrashFacilityResponse(
|
||||
Guid Id,
|
||||
string Name,
|
||||
DateTime? DeletedAt);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record TrashOrderResponse(
|
||||
Guid Id,
|
||||
string? RequiredQualification,
|
||||
DateTime? DeletedAt);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record TrashTimeEntryResponse(Guid Id, DateOnly Date, DateTime? DeletedAt);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record UpdateAbsenceRequest(
|
||||
string Type,
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate,
|
||||
string? Reason,
|
||||
string? Substitute,
|
||||
string? Note);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record UpdateDocumentRequest(
|
||||
string Category,
|
||||
string? Description,
|
||||
string FileName);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record UpdateFacilityQualificationRateRequest(
|
||||
string Qualification,
|
||||
decimal Rate);
|
||||
@@ -12,4 +12,16 @@ public record UpdateFacilityRequest(
|
||||
string? BillingStreet,
|
||||
string? BillingPostalCode,
|
||||
string? BillingCity,
|
||||
string? BillingCountry);
|
||||
string? BillingCountry,
|
||||
int? FollowUpDays,
|
||||
decimal? BillingRate,
|
||||
decimal? NightSurchargePercent,
|
||||
decimal? SaturdaySurchargePercent,
|
||||
decimal? SundaySurchargePercent,
|
||||
decimal? HolidaySurchargePercent,
|
||||
decimal? TravelCostRate,
|
||||
decimal? MinimumHours,
|
||||
string? BreakPolicy,
|
||||
string? BillingInterval,
|
||||
int? PaymentTermDays,
|
||||
string? IndividualAgreements);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record UpdateTimeEntryRequest(
|
||||
Guid OrderId,
|
||||
DateOnly Date,
|
||||
TimeOnly Start,
|
||||
TimeOnly End,
|
||||
TimeSpan BreakDuration,
|
||||
decimal NightHours,
|
||||
decimal SaturdayHours,
|
||||
decimal SundayHours,
|
||||
decimal HolidayHours);
|
||||
@@ -5,4 +5,5 @@ public record UpdateValueListItemRequest(
|
||||
int SortOrder,
|
||||
bool IsDefault = false,
|
||||
bool IsInitial = false,
|
||||
bool IsTerminal = false);
|
||||
bool IsTerminal = false,
|
||||
bool TriggersFollowUp = false);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record UploadDocumentRequest(
|
||||
string EntityType,
|
||||
Guid EntityId,
|
||||
string Category,
|
||||
string? Description,
|
||||
IFormFile File);
|
||||
@@ -2,4 +2,4 @@ using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record UserPermissionOverrideResponse(Guid Id, ModuleType Module, PermissionAction Action, PermissionEffect Effect);
|
||||
public record UserPermissionOverrideResponse(Guid Id, ModuleType Module, PermissionAction Action, PermissionEffect Effect, PermissionScope Scope);
|
||||
|
||||
@@ -6,4 +6,5 @@ public record ValueListItemResponse(
|
||||
int SortOrder,
|
||||
bool IsDefault,
|
||||
bool IsInitial,
|
||||
bool IsTerminal);
|
||||
bool IsTerminal,
|
||||
bool TriggersFollowUp);
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record ValueListTransitionResponse(Guid Id, Guid FromItemId, Guid ToItemId);
|
||||
public record ValueListTransitionResponse(Guid Id, Guid FromItemId, Guid ToItemId, bool RequiresApproval);
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OmsorgCore.Api.Contracts;
|
||||
using OmsorgCore.Api.Security;
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Application.Services;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Abwesenheits-/Urlaubs-/Krankmeldungsanträge (FR-CON-1, Datenbasis für FR-EM-3). Außendienst
|
||||
/// darf nur Create/View mit PermissionScope.Own (eigene Anträge, EmployeeId wird serverseitig aus
|
||||
/// dem JWT gesetzt, siehe AbsenceService.CreateAsync), Büro-Rollen sehen/entscheiden über alle.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/absences")]
|
||||
public class AbsencesController : ControllerBase
|
||||
{
|
||||
private const string AbsenceTypeListKey = "AbsenceType";
|
||||
private const string AbsenceStatusListKey = "AbsenceStatus";
|
||||
|
||||
private readonly IAbsenceService _absenceService;
|
||||
private readonly IValueListRepository _valueListRepository;
|
||||
|
||||
public AbsencesController(IAbsenceService absenceService, IValueListRepository valueListRepository)
|
||||
{
|
||||
_absenceService = absenceService;
|
||||
_valueListRepository = valueListRepository;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[RequirePermission(ModuleType.Absences, PermissionAction.View)]
|
||||
public async Task<ActionResult<PagedResponse<AbsenceResponse>>> GetAll(
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] string? type,
|
||||
[FromQuery] Guid? employeeId,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
page = Math.Max(page, 1);
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
|
||||
var (items, totalCount) = await _absenceService.GetPagedAsync(status, type, employeeId, page, pageSize, cancellationToken);
|
||||
return Ok(new PagedResponse<AbsenceResponse>(items.Select(ToResponse).ToList(), totalCount, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Absences, PermissionAction.View)]
|
||||
public async Task<ActionResult<AbsenceResponse>> GetById(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var absence = await _absenceService.GetByIdAsync(id, cancellationToken);
|
||||
return absence is null ? NotFound() : Ok(ToResponse(absence));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[RequirePermission(ModuleType.Absences, PermissionAction.Create)]
|
||||
public async Task<ActionResult<AbsenceResponse>> Create(CreateAbsenceRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var fieldError = await ValidateFieldsAsync(request.Type, request.StartDate, request.EndDate, request.Reason, request.Substitute, request.Note, cancellationToken);
|
||||
if (fieldError is not null)
|
||||
{
|
||||
return BadRequest(fieldError);
|
||||
}
|
||||
|
||||
var absence = new Absence
|
||||
{
|
||||
Type = request.Type,
|
||||
StartDate = request.StartDate,
|
||||
EndDate = request.EndDate,
|
||||
Reason = request.Reason,
|
||||
Substitute = request.Substitute,
|
||||
Note = request.Note
|
||||
};
|
||||
|
||||
var created = await _absenceService.CreateAsync(absence, cancellationToken);
|
||||
if (created is null)
|
||||
{
|
||||
return BadRequest("Kein Mitarbeiter verknüpft - Abwesenheitsanträge können nur für einen verknüpften Mitarbeiter angelegt werden.");
|
||||
}
|
||||
|
||||
var reloaded = await _absenceService.GetByIdAsync(created.Id, cancellationToken);
|
||||
return CreatedAtAction(nameof(GetById), new { id = created.Id }, ToResponse(reloaded!));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Absences, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<AbsenceResponse>> Update(Guid id, UpdateAbsenceRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var fieldError = await ValidateFieldsAsync(request.Type, request.StartDate, request.EndDate, request.Reason, request.Substitute, request.Note, cancellationToken);
|
||||
if (fieldError is not null)
|
||||
{
|
||||
return BadRequest(fieldError);
|
||||
}
|
||||
|
||||
var updates = new Absence
|
||||
{
|
||||
Type = request.Type,
|
||||
StartDate = request.StartDate,
|
||||
EndDate = request.EndDate,
|
||||
Reason = request.Reason,
|
||||
Substitute = request.Substitute,
|
||||
Note = request.Note
|
||||
};
|
||||
|
||||
var result = await _absenceService.UpdateAsync(id, updates, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
return result.FailureReason == UpdateAbsenceFailureReason.AlreadyDecided
|
||||
? BadRequest("Der Antrag wurde bereits entschieden und kann nicht mehr bearbeitet werden.")
|
||||
: NotFound();
|
||||
}
|
||||
|
||||
var reloaded = await _absenceService.GetByIdAsync(result.Absence!.Id, cancellationToken);
|
||||
return Ok(ToResponse(reloaded!));
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/decision")]
|
||||
[RequirePermission(ModuleType.Absences, PermissionAction.Approve)]
|
||||
public async Task<ActionResult<AbsenceResponse>> Decide(Guid id, AbsenceDecisionRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var allowedStatuses = await _valueListRepository.GetActiveValuesAsync(AbsenceStatusListKey, cancellationToken);
|
||||
var initialStatus = await _absenceService.GetInitialStatusValueAsync(cancellationToken);
|
||||
var decidableStatuses = allowedStatuses.Where(s => s != initialStatus).ToList();
|
||||
if (string.IsNullOrWhiteSpace(request.Status) || !decidableStatuses.Contains(request.Status))
|
||||
{
|
||||
return BadRequest($"Status muss einer der folgenden Werte sein: {string.Join(", ", decidableStatuses)}.");
|
||||
}
|
||||
|
||||
if (request.AdminNote is { Length: > 500 })
|
||||
{
|
||||
return BadRequest("AdminNote darf maximal 500 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
var decided = await _absenceService.DecideAsync(id, request.Status, request.AdminNote, cancellationToken);
|
||||
return decided is null ? NotFound() : Ok(ToResponse(decided));
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Absences, PermissionAction.Delete)]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var deleted = await _absenceService.DeleteAsync(id, cancellationToken);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
private async Task<string?> ValidateFieldsAsync(
|
||||
string type,
|
||||
DateOnly startDate,
|
||||
DateOnly endDate,
|
||||
string? reason,
|
||||
string? substitute,
|
||||
string? note,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(type))
|
||||
{
|
||||
return "Type ist erforderlich.";
|
||||
}
|
||||
|
||||
var allowedTypes = await _valueListRepository.GetActiveValuesAsync(AbsenceTypeListKey, cancellationToken);
|
||||
if (!allowedTypes.Contains(type))
|
||||
{
|
||||
return $"Type muss einer der folgenden Werte sein: {string.Join(", ", allowedTypes)}.";
|
||||
}
|
||||
|
||||
if (startDate == default)
|
||||
{
|
||||
return "StartDate ist erforderlich.";
|
||||
}
|
||||
|
||||
if (endDate < startDate)
|
||||
{
|
||||
return "EndDate darf nicht vor StartDate liegen.";
|
||||
}
|
||||
|
||||
if (reason is { Length: > 500 })
|
||||
{
|
||||
return "Reason darf maximal 500 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (substitute is { Length: > 200 })
|
||||
{
|
||||
return "Substitute darf maximal 200 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (note is { Length: > 500 })
|
||||
{
|
||||
return "Note darf maximal 500 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static AbsenceResponse ToResponse(Absence absence)
|
||||
=> new(
|
||||
absence.Id,
|
||||
absence.EmployeeId,
|
||||
absence.Employee is null ? string.Empty : $"{absence.Employee.FirstName} {absence.Employee.LastName}",
|
||||
absence.Type,
|
||||
absence.StartDate,
|
||||
absence.EndDate,
|
||||
absence.Reason,
|
||||
absence.Substitute,
|
||||
absence.Note,
|
||||
absence.Status,
|
||||
absence.AdminNote,
|
||||
absence.CreatedAt);
|
||||
}
|
||||
@@ -22,11 +22,13 @@ public class AdminEmailController : ControllerBase
|
||||
{
|
||||
private readonly IEmailSender _emailSender;
|
||||
private readonly EmailOptions _emailOptions;
|
||||
private readonly ILogger<AdminEmailController> _logger;
|
||||
|
||||
public AdminEmailController(IEmailSender emailSender, IOptions<EmailOptions> emailOptions)
|
||||
public AdminEmailController(IEmailSender emailSender, IOptions<EmailOptions> emailOptions, ILogger<AdminEmailController> logger)
|
||||
{
|
||||
_emailSender = emailSender;
|
||||
_emailOptions = emailOptions.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("password-reset-template")]
|
||||
@@ -60,7 +62,18 @@ public class AdminEmailController : ControllerBase
|
||||
subject = EmailTemplateRenderer.Render(subject, values);
|
||||
body = EmailTemplateRenderer.Render(body, values);
|
||||
|
||||
await _emailSender.SendAsync(new EmailMessage(request.ToAddress, subject, body), cancellationToken);
|
||||
try
|
||||
{
|
||||
await _emailSender.SendAsync(new EmailMessage(request.ToAddress, subject, body), cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Admin-only (RequirePermission oben) - die Exception-Message darf hier raus, sie enthält
|
||||
// keine SMTP-Zugangsdaten (nur MailKit-Fehlertext wie "Authentication failed"/"Connection
|
||||
// refused") und ist genau das, was zum Debuggen der Email:*-Konfiguration gebraucht wird.
|
||||
_logger.LogError(ex, "Test-Mail konnte nicht gesendet werden an {ToAddress}", request.ToAddress);
|
||||
return StatusCode(StatusCodes.Status502BadGateway, new { error = "send_failed", message = ex.Message });
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -13,12 +13,17 @@ namespace OmsorgCore.Api.Controllers;
|
||||
[Route("api/auth")]
|
||||
public class AuthController : ControllerBase
|
||||
{
|
||||
private const string RefreshTokenCookieName = "refreshToken";
|
||||
|
||||
private readonly IAuthService _authService;
|
||||
private readonly ICurrentUserService _currentUserService;
|
||||
private readonly IPasswordResetService _passwordResetService;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IDomainEventDispatcher _dispatcher;
|
||||
private readonly PasswordPolicyOptions _passwordPolicyOptions;
|
||||
private readonly RefreshTokenOptions _refreshTokenOptions;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly ILogger<AuthController> _logger;
|
||||
|
||||
public AuthController(
|
||||
IAuthService authService,
|
||||
@@ -26,7 +31,10 @@ public class AuthController : ControllerBase
|
||||
IPasswordResetService passwordResetService,
|
||||
IUserService userService,
|
||||
IDomainEventDispatcher dispatcher,
|
||||
IOptions<PasswordPolicyOptions> passwordPolicyOptions)
|
||||
IOptions<PasswordPolicyOptions> passwordPolicyOptions,
|
||||
IOptions<RefreshTokenOptions> refreshTokenOptions,
|
||||
IWebHostEnvironment environment,
|
||||
ILogger<AuthController> logger)
|
||||
{
|
||||
_authService = authService;
|
||||
_currentUserService = currentUserService;
|
||||
@@ -34,6 +42,25 @@ public class AuthController : ControllerBase
|
||||
_userService = userService;
|
||||
_dispatcher = dispatcher;
|
||||
_passwordPolicyOptions = passwordPolicyOptions.Value;
|
||||
_refreshTokenOptions = refreshTokenOptions.Value;
|
||||
_environment = environment;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// HttpOnly, damit ein Browser-Frontend den Refresh-Token nie per JS lesen kann (XSS-Schutz) -
|
||||
// Path auf /api/auth eingeschränkt, da nur login/refresh/logout ihn brauchen. Secure nur außerhalb
|
||||
// von Development, weil der lokale Dev-Server per launchSettings.json standardmäßig nur über
|
||||
// http:// läuft (kein https-Profil default) - ein Secure-Cookie würde der Browser dort nie setzen.
|
||||
private void SetRefreshTokenCookie(string refreshToken)
|
||||
{
|
||||
Response.Cookies.Append(RefreshTokenCookieName, refreshToken, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = !_environment.IsDevelopment(),
|
||||
SameSite = SameSiteMode.Lax,
|
||||
Expires = DateTimeOffset.UtcNow.AddDays(_refreshTokenOptions.ExpiryDays),
|
||||
Path = "/api/auth"
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
@@ -54,25 +81,37 @@ public class AuthController : ControllerBase
|
||||
}
|
||||
|
||||
await _dispatcher.DispatchAsync(new AuditEvent(result.UserId, result.Username, ipAddress, "Login"), cancellationToken);
|
||||
return Ok(new LoginResponse(result.Token, result.RefreshToken, result.ExpiresAt.Value, result.MustChangePassword));
|
||||
SetRefreshTokenCookie(result.RefreshToken);
|
||||
return Ok(new LoginResponse(result.Token, result.ExpiresAt.Value, result.MustChangePassword));
|
||||
}
|
||||
|
||||
[HttpPost("refresh")]
|
||||
public async Task<ActionResult<LoginResponse>> Refresh(RefreshRequest request, CancellationToken cancellationToken)
|
||||
public async Task<ActionResult<LoginResponse>> Refresh(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _authService.RefreshAsync(request.RefreshToken, cancellationToken);
|
||||
if (!Request.Cookies.TryGetValue(RefreshTokenCookieName, out var refreshToken) || string.IsNullOrEmpty(refreshToken))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var result = await _authService.RefreshAsync(refreshToken, cancellationToken);
|
||||
if (!result.Success || result.Token is null || result.RefreshToken is null || result.ExpiresAt is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
return Ok(new LoginResponse(result.Token, result.RefreshToken, result.ExpiresAt.Value, result.MustChangePassword));
|
||||
SetRefreshTokenCookie(result.RefreshToken);
|
||||
return Ok(new LoginResponse(result.Token, result.ExpiresAt.Value, result.MustChangePassword));
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
public async Task<IActionResult> Logout(LogoutRequest request, CancellationToken cancellationToken)
|
||||
public async Task<IActionResult> Logout(CancellationToken cancellationToken)
|
||||
{
|
||||
await _authService.RevokeAsync(request.RefreshToken, cancellationToken);
|
||||
if (Request.Cookies.TryGetValue(RefreshTokenCookieName, out var refreshToken) && !string.IsNullOrEmpty(refreshToken))
|
||||
{
|
||||
await _authService.RevokeAsync(refreshToken, cancellationToken);
|
||||
}
|
||||
|
||||
Response.Cookies.Delete(RefreshTokenCookieName, new CookieOptions { Path = "/api/auth" });
|
||||
await _dispatcher.DispatchAsync(
|
||||
new AuditEvent(_currentUserService.UserId, _currentUserService.Username, _currentUserService.IpAddress, "Logout"),
|
||||
cancellationToken);
|
||||
@@ -95,7 +134,7 @@ public class AuthController : ControllerBase
|
||||
}
|
||||
|
||||
var permissions = profile.Permissions
|
||||
.Select(p => new PermissionDto(p.Module, p.Action))
|
||||
.Select(p => new PermissionDto(p.Module, p.Action, p.Scope))
|
||||
.ToList();
|
||||
|
||||
return Ok(new MeResponse(
|
||||
@@ -142,8 +181,22 @@ public class AuthController : ControllerBase
|
||||
var result = await _passwordResetService.RequestResetAsync(request.Username, cancellationToken);
|
||||
if (result.Status == PasswordResetRequestStatus.Sent && result.Email is not null && result.RawPin is not null)
|
||||
{
|
||||
await _dispatcher.DispatchAsync(
|
||||
new PasswordResetRequestedEvent(result.Email, result.RawPin), cancellationToken);
|
||||
try
|
||||
{
|
||||
await _dispatcher.DispatchAsync(
|
||||
new PasswordResetRequestedEvent(result.Email, result.RawPin), cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Der Reset-Code wurde bereits in der DB angelegt (PasswordResetService.RequestResetAsync) -
|
||||
// nur der E-Mail-Versand ist fehlgeschlagen (z.B. SMTP nicht erreichbar/falsch konfiguriert).
|
||||
// Client bekommt "email_unavailable" statt "sent", damit die UI ehrlich anzeigt, dass gerade
|
||||
// kein Code angekommen ist, statt den Nutzer auf einen leeren PIN-Eingabe-Schritt zu schicken.
|
||||
// Kein zusätzliches Enumeration-Risiko ggü. heute: "sent" vs. "cannot_reset" unterscheidet
|
||||
// bereits, ob der Username existiert (siehe ForgotPasswordRequestResponse-Doku).
|
||||
_logger.LogError(ex, "Passwort-Reset-E-Mail konnte nicht versendet werden für UserId {UserId}", result.UserId);
|
||||
return Ok(new ForgotPasswordRequestResponse("email_unavailable"));
|
||||
}
|
||||
}
|
||||
|
||||
var status = result.Status == PasswordResetRequestStatus.Sent ? "sent" : "cannot_reset";
|
||||
|
||||
@@ -151,6 +151,14 @@ public class ContractsController : ControllerBase
|
||||
return updated is null ? NotFound() : Ok(ToResponse(updated));
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Contracts, PermissionAction.Delete)]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var deleted = await _contractService.DeleteAsync(id, cancellationToken);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
private async Task<string?> ValidateFieldsAsync(
|
||||
string contractType,
|
||||
Guid? employeeId,
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OmsorgCore.Api.Contracts;
|
||||
using OmsorgCore.Api.Security;
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Application.Services;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
using OmsorgCore.Engine.Events;
|
||||
|
||||
namespace OmsorgCore.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/documents")]
|
||||
public class DocumentsController : ControllerBase
|
||||
{
|
||||
private readonly IDocumentService _documentService;
|
||||
private readonly IEmployeeRepository _employeeRepository;
|
||||
private readonly ICurrentUserService _currentUserService;
|
||||
private readonly IDomainEventDispatcher _dispatcher;
|
||||
|
||||
public DocumentsController(
|
||||
IDocumentService documentService,
|
||||
IEmployeeRepository employeeRepository,
|
||||
ICurrentUserService currentUserService,
|
||||
IDomainEventDispatcher dispatcher)
|
||||
{
|
||||
_documentService = documentService;
|
||||
_employeeRepository = employeeRepository;
|
||||
_currentUserService = currentUserService;
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[RequirePermission(ModuleType.Documents, PermissionAction.View)]
|
||||
public async Task<ActionResult<IReadOnlyList<DocumentResponse>>> GetByEntity(
|
||||
[FromQuery] string entityType,
|
||||
[FromQuery] Guid entityId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Enum.TryParse<DocumentEntityType>(entityType, out _))
|
||||
{
|
||||
return BadRequest($"entityType muss einer der folgenden Werte sein: {string.Join(", ", Enum.GetNames<DocumentEntityType>())}.");
|
||||
}
|
||||
|
||||
var documents = await _documentService.GetByEntityAsync(entityType, entityId, cancellationToken);
|
||||
return Ok(documents.Select(ToResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[RequirePermission(ModuleType.Documents, PermissionAction.Create)]
|
||||
[Consumes("multipart/form-data")]
|
||||
public async Task<ActionResult<DocumentResponse>> Upload([FromForm] UploadDocumentRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Enum.TryParse<DocumentEntityType>(request.EntityType, out var entityType))
|
||||
{
|
||||
return BadRequest($"EntityType muss einer der folgenden Werte sein: {string.Join(", ", Enum.GetNames<DocumentEntityType>())}.");
|
||||
}
|
||||
|
||||
if (entityType == DocumentEntityType.Employee)
|
||||
{
|
||||
var employee = await _employeeRepository.GetByIdAsync(request.EntityId, cancellationToken);
|
||||
if (employee is null)
|
||||
{
|
||||
return BadRequest("EntityId verweist auf keinen existierenden Mitarbeiter.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return BadRequest("Dokumente sind aktuell nur für EntityType=Employee möglich.");
|
||||
}
|
||||
|
||||
if (request.File is null || request.File.Length == 0)
|
||||
{
|
||||
return BadRequest("File ist erforderlich.");
|
||||
}
|
||||
|
||||
await using var stream = request.File.OpenReadStream();
|
||||
var result = await _documentService.UploadAsync(
|
||||
request.EntityType,
|
||||
request.EntityId,
|
||||
request.Category,
|
||||
request.Description,
|
||||
request.File.FileName,
|
||||
request.File.ContentType,
|
||||
request.File.Length,
|
||||
stream,
|
||||
_currentUserService.UserId!.Value,
|
||||
cancellationToken);
|
||||
|
||||
if (result.Error != DocumentUploadError.None || result.Document is null)
|
||||
{
|
||||
return BadRequest(ToErrorMessage(result.Error));
|
||||
}
|
||||
|
||||
var created = await _documentService.GetByIdAsync(result.Document.Id, cancellationToken) ?? result.Document;
|
||||
return CreatedAtAction(nameof(GetByEntity), new { entityType = created.EntityType, entityId = created.EntityId }, ToResponse(created));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Documents, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<DocumentResponse>> Update(Guid id, UpdateDocumentRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.FileName) || request.FileName.Length > 260)
|
||||
{
|
||||
return BadRequest("FileName ist erforderlich und darf maximal 260 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
var result = await _documentService.UpdateAsync(id, request.Category, request.Description, request.FileName, cancellationToken);
|
||||
|
||||
return result.Error switch
|
||||
{
|
||||
DocumentUpdateError.NotFound => NotFound(),
|
||||
DocumentUpdateError.InvalidCategory => BadRequest("Category ist ungültig."),
|
||||
_ => Ok(ToResponse(result.Document!))
|
||||
};
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/download")]
|
||||
[RequirePermission(ModuleType.Documents, PermissionAction.View)]
|
||||
public async Task<IActionResult> Download(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var document = await _documentService.GetByIdAsync(id, cancellationToken);
|
||||
if (document is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var stream = await _documentService.OpenForDownloadAsync(id, cancellationToken);
|
||||
if (stream is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
await _dispatcher.DispatchAsync(
|
||||
new AuditEvent(
|
||||
_currentUserService.UserId,
|
||||
_currentUserService.Username,
|
||||
_currentUserService.IpAddress,
|
||||
"DocumentDownloaded",
|
||||
$"{{\"documentId\":\"{document.Id}\",\"fileName\":\"{document.FileName}\"}}"),
|
||||
cancellationToken);
|
||||
|
||||
return File(stream, document.ContentType, document.FileName);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Documents, PermissionAction.Delete)]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var deleted = await _documentService.DeleteAsync(id, cancellationToken);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
private static string ToErrorMessage(DocumentUploadError error) => error switch
|
||||
{
|
||||
DocumentUploadError.InvalidCategory => "Category ist ungültig.",
|
||||
DocumentUploadError.FileTooLarge => "Die Datei überschreitet die maximal erlaubte Größe.",
|
||||
DocumentUploadError.ContentTypeNotAllowed => "Dieser Dateityp ist nicht erlaubt.",
|
||||
_ => "Upload fehlgeschlagen."
|
||||
};
|
||||
|
||||
private static DocumentResponse ToResponse(Document document)
|
||||
=> new(
|
||||
document.Id,
|
||||
document.EntityType,
|
||||
document.EntityId,
|
||||
document.Category,
|
||||
document.FileName,
|
||||
document.ContentType,
|
||||
document.SizeBytes,
|
||||
document.Description,
|
||||
document.UploadedByUserId,
|
||||
document.UploadedByUser?.Username,
|
||||
document.CreatedAt);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ public class EmployeesController : ControllerBase
|
||||
{
|
||||
private const string StatusListKey = "EmployeeStatus";
|
||||
private const string EmploymentTypeListKey = "EmploymentType";
|
||||
private const string QualificationListKey = "Qualification";
|
||||
|
||||
private readonly IEmployeeService _employeeService;
|
||||
private readonly IValueListRepository _valueListRepository;
|
||||
@@ -220,6 +221,14 @@ public class EmployeesController : ControllerBase
|
||||
return updated is null ? NotFound() : Ok(ToResponse(updated));
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Employees, PermissionAction.Delete)]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var deleted = await _employeeService.DeleteAsync(id, cancellationToken);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
private static string? ValidateAddressFields(string? street, string? postalCode, string? city, string? country)
|
||||
{
|
||||
if (street is { Length: > 200 })
|
||||
@@ -277,9 +286,13 @@ public class EmployeesController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
if (qualification is { Length: > 500 })
|
||||
if (qualification is not null)
|
||||
{
|
||||
return "Qualification darf maximal 500 Zeichen lang sein.";
|
||||
var allowedQualifications = await _valueListRepository.GetActiveValuesAsync(QualificationListKey, cancellationToken);
|
||||
if (!allowedQualifications.Contains(qualification))
|
||||
{
|
||||
return $"Qualification muss einer der folgenden Werte sein: {string.Join(", ", allowedQualifications)}.";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -17,6 +17,8 @@ public class FacilitiesController : ControllerBase
|
||||
{
|
||||
private const string CrmStatusListKey = "CrmStatus";
|
||||
private const string FacilityTypeListKey = "FacilityType";
|
||||
private const string FollowUpPeriodsListKey = "FollowUpPeriods";
|
||||
private const string BillingIntervalListKey = "BillingInterval";
|
||||
|
||||
private readonly IFacilityService _facilityService;
|
||||
private readonly IValueListRepository _valueListRepository;
|
||||
@@ -34,6 +36,7 @@ public class FacilitiesController : ControllerBase
|
||||
public async Task<ActionResult<PagedResponse<FacilityResponse>>> GetAll(
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] string? crmStatus,
|
||||
[FromQuery] bool followUpDueOnly = false,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -41,7 +44,7 @@ public class FacilitiesController : ControllerBase
|
||||
page = Math.Max(page, 1);
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
|
||||
var (items, totalCount) = await _facilityService.GetPagedAsync(search, crmStatus, page, pageSize, cancellationToken);
|
||||
var (items, totalCount) = await _facilityService.GetPagedAsync(search, crmStatus, followUpDueOnly, page, pageSize, cancellationToken);
|
||||
return Ok(new PagedResponse<FacilityResponse>(items.Select(ToResponse).ToList(), totalCount, page, pageSize));
|
||||
}
|
||||
|
||||
@@ -118,6 +121,12 @@ public class FacilitiesController : ControllerBase
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<FacilityResponse>> Update(Guid id, UpdateFacilityRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await _facilityService.GetByIdAsync(id, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 300)
|
||||
{
|
||||
return BadRequest("Name ist erforderlich und darf maximal 300 Zeichen lang sein.");
|
||||
@@ -128,10 +137,37 @@ public class FacilitiesController : ControllerBase
|
||||
return BadRequest("CrmStatus ist erforderlich und darf maximal 50 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
var allowedCrmStatuses = await _valueListRepository.GetActiveValuesAsync(CrmStatusListKey, cancellationToken);
|
||||
if (!allowedCrmStatuses.Contains(request.CrmStatus))
|
||||
var crmStatusItems = await _valueListRepository.GetItemsAsync(CrmStatusListKey, cancellationToken);
|
||||
var selectedCrmStatusItem = crmStatusItems.FirstOrDefault(i => i.Value == request.CrmStatus);
|
||||
if (selectedCrmStatusItem is null)
|
||||
{
|
||||
return BadRequest($"CrmStatus muss einer der folgenden Werte sein: {string.Join(", ", allowedCrmStatuses)}.");
|
||||
return BadRequest($"CrmStatus muss einer der folgenden Werte sein: {string.Join(", ", crmStatusItems.Select(i => i.Value))}.");
|
||||
}
|
||||
|
||||
var currentCrmStatusItem = crmStatusItems.FirstOrDefault(i => i.Value == existing.CrmStatus);
|
||||
if (currentCrmStatusItem is not null
|
||||
&& !await _valueListRepository.CanTransitionAsync(currentCrmStatusItem.Id, selectedCrmStatusItem.Id, cancellationToken))
|
||||
{
|
||||
return BadRequest("Der Statuswechsel ist nicht zulässig.");
|
||||
}
|
||||
|
||||
DateTime? followUpDueDate = null;
|
||||
if (selectedCrmStatusItem.TriggersFollowUp)
|
||||
{
|
||||
if (existing.CrmStatus == request.CrmStatus)
|
||||
{
|
||||
followUpDueDate = existing.FollowUpDueDate;
|
||||
}
|
||||
else
|
||||
{
|
||||
var allowedFollowUpPeriods = await _valueListRepository.GetActiveValuesAsync(FollowUpPeriodsListKey, cancellationToken);
|
||||
if (request.FollowUpDays is null || !allowedFollowUpPeriods.Contains(request.FollowUpDays.Value.ToString()))
|
||||
{
|
||||
return BadRequest($"FollowUpDays ist bei CrmStatus \"{request.CrmStatus}\" erforderlich und muss einer der folgenden Werte sein: {string.Join(", ", allowedFollowUpPeriods)}.");
|
||||
}
|
||||
|
||||
followUpDueDate = DateTime.UtcNow.AddDays(request.FollowUpDays.Value);
|
||||
}
|
||||
}
|
||||
|
||||
if (request.FacilityType is { Length: > 100 })
|
||||
@@ -165,10 +201,53 @@ public class FacilitiesController : ControllerBase
|
||||
return BadRequest(billingAddressError);
|
||||
}
|
||||
|
||||
if (request.BreakPolicy is { Length: > 1000 })
|
||||
{
|
||||
return BadRequest("BreakPolicy darf maximal 1000 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
if (request.IndividualAgreements is { Length: > 2000 })
|
||||
{
|
||||
return BadRequest("IndividualAgreements darf maximal 2000 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
if (request.BillingInterval is not null)
|
||||
{
|
||||
var allowedBillingIntervals = await _valueListRepository.GetActiveValuesAsync(BillingIntervalListKey, cancellationToken);
|
||||
if (!allowedBillingIntervals.Contains(request.BillingInterval))
|
||||
{
|
||||
return BadRequest($"BillingInterval muss einer der folgenden Werte sein: {string.Join(", ", allowedBillingIntervals)}.");
|
||||
}
|
||||
}
|
||||
|
||||
if (request.BillingRate is < 0
|
||||
|| request.TravelCostRate is < 0
|
||||
|| request.MinimumHours is < 0
|
||||
|| request.NightSurchargePercent is < 0
|
||||
|| request.SaturdaySurchargePercent is < 0
|
||||
|| request.SundaySurchargePercent is < 0
|
||||
|| request.HolidaySurchargePercent is < 0
|
||||
|| request.PaymentTermDays is < 0)
|
||||
{
|
||||
return BadRequest("Konditionswerte dürfen nicht negativ sein.");
|
||||
}
|
||||
|
||||
var updates = new Facility
|
||||
{
|
||||
Name = request.Name,
|
||||
CrmStatus = request.CrmStatus,
|
||||
FollowUpDueDate = followUpDueDate,
|
||||
BillingRate = request.BillingRate,
|
||||
NightSurchargePercent = request.NightSurchargePercent,
|
||||
SaturdaySurchargePercent = request.SaturdaySurchargePercent,
|
||||
SundaySurchargePercent = request.SundaySurchargePercent,
|
||||
HolidaySurchargePercent = request.HolidaySurchargePercent,
|
||||
TravelCostRate = request.TravelCostRate,
|
||||
MinimumHours = request.MinimumHours,
|
||||
BreakPolicy = request.BreakPolicy,
|
||||
BillingInterval = request.BillingInterval,
|
||||
PaymentTermDays = request.PaymentTermDays,
|
||||
IndividualAgreements = request.IndividualAgreements,
|
||||
FacilityType = request.FacilityType,
|
||||
Website = request.Website,
|
||||
Street = request.Street,
|
||||
@@ -185,6 +264,14 @@ public class FacilitiesController : ControllerBase
|
||||
return updated is null ? NotFound() : Ok(ToResponse(updated));
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Delete)]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var deleted = await _facilityService.DeleteAsync(id, cancellationToken);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
private static string? ValidateAddressFields(string? street, string? postalCode, string? city, string? country, string prefix)
|
||||
{
|
||||
if (street is { Length: > 200 })
|
||||
@@ -224,5 +311,17 @@ public class FacilitiesController : ControllerBase
|
||||
facility.BillingStreet,
|
||||
facility.BillingPostalCode,
|
||||
facility.BillingCity,
|
||||
facility.BillingCountry);
|
||||
facility.BillingCountry,
|
||||
facility.FollowUpDueDate,
|
||||
facility.BillingRate,
|
||||
facility.NightSurchargePercent,
|
||||
facility.SaturdaySurchargePercent,
|
||||
facility.SundaySurchargePercent,
|
||||
facility.HolidaySurchargePercent,
|
||||
facility.TravelCostRate,
|
||||
facility.MinimumHours,
|
||||
facility.BreakPolicy,
|
||||
facility.BillingInterval,
|
||||
facility.PaymentTermDays,
|
||||
facility.IndividualAgreements);
|
||||
}
|
||||
|
||||
@@ -100,6 +100,20 @@ public class FacilityContactsController : ControllerBase
|
||||
return updated is null ? NotFound() : Ok(ToResponse(updated));
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Delete)]
|
||||
public async Task<IActionResult> Delete(Guid facilityId, Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await _facilityContactService.GetByIdAsync(id, cancellationToken);
|
||||
if (existing is null || existing.FacilityId != facilityId)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var deleted = await _facilityContactService.DeleteAsync(id, cancellationToken);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
private static string? ValidateRequest(string name, string? role, string? department, string? phoneNumber, string? email, string? notes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name) || name.Length > 200)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OmsorgCore.Api.Contracts;
|
||||
using OmsorgCore.Api.Security;
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Application.Services;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Qualifikationsabhängige Verrechnungssätze sind eine 1:n-Unterressource von Facility (FR-EIN-4) —
|
||||
/// kein eigenständiges Core-Objekt, daher unter /api/facilities/{facilityId}/qualification-rates und
|
||||
/// mit den gleichen Facilities-Rechten gegated statt einem eigenen ModuleType (analog FacilityContact).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/facilities/{facilityId:guid}/qualification-rates")]
|
||||
public class FacilityQualificationRatesController : ControllerBase
|
||||
{
|
||||
private const string QualificationListKey = "Qualification";
|
||||
|
||||
private readonly IFacilityService _facilityService;
|
||||
private readonly IFacilityQualificationRateService _facilityQualificationRateService;
|
||||
private readonly IValueListRepository _valueListRepository;
|
||||
|
||||
public FacilityQualificationRatesController(
|
||||
IFacilityService facilityService,
|
||||
IFacilityQualificationRateService facilityQualificationRateService,
|
||||
IValueListRepository valueListRepository)
|
||||
{
|
||||
_facilityService = facilityService;
|
||||
_facilityQualificationRateService = facilityQualificationRateService;
|
||||
_valueListRepository = valueListRepository;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.View)]
|
||||
public async Task<ActionResult<IReadOnlyList<FacilityQualificationRateResponse>>> GetAll(Guid facilityId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (await _facilityService.GetByIdAsync(facilityId, cancellationToken) is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var rates = await _facilityQualificationRateService.GetByFacilityIdAsync(facilityId, cancellationToken);
|
||||
return Ok(rates.Select(ToResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Create)]
|
||||
public async Task<ActionResult<FacilityQualificationRateResponse>> Create(Guid facilityId, CreateFacilityQualificationRateRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (await _facilityService.GetByIdAsync(facilityId, cancellationToken) is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var fieldError = await ValidateRequestAsync(request.Qualification, request.Rate, cancellationToken);
|
||||
if (fieldError is not null)
|
||||
{
|
||||
return BadRequest(fieldError);
|
||||
}
|
||||
|
||||
var rate = new FacilityQualificationRate
|
||||
{
|
||||
FacilityId = facilityId,
|
||||
Qualification = request.Qualification,
|
||||
Rate = request.Rate
|
||||
};
|
||||
|
||||
var created = await _facilityQualificationRateService.CreateAsync(rate, cancellationToken);
|
||||
return CreatedAtAction(nameof(GetAll), new { facilityId }, ToResponse(created));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<FacilityQualificationRateResponse>> Update(Guid facilityId, Guid id, UpdateFacilityQualificationRateRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await _facilityQualificationRateService.GetByIdAsync(id, cancellationToken);
|
||||
if (existing is null || existing.FacilityId != facilityId)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var fieldError = await ValidateRequestAsync(request.Qualification, request.Rate, cancellationToken);
|
||||
if (fieldError is not null)
|
||||
{
|
||||
return BadRequest(fieldError);
|
||||
}
|
||||
|
||||
var updates = new FacilityQualificationRate
|
||||
{
|
||||
Qualification = request.Qualification,
|
||||
Rate = request.Rate
|
||||
};
|
||||
|
||||
var updated = await _facilityQualificationRateService.UpdateAsync(id, updates, cancellationToken);
|
||||
return updated is null ? NotFound() : Ok(ToResponse(updated));
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Delete)]
|
||||
public async Task<IActionResult> Delete(Guid facilityId, Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await _facilityQualificationRateService.GetByIdAsync(id, cancellationToken);
|
||||
if (existing is null || existing.FacilityId != facilityId)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var deleted = await _facilityQualificationRateService.DeleteAsync(id, cancellationToken);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
private async Task<string?> ValidateRequestAsync(string qualification, decimal rate, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(qualification) || qualification.Length > 200)
|
||||
{
|
||||
return "Qualification ist erforderlich und darf maximal 200 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
var allowedQualifications = await _valueListRepository.GetActiveValuesAsync(QualificationListKey, cancellationToken);
|
||||
if (!allowedQualifications.Contains(qualification))
|
||||
{
|
||||
return $"Qualification muss einer der folgenden Werte sein: {string.Join(", ", allowedQualifications)}.";
|
||||
}
|
||||
|
||||
if (rate < 0)
|
||||
{
|
||||
return "Rate darf nicht negativ sein.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static FacilityQualificationRateResponse ToResponse(FacilityQualificationRate rate)
|
||||
=> new(
|
||||
rate.Id,
|
||||
rate.FacilityId,
|
||||
rate.Qualification,
|
||||
rate.Rate);
|
||||
}
|
||||
@@ -15,6 +15,9 @@ namespace OmsorgCore.Api.Controllers;
|
||||
public class OrdersController : ControllerBase
|
||||
{
|
||||
private const string StatusListKey = "OrderStatus";
|
||||
private const string QualificationListKey = "Qualification";
|
||||
private const string ShiftTypeListKey = "ShiftType";
|
||||
private const string PriorityListKey = "Priority";
|
||||
|
||||
private readonly IOrderService _orderService;
|
||||
private readonly IFacilityContactService _facilityContactService;
|
||||
@@ -39,6 +42,9 @@ public class OrdersController : ControllerBase
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] Guid? statusId,
|
||||
[FromQuery] Guid? facilityId,
|
||||
[FromQuery] string? priority,
|
||||
[FromQuery] string? requiredQualification,
|
||||
[FromQuery] string? shiftType,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -46,7 +52,8 @@ public class OrdersController : ControllerBase
|
||||
page = Math.Max(page, 1);
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
|
||||
var (items, totalCount) = await _orderService.GetPagedAsync(search, statusId, facilityId, page, pageSize, cancellationToken);
|
||||
var (items, totalCount) = await _orderService.GetPagedAsync(
|
||||
search, statusId, facilityId, priority, requiredQualification, shiftType, page, pageSize, cancellationToken);
|
||||
return Ok(new PagedResponse<OrderResponse>(items.Select(ToResponse).ToList(), totalCount, page, pageSize));
|
||||
}
|
||||
|
||||
@@ -62,7 +69,7 @@ public class OrdersController : ControllerBase
|
||||
[RequirePermission(ModuleType.Orders, PermissionAction.Create)]
|
||||
public async Task<ActionResult<OrderResponse>> Create(CreateOrderRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var fieldError = ValidateFields(
|
||||
var fieldError = await ValidateFieldsAsync(
|
||||
request.FacilityId,
|
||||
request.StartDate,
|
||||
request.EndDate,
|
||||
@@ -70,7 +77,8 @@ public class OrdersController : ControllerBase
|
||||
request.ShiftType,
|
||||
request.RequiredHeadcount,
|
||||
request.Conditions,
|
||||
request.Priority);
|
||||
request.Priority,
|
||||
cancellationToken);
|
||||
if (fieldError is not null)
|
||||
{
|
||||
return BadRequest(fieldError);
|
||||
@@ -106,7 +114,7 @@ public class OrdersController : ControllerBase
|
||||
[RequirePermission(ModuleType.Orders, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<OrderResponse>> Update(Guid id, UpdateOrderRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var fieldError = ValidateFields(
|
||||
var fieldError = await ValidateFieldsAsync(
|
||||
request.FacilityId,
|
||||
request.StartDate,
|
||||
request.EndDate,
|
||||
@@ -114,7 +122,8 @@ public class OrdersController : ControllerBase
|
||||
request.ShiftType,
|
||||
request.RequiredHeadcount,
|
||||
request.Conditions,
|
||||
request.Priority);
|
||||
request.Priority,
|
||||
cancellationToken);
|
||||
if (fieldError is not null)
|
||||
{
|
||||
return BadRequest(fieldError);
|
||||
@@ -160,6 +169,14 @@ public class OrdersController : ControllerBase
|
||||
return Ok(ToResponse(result.Order!));
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Orders, PermissionAction.Delete)]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var deleted = await _orderService.DeleteAsync(id, cancellationToken);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
private async Task<string?> ValidateFacilityContactAsync(Guid? facilityContactId, Guid facilityId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (facilityContactId is null)
|
||||
@@ -176,7 +193,7 @@ public class OrdersController : ControllerBase
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ValidateFields(
|
||||
private async Task<string?> ValidateFieldsAsync(
|
||||
Guid facilityId,
|
||||
DateOnly startDate,
|
||||
DateOnly? endDate,
|
||||
@@ -184,7 +201,8 @@ public class OrdersController : ControllerBase
|
||||
string? shiftType,
|
||||
int requiredHeadcount,
|
||||
string? conditions,
|
||||
string priority)
|
||||
string priority,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (facilityId == Guid.Empty)
|
||||
{
|
||||
@@ -206,19 +224,33 @@ public class OrdersController : ControllerBase
|
||||
return "RequiredHeadcount muss mindestens 1 sein.";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(priority) || priority.Length > 50)
|
||||
if (string.IsNullOrWhiteSpace(priority))
|
||||
{
|
||||
return "Priority ist erforderlich und darf maximal 50 Zeichen lang sein.";
|
||||
return "Priority ist erforderlich.";
|
||||
}
|
||||
|
||||
if (requiredQualification is { Length: > 200 })
|
||||
var priorityItems = await _valueListService.GetItemsAsync(PriorityListKey, cancellationToken);
|
||||
if (!priorityItems.Any(i => i.Value == priority))
|
||||
{
|
||||
return "RequiredQualification darf maximal 200 Zeichen lang sein.";
|
||||
return $"Priority muss einer der folgenden Werte sein: {string.Join(", ", priorityItems.Select(i => i.Value))}.";
|
||||
}
|
||||
|
||||
if (shiftType is { Length: > 100 })
|
||||
if (requiredQualification is not null)
|
||||
{
|
||||
return "ShiftType darf maximal 100 Zeichen lang sein.";
|
||||
var qualificationItems = await _valueListService.GetItemsAsync(QualificationListKey, cancellationToken);
|
||||
if (!qualificationItems.Any(i => i.Value == requiredQualification))
|
||||
{
|
||||
return $"RequiredQualification muss einer der folgenden Werte sein: {string.Join(", ", qualificationItems.Select(i => i.Value))}.";
|
||||
}
|
||||
}
|
||||
|
||||
if (shiftType is not null)
|
||||
{
|
||||
var shiftTypeItems = await _valueListService.GetItemsAsync(ShiftTypeListKey, cancellationToken);
|
||||
if (!shiftTypeItems.Any(i => i.Value == shiftType))
|
||||
{
|
||||
return $"ShiftType muss einer der folgenden Werte sein: {string.Join(", ", shiftTypeItems.Select(i => i.Value))}.";
|
||||
}
|
||||
}
|
||||
|
||||
if (conditions is { Length: > 500 })
|
||||
|
||||
@@ -60,7 +60,7 @@ public class RolesController : ControllerBase
|
||||
}
|
||||
|
||||
var permissions = role.RolePermissions
|
||||
.Select(rp => new PermissionDto(rp.Module, rp.Action))
|
||||
.Select(rp => new PermissionDto(rp.Module, rp.Action, rp.Scope))
|
||||
.ToList();
|
||||
|
||||
return Ok(new RolePermissionsResponse(role.Id, role.Name, permissions));
|
||||
@@ -70,7 +70,7 @@ public class RolesController : ControllerBase
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
public async Task<IActionResult> UpdatePermissions(Guid id, UpdateRolePermissionsRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsed = request.Permissions.Select(dto => (dto.Module, dto.Action)).ToList();
|
||||
var parsed = request.Permissions.Select(dto => (dto.Module, dto.Action, dto.Scope)).ToList();
|
||||
|
||||
var result = await _roleService.UpdatePermissionsAsync(id, parsed, cancellationToken);
|
||||
if (!result.Success)
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OmsorgCore.Api.Contracts;
|
||||
using OmsorgCore.Api.Security;
|
||||
using OmsorgCore.Application.Services;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Strukturierte Zeiterfassung pro Schicht (FR-ZE-1) mit Statuspipeline
|
||||
/// Entwurf -> Eingereicht -> Prüfung -> Rückfrage -> Freigegeben -> Abgerechnet (FR-ZE-2). Außendienst
|
||||
/// darf nur Create/View/Edit mit PermissionScope.Own (eigene Einträge, EmployeeId wird serverseitig
|
||||
/// aus dem JWT gesetzt, siehe TimeEntryService.CreateAsync) und die Selbst-Einreichungs-Kante über
|
||||
/// <see cref="Submit"/> auslösen; Büro-Rollen entscheiden über <see cref="Decide"/> (Approve).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/time-entries")]
|
||||
public class TimeEntriesController : ControllerBase
|
||||
{
|
||||
private readonly ITimeEntryService _timeEntryService;
|
||||
|
||||
public TimeEntriesController(ITimeEntryService timeEntryService)
|
||||
{
|
||||
_timeEntryService = timeEntryService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[RequirePermission(ModuleType.TimeEntries, PermissionAction.View)]
|
||||
public async Task<ActionResult<PagedResponse<TimeEntryResponse>>> GetAll(
|
||||
[FromQuery] Guid? statusId,
|
||||
[FromQuery] Guid? employeeId,
|
||||
[FromQuery] Guid? orderId,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
page = Math.Max(page, 1);
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
|
||||
var (items, totalCount) = await _timeEntryService.GetPagedAsync(statusId, employeeId, orderId, page, pageSize, cancellationToken);
|
||||
return Ok(new PagedResponse<TimeEntryResponse>(items.Select(ToResponse).ToList(), totalCount, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[RequirePermission(ModuleType.TimeEntries, PermissionAction.View)]
|
||||
public async Task<ActionResult<TimeEntryResponse>> GetById(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var timeEntry = await _timeEntryService.GetByIdAsync(id, cancellationToken);
|
||||
return timeEntry is null ? NotFound() : Ok(ToResponse(timeEntry));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[RequirePermission(ModuleType.TimeEntries, PermissionAction.Create)]
|
||||
public async Task<ActionResult<TimeEntryResponse>> Create(CreateTimeEntryRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var fieldError = ValidateFields(request.Start, request.End, request.NightHours, request.SaturdayHours, request.SundayHours, request.HolidayHours);
|
||||
if (fieldError is not null)
|
||||
{
|
||||
return BadRequest(fieldError);
|
||||
}
|
||||
|
||||
var timeEntry = new TimeEntry
|
||||
{
|
||||
OrderId = request.OrderId,
|
||||
Date = request.Date,
|
||||
Start = request.Start,
|
||||
End = request.End,
|
||||
BreakDuration = request.BreakDuration,
|
||||
NightHours = request.NightHours,
|
||||
SaturdayHours = request.SaturdayHours,
|
||||
SundayHours = request.SundayHours,
|
||||
HolidayHours = request.HolidayHours
|
||||
};
|
||||
|
||||
var created = await _timeEntryService.CreateAsync(timeEntry, cancellationToken);
|
||||
if (created is null)
|
||||
{
|
||||
return BadRequest("Zeiterfassung konnte nicht angelegt werden - entweder kein verknüpfter Mitarbeiter oder der Auftrag existiert nicht.");
|
||||
}
|
||||
|
||||
var reloaded = await _timeEntryService.GetByIdAsync(created.Id, cancellationToken);
|
||||
return CreatedAtAction(nameof(GetById), new { id = created.Id }, ToResponse(reloaded!));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[RequirePermission(ModuleType.TimeEntries, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<TimeEntryResponse>> Update(Guid id, UpdateTimeEntryRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var fieldError = ValidateFields(request.Start, request.End, request.NightHours, request.SaturdayHours, request.SundayHours, request.HolidayHours);
|
||||
if (fieldError is not null)
|
||||
{
|
||||
return BadRequest(fieldError);
|
||||
}
|
||||
|
||||
var updates = new TimeEntry
|
||||
{
|
||||
OrderId = request.OrderId,
|
||||
Date = request.Date,
|
||||
Start = request.Start,
|
||||
End = request.End,
|
||||
BreakDuration = request.BreakDuration,
|
||||
NightHours = request.NightHours,
|
||||
SaturdayHours = request.SaturdayHours,
|
||||
SundayHours = request.SundayHours,
|
||||
HolidayHours = request.HolidayHours
|
||||
};
|
||||
|
||||
var result = await _timeEntryService.UpdateAsync(id, updates, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
return result.FailureReason switch
|
||||
{
|
||||
UpdateTimeEntryFailureReason.NotEditable => BadRequest("Die Zeiterfassung wurde bereits zur Prüfung übergeben und kann nicht mehr bearbeitet werden."),
|
||||
UpdateTimeEntryFailureReason.OrderNotFound => BadRequest("Der angegebene Auftrag existiert nicht."),
|
||||
_ => NotFound()
|
||||
};
|
||||
}
|
||||
|
||||
var reloaded = await _timeEntryService.GetByIdAsync(result.TimeEntry!.Id, cancellationToken);
|
||||
return Ok(ToResponse(reloaded!));
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/submit")]
|
||||
[RequirePermission(ModuleType.TimeEntries, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<TimeEntryResponse>> Submit(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _timeEntryService.SubmitAsync(id, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
return result.FailureReason == SubmitTimeEntryFailureReason.NoSelfServiceTransition
|
||||
? BadRequest("Aus dem aktuellen Status ist keine Einreichung möglich.")
|
||||
: NotFound();
|
||||
}
|
||||
|
||||
var reloaded = await _timeEntryService.GetByIdAsync(result.TimeEntry!.Id, cancellationToken);
|
||||
return Ok(ToResponse(reloaded!));
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/decision")]
|
||||
[RequirePermission(ModuleType.TimeEntries, PermissionAction.Approve)]
|
||||
public async Task<ActionResult<TimeEntryResponse>> Decide(Guid id, TimeEntryDecisionRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.AdminNote is { Length: > 500 })
|
||||
{
|
||||
return BadRequest("AdminNote darf maximal 500 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
var result = await _timeEntryService.DecideAsync(id, request.StatusId, request.AdminNote, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
return result.FailureReason == DecideTimeEntryFailureReason.InvalidStatusTransition
|
||||
? BadRequest("Der Statuswechsel ist nicht zulässig.")
|
||||
: NotFound();
|
||||
}
|
||||
|
||||
var reloaded = await _timeEntryService.GetByIdAsync(result.TimeEntry!.Id, cancellationToken);
|
||||
return Ok(ToResponse(reloaded!));
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[RequirePermission(ModuleType.TimeEntries, PermissionAction.Delete)]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var deleted = await _timeEntryService.DeleteAsync(id, cancellationToken);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
// End < Start ist bewusst erlaubt (Nachtschichten, die über Mitternacht gehen) - keine
|
||||
// Start/Ende-Reihenfolge-Prüfung wie bei Absence.StartDate/EndDate.
|
||||
private static string? ValidateFields(TimeOnly start, TimeOnly end, decimal nightHours, decimal saturdayHours, decimal sundayHours, decimal holidayHours)
|
||||
{
|
||||
if (nightHours < 0 || saturdayHours < 0 || sundayHours < 0 || holidayHours < 0)
|
||||
{
|
||||
return "Zuschlagsstunden dürfen nicht negativ sein.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static TimeEntryResponse ToResponse(TimeEntry timeEntry)
|
||||
=> new(
|
||||
timeEntry.Id,
|
||||
timeEntry.EmployeeId,
|
||||
timeEntry.Employee is null ? string.Empty : $"{timeEntry.Employee.FirstName} {timeEntry.Employee.LastName}",
|
||||
timeEntry.OrderId,
|
||||
timeEntry.Order?.FacilityId ?? Guid.Empty,
|
||||
timeEntry.Order?.Facility?.Name ?? string.Empty,
|
||||
timeEntry.Date,
|
||||
timeEntry.Start,
|
||||
timeEntry.End,
|
||||
timeEntry.BreakDuration,
|
||||
timeEntry.NightHours,
|
||||
timeEntry.SaturdayHours,
|
||||
timeEntry.SundayHours,
|
||||
timeEntry.HolidayHours,
|
||||
timeEntry.StatusId,
|
||||
timeEntry.Status?.Value ?? string.Empty,
|
||||
timeEntry.Status?.IsEditableByOwner ?? false,
|
||||
timeEntry.AdminNote,
|
||||
timeEntry.CreatedAt);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OmsorgCore.Api.Contracts;
|
||||
using OmsorgCore.Api.Security;
|
||||
using OmsorgCore.Application.Services;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Papierkorb: listet und stellt soft-gelöschte Datensätze der 5 Core-Objekte mit vollem CRUD
|
||||
/// wieder her. Reine API-Gruppierung für die Papierkorb-Seite in omsorgapp — kein eigener
|
||||
/// ModuleType/eigenes Recht, jede Route ist über das Recht des jeweiligen Objekts gegated
|
||||
/// (z. B. ModuleType.Employees + PermissionAction.Recover), analog zu den bestehenden
|
||||
/// Delete-Endpoints in EmployeesController/FacilitiesController/etc.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/trash")]
|
||||
public class TrashController : ControllerBase
|
||||
{
|
||||
private readonly IEmployeeService _employeeService;
|
||||
private readonly IFacilityService _facilityService;
|
||||
private readonly IContractService _contractService;
|
||||
private readonly IOrderService _orderService;
|
||||
private readonly IFacilityContactService _facilityContactService;
|
||||
private readonly IFacilityQualificationRateService _facilityQualificationRateService;
|
||||
private readonly IAbsenceService _absenceService;
|
||||
private readonly ITimeEntryService _timeEntryService;
|
||||
|
||||
public TrashController(
|
||||
IEmployeeService employeeService,
|
||||
IFacilityService facilityService,
|
||||
IContractService contractService,
|
||||
IOrderService orderService,
|
||||
IFacilityContactService facilityContactService,
|
||||
IFacilityQualificationRateService facilityQualificationRateService,
|
||||
IAbsenceService absenceService,
|
||||
ITimeEntryService timeEntryService)
|
||||
{
|
||||
_employeeService = employeeService;
|
||||
_facilityService = facilityService;
|
||||
_contractService = contractService;
|
||||
_orderService = orderService;
|
||||
_facilityContactService = facilityContactService;
|
||||
_facilityQualificationRateService = facilityQualificationRateService;
|
||||
_absenceService = absenceService;
|
||||
_timeEntryService = timeEntryService;
|
||||
}
|
||||
|
||||
[HttpGet("employees")]
|
||||
[RequirePermission(ModuleType.Employees, PermissionAction.Recover)]
|
||||
public async Task<ActionResult<IReadOnlyList<TrashEmployeeResponse>>> GetDeletedEmployees([FromQuery] string? search, CancellationToken cancellationToken)
|
||||
{
|
||||
var employees = await _employeeService.GetDeletedAsync(search, cancellationToken);
|
||||
return Ok(employees.Select(e => new TrashEmployeeResponse(e.Id, e.FirstName, e.LastName, e.DeletedAt)).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("employees/{id:guid}/restore")]
|
||||
[RequirePermission(ModuleType.Employees, PermissionAction.Recover)]
|
||||
public async Task<IActionResult> RestoreEmployee(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var restored = await _employeeService.RestoreAsync(id, cancellationToken);
|
||||
return restored ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
[HttpGet("facilities")]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Recover)]
|
||||
public async Task<ActionResult<IReadOnlyList<TrashFacilityResponse>>> GetDeletedFacilities([FromQuery] string? search, CancellationToken cancellationToken)
|
||||
{
|
||||
var facilities = await _facilityService.GetDeletedAsync(search, cancellationToken);
|
||||
return Ok(facilities.Select(f => new TrashFacilityResponse(f.Id, f.Name, f.DeletedAt)).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("facilities/{id:guid}/restore")]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Recover)]
|
||||
public async Task<IActionResult> RestoreFacility(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var restored = await _facilityService.RestoreAsync(id, cancellationToken);
|
||||
return restored ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
[HttpGet("contracts")]
|
||||
[RequirePermission(ModuleType.Contracts, PermissionAction.Recover)]
|
||||
public async Task<ActionResult<IReadOnlyList<TrashContractResponse>>> GetDeletedContracts([FromQuery] string? search, CancellationToken cancellationToken)
|
||||
{
|
||||
var contracts = await _contractService.GetDeletedAsync(search, cancellationToken);
|
||||
return Ok(contracts.Select(c => new TrashContractResponse(c.Id, c.ContractType, c.DeletedAt)).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("contracts/{id:guid}/restore")]
|
||||
[RequirePermission(ModuleType.Contracts, PermissionAction.Recover)]
|
||||
public async Task<IActionResult> RestoreContract(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var restored = await _contractService.RestoreAsync(id, cancellationToken);
|
||||
return restored ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
[HttpGet("orders")]
|
||||
[RequirePermission(ModuleType.Orders, PermissionAction.Recover)]
|
||||
public async Task<ActionResult<IReadOnlyList<TrashOrderResponse>>> GetDeletedOrders([FromQuery] string? search, CancellationToken cancellationToken)
|
||||
{
|
||||
var orders = await _orderService.GetDeletedAsync(search, cancellationToken);
|
||||
return Ok(orders.Select(o => new TrashOrderResponse(o.Id, o.RequiredQualification, o.DeletedAt)).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("orders/{id:guid}/restore")]
|
||||
[RequirePermission(ModuleType.Orders, PermissionAction.Recover)]
|
||||
public async Task<IActionResult> RestoreOrder(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var restored = await _orderService.RestoreAsync(id, cancellationToken);
|
||||
return restored ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
[HttpGet("facility-contacts")]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Recover)]
|
||||
public async Task<ActionResult<IReadOnlyList<TrashFacilityContactResponse>>> GetDeletedFacilityContacts([FromQuery] string? search, CancellationToken cancellationToken)
|
||||
{
|
||||
var contacts = await _facilityContactService.GetDeletedAsync(search, cancellationToken);
|
||||
return Ok(contacts.Select(c => new TrashFacilityContactResponse(c.Id, c.FacilityId, c.Name, c.DeletedAt)).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("facility-contacts/{id:guid}/restore")]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Recover)]
|
||||
public async Task<IActionResult> RestoreFacilityContact(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var restored = await _facilityContactService.RestoreAsync(id, cancellationToken);
|
||||
return restored ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
[HttpGet("facility-qualification-rates")]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Recover)]
|
||||
public async Task<ActionResult<IReadOnlyList<TrashFacilityQualificationRateResponse>>> GetDeletedFacilityQualificationRates([FromQuery] string? search, CancellationToken cancellationToken)
|
||||
{
|
||||
var rates = await _facilityQualificationRateService.GetDeletedAsync(search, cancellationToken);
|
||||
return Ok(rates.Select(r => new TrashFacilityQualificationRateResponse(r.Id, r.FacilityId, r.Qualification, r.DeletedAt)).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("facility-qualification-rates/{id:guid}/restore")]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Recover)]
|
||||
public async Task<IActionResult> RestoreFacilityQualificationRate(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var restored = await _facilityQualificationRateService.RestoreAsync(id, cancellationToken);
|
||||
return restored ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
[HttpGet("absences")]
|
||||
[RequirePermission(ModuleType.Absences, PermissionAction.Recover)]
|
||||
public async Task<ActionResult<IReadOnlyList<TrashAbsenceResponse>>> GetDeletedAbsences([FromQuery] string? search, CancellationToken cancellationToken)
|
||||
{
|
||||
var absences = await _absenceService.GetDeletedAsync(search, cancellationToken);
|
||||
return Ok(absences.Select(a => new TrashAbsenceResponse(a.Id, a.Type, a.DeletedAt)).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("absences/{id:guid}/restore")]
|
||||
[RequirePermission(ModuleType.Absences, PermissionAction.Recover)]
|
||||
public async Task<IActionResult> RestoreAbsence(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var restored = await _absenceService.RestoreAsync(id, cancellationToken);
|
||||
return restored ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
[HttpGet("time-entries")]
|
||||
[RequirePermission(ModuleType.TimeEntries, PermissionAction.Recover)]
|
||||
public async Task<ActionResult<IReadOnlyList<TrashTimeEntryResponse>>> GetDeletedTimeEntries([FromQuery] string? search, CancellationToken cancellationToken)
|
||||
{
|
||||
var timeEntries = await _timeEntryService.GetDeletedAsync(search, cancellationToken);
|
||||
return Ok(timeEntries.Select(t => new TrashTimeEntryResponse(t.Id, t.Date, t.DeletedAt)).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("time-entries/{id:guid}/restore")]
|
||||
[RequirePermission(ModuleType.TimeEntries, PermissionAction.Recover)]
|
||||
public async Task<IActionResult> RestoreTimeEntry(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var restored = await _timeEntryService.RestoreAsync(id, cancellationToken);
|
||||
return restored ? NoContent() : NotFound();
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ public class UsersController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.View)]
|
||||
[RequirePermission(ModuleType.Users, PermissionAction.View)]
|
||||
public async Task<ActionResult<IReadOnlyList<UserResponse>>> GetAll(CancellationToken cancellationToken)
|
||||
{
|
||||
var users = await _userService.GetAllAsync(cancellationToken);
|
||||
@@ -40,7 +40,7 @@ public class UsersController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Create)]
|
||||
[RequirePermission(ModuleType.Users, PermissionAction.Create)]
|
||||
public async Task<ActionResult<UserResponse>> Create(CreateUserRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Username) || request.Username.Length > 100)
|
||||
@@ -86,7 +86,7 @@ public class UsersController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/reset-password")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
[RequirePermission(ModuleType.Users, PermissionAction.Edit)]
|
||||
public async Task<IActionResult> ResetPassword(Guid id, ResetUserPasswordRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var (parseError, mode, pinValidity) = ParseModeAndPinValidity(request.Mode, request.PinValidityDays, request.InitialPassword);
|
||||
@@ -120,7 +120,7 @@ public class UsersController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
[RequirePermission(ModuleType.Users, PermissionAction.Edit)]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateUserRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _userService.UpdateAsync(id, request.RoleId, request.IsActive, cancellationToken);
|
||||
@@ -177,7 +177,7 @@ public class UsersController : ControllerBase
|
||||
}
|
||||
|
||||
return Ok(overrides.Select(o => new UserPermissionOverrideResponse(
|
||||
o.Id, o.Module, o.Action, o.Effect)).ToList());
|
||||
o.Id, o.Module, o.Action, o.Effect, o.Scope)).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/permission-overrides")]
|
||||
@@ -185,7 +185,7 @@ public class UsersController : ControllerBase
|
||||
public async Task<ActionResult<UserPermissionOverrideResponse>> AddPermissionOverride(
|
||||
Guid id, AddUserPermissionOverrideRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _userService.AddPermissionOverrideAsync(id, request.Module, request.Action, request.Effect, cancellationToken);
|
||||
var result = await _userService.AddPermissionOverrideAsync(id, request.Module, request.Action, request.Effect, request.Scope, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
return result.FailureReason switch
|
||||
@@ -196,7 +196,7 @@ public class UsersController : ControllerBase
|
||||
}
|
||||
|
||||
var o = result.Override!;
|
||||
return Ok(new UserPermissionOverrideResponse(o.Id, o.Module, o.Action, o.Effect));
|
||||
return Ok(new UserPermissionOverrideResponse(o.Id, o.Module, o.Action, o.Effect, o.Scope));
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}/permission-overrides/{overrideId:guid}")]
|
||||
|
||||
@@ -12,8 +12,8 @@ namespace OmsorgCore.Api.Controllers;
|
||||
/// Verwaltet die konfigurierbaren Auswahllisten (Mitarbeiterstatus, Beschäftigungsart, CRM-Status,
|
||||
/// Einrichtungstyp, Vertragstyp/-status, Auftragsstatus) — siehe omsorgCore/CLAUDE.md, Abschnitt
|
||||
/// "Konfigurierbare Auswahllisten". Lesen ist für jeden eingeloggten Nutzer erlaubt (die aufrufenden
|
||||
/// Formulare gehören zu unterschiedlichen Modulen), Schreiben ist eine Admin-Funktion und läuft über
|
||||
/// dasselbe Recht wie die übrige "Einstellungen"-Seite (<see cref="ModuleType.UserManagement"/>).
|
||||
/// Formulare gehören zu unterschiedlichen Modulen), Schreiben ist eine eigene Admin-Funktion
|
||||
/// (<see cref="ModuleType.Configuration"/>), getrennt von der Benutzer-/Rechteverwaltung.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
@@ -42,7 +42,7 @@ public class ValueListsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("{key}/items")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
[RequirePermission(ModuleType.Configuration, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<ValueListItemResponse>> CreateItem(string key, CreateValueListItemRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Value) || request.Value.Length > 100)
|
||||
@@ -53,7 +53,7 @@ public class ValueListsController : ControllerBase
|
||||
try
|
||||
{
|
||||
var item = await _valueListService.CreateItemAsync(
|
||||
key, request.Value, request.SortOrder, request.IsDefault, request.IsInitial, request.IsTerminal, cancellationToken);
|
||||
key, request.Value, request.SortOrder, request.IsDefault, request.IsInitial, request.IsTerminal, request.TriggersFollowUp, cancellationToken);
|
||||
return Ok(ToResponse(item));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
@@ -63,7 +63,7 @@ public class ValueListsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{key}/items/{id:guid}")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
[RequirePermission(ModuleType.Configuration, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<ValueListItemResponse>> UpdateItem(string key, Guid id, UpdateValueListItemRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Value) || request.Value.Length > 100)
|
||||
@@ -72,12 +72,12 @@ public class ValueListsController : ControllerBase
|
||||
}
|
||||
|
||||
var item = await _valueListService.UpdateItemAsync(
|
||||
id, request.Value, request.SortOrder, request.IsDefault, request.IsInitial, request.IsTerminal, cancellationToken);
|
||||
id, request.Value, request.SortOrder, request.IsDefault, request.IsInitial, request.IsTerminal, request.TriggersFollowUp, cancellationToken);
|
||||
return item is null ? NotFound() : Ok(ToResponse(item));
|
||||
}
|
||||
|
||||
[HttpDelete("{key}/items/{id:guid}")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
[RequirePermission(ModuleType.Configuration, PermissionAction.Edit)]
|
||||
public async Task<IActionResult> DeleteItem(string key, Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _valueListService.DeleteItemAsync(id, cancellationToken);
|
||||
@@ -97,7 +97,7 @@ public class ValueListsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("{key}/items/{id:guid}/usages")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
[RequirePermission(ModuleType.Configuration, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<IReadOnlyList<ValueListUsageResponse>>> GetUsages(string key, Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var usages = await _valueListService.GetUsagesAsync(id, cancellationToken);
|
||||
@@ -108,11 +108,11 @@ public class ValueListsController : ControllerBase
|
||||
public async Task<ActionResult<IReadOnlyList<ValueListTransitionResponse>>> GetTransitions(string key, CancellationToken cancellationToken)
|
||||
{
|
||||
var transitions = await _valueListService.GetTransitionsAsync(key, cancellationToken);
|
||||
return Ok(transitions.Select(t => new ValueListTransitionResponse(t.Id, t.FromItemId, t.ToItemId)).ToList());
|
||||
return Ok(transitions.Select(t => new ValueListTransitionResponse(t.Id, t.FromItemId, t.ToItemId, t.RequiresApproval)).ToList());
|
||||
}
|
||||
|
||||
[HttpPut("{key}/transitions")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
[RequirePermission(ModuleType.Configuration, PermissionAction.Edit)]
|
||||
public async Task<IActionResult> ReplaceTransitions(string key, List<ValueListTransitionRequest> request, CancellationToken cancellationToken)
|
||||
{
|
||||
await _valueListService.ReplaceTransitionsAsync(key, request.Select(t => (t.FromItemId, t.ToItemId)), cancellationToken);
|
||||
@@ -120,5 +120,5 @@ public class ValueListsController : ControllerBase
|
||||
}
|
||||
|
||||
private static ValueListItemResponse ToResponse(ValueListItem item)
|
||||
=> new(item.Id, item.Value, item.SortOrder, item.IsDefault, item.IsInitial, item.IsTerminal);
|
||||
=> new(item.Id, item.Value, item.SortOrder, item.IsDefault, item.IsInitial, item.IsTerminal, item.TriggersFollowUp);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ builder.Services.AddSwaggerGen(options =>
|
||||
// Schichten verdrahten: Domain kennt niemanden, Application kennt nur Domain,
|
||||
// Infrastructure implementiert Application-Interfaces, Engine ist die Event-Schicht
|
||||
// auf denselben Daten, Api verdrahtet alles nur hier.
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
builder.Services.AddEngine();
|
||||
@@ -58,6 +59,18 @@ builder.Services.AddEmail(builder.Configuration);
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
||||
|
||||
// Nötig, seit omsorgapp als Browser-SPA statt Electron läuft: der Refresh-Token geht per
|
||||
// HttpOnly-Cookie, dafür muss der Browser die Cross-Origin-Antwort mit Credentials akzeptieren.
|
||||
var corsOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? Array.Empty<string>();
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy("Frontend", policy => policy
|
||||
.WithOrigins(corsOrigins)
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()
|
||||
.AllowCredentials());
|
||||
});
|
||||
|
||||
var jwtOptions = builder.Configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>() ?? new JwtOptions();
|
||||
|
||||
builder.Services
|
||||
@@ -183,6 +196,8 @@ if (app.Environment.IsDevelopment())
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.UseCors("Frontend");
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
|
||||
@@ -30,4 +30,13 @@ public class CurrentUserService : ICurrentUserService
|
||||
public string? RoleName => _httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.Role);
|
||||
|
||||
public string? IpAddress => _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
|
||||
|
||||
public Guid? EmployeeId
|
||||
{
|
||||
get
|
||||
{
|
||||
var value = _httpContextAccessor.HttpContext?.User.FindFirstValue("employeeId");
|
||||
return Guid.TryParse(value, out var id) ? id : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Cors": {
|
||||
"AllowedOrigins": []
|
||||
},
|
||||
"Jwt": {
|
||||
"Issuer": "OmsorgCore",
|
||||
"Audience": "OmsorgClients",
|
||||
@@ -21,6 +24,11 @@
|
||||
"MaxLoginFailures": 5,
|
||||
"LoginLockoutMinutes": 10
|
||||
},
|
||||
"Storage": {
|
||||
"DocumentsRootPath": "App_Data/documents",
|
||||
"MaxDocumentSizeBytes": 20971520,
|
||||
"AllowedDocumentContentTypes": "application/pdf,image/jpeg,image/png"
|
||||
},
|
||||
"Email": {
|
||||
"PinExpiryMinutes": 5,
|
||||
"MaxAttempts": 3,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface IAbsenceRepository
|
||||
{
|
||||
Task<Absence?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<(IReadOnlyList<Absence> Items, int TotalCount)> GetPagedAsync(
|
||||
string? status,
|
||||
string? type,
|
||||
Guid? employeeId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default,
|
||||
Guid? restrictToEmployeeId = null);
|
||||
Task AddAsync(Absence absence, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(Absence absence, CancellationToken cancellationToken = default);
|
||||
Task<bool> SoftDeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Absence>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -16,5 +16,8 @@ public interface IContractRepository
|
||||
CancellationToken cancellationToken = default);
|
||||
Task AddAsync(Contract contract, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(Contract contract, CancellationToken cancellationToken = default);
|
||||
Task<bool> SoftDeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Contract>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -12,4 +12,12 @@ public interface ICurrentUserService
|
||||
string? Username { get; }
|
||||
string? RoleName { get; }
|
||||
string? IpAddress { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Verknüpfte Mitarbeiter-Id (<see cref="Domain.Entities.User.EmployeeId"/>) dieses Users, falls
|
||||
/// vorhanden - aus dem JWT-Claim "employeeId", nicht per DB-Read. Wird nach Verknüpfen/Lösen
|
||||
/// erst mit dem nächsten Token-Refresh aktuell (Staleness-Fenster = Access-Token-Laufzeit).
|
||||
/// Anker für Own-Scope-Datenfilterung, siehe <see cref="IPermissionService.GetScopeAsync"/>.
|
||||
/// </summary>
|
||||
Guid? EmployeeId { get; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface IDocumentRepository
|
||||
{
|
||||
Task<Document?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Document>> GetByEntityAsync(string entityType, Guid entityId, CancellationToken cancellationToken = default);
|
||||
Task AddAsync(Document document, CancellationToken cancellationToken = default);
|
||||
Task<bool> SoftDeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Document>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Port für die physische Dateiablage eines <see cref="Domain.Entities.Document"/> — getrennt vom
|
||||
/// Repository (das nur Metadaten in der DB verwaltet), weil die Bytes bewusst NICHT als Blob in
|
||||
/// Postgres liegen, sondern auf dem Dateisystem (siehe omsorgCore/CLAUDE.md, Dokumentenarchiv).
|
||||
/// </summary>
|
||||
public interface IDocumentStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Schreibt den Inhalt auf die Storage und liefert den relativen <c>StorageKey</c>, unter dem
|
||||
/// er später wiedergefunden wird.
|
||||
/// </summary>
|
||||
Task<string> SaveAsync(string entityType, Guid entityId, Guid documentId, string originalFileName, Stream content, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<Stream> OpenReadAsync(string storageKey, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Einzige Stelle, die entscheidet, ob ein Upload die konfigurierte Größen-/Dateityp-Grenze
|
||||
/// einhält (Storage:MaxDocumentSizeBytes/Storage:AllowedDocumentContentTypes) - analog zu
|
||||
/// <see cref="IPasswordPolicy"/>, damit die Regel nicht mehrfach im Controller/Service dupliziert wird.
|
||||
/// </summary>
|
||||
public interface IDocumentUploadPolicy
|
||||
{
|
||||
long MaxSizeBytes { get; }
|
||||
|
||||
bool IsSizeAllowed(long sizeBytes);
|
||||
bool IsContentTypeAllowed(string? contentType);
|
||||
}
|
||||
@@ -6,14 +6,22 @@ public interface IEmployeeRepository
|
||||
{
|
||||
Task<Employee?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Employee>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
/// <param name="restrictToEmployeeId">
|
||||
/// Own-Scope-Filterung (siehe IPermissionService.GetScopeAsync): liefert bei Angabe nur den
|
||||
/// Datensatz mit dieser Id, unabhängig von <paramref name="search"/>/<paramref name="status"/>/<paramref name="employmentType"/>.
|
||||
/// </param>
|
||||
Task<(IReadOnlyList<Employee> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
string? status,
|
||||
string? employmentType,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
CancellationToken cancellationToken = default,
|
||||
Guid? restrictToEmployeeId = null);
|
||||
Task AddAsync(Employee employee, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(Employee employee, CancellationToken cancellationToken = default);
|
||||
Task<bool> SoftDeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Employee>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -8,5 +8,8 @@ public interface IFacilityContactRepository
|
||||
Task<IReadOnlyList<FacilityContact>> GetByFacilityIdAsync(Guid facilityId, CancellationToken cancellationToken = default);
|
||||
Task AddAsync(FacilityContact contact, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(FacilityContact contact, CancellationToken cancellationToken = default);
|
||||
Task<bool> SoftDeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<FacilityContact>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface IFacilityQualificationRateRepository
|
||||
{
|
||||
Task<FacilityQualificationRate?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<FacilityQualificationRate>> GetByFacilityIdAsync(Guid facilityId, CancellationToken cancellationToken = default);
|
||||
Task AddAsync(FacilityQualificationRate rate, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(FacilityQualificationRate rate, CancellationToken cancellationToken = default);
|
||||
Task<bool> SoftDeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<FacilityQualificationRate>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -9,10 +9,14 @@ public interface IFacilityRepository
|
||||
Task<(IReadOnlyList<Facility> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
string? crmStatus,
|
||||
bool followUpDueOnly,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task AddAsync(Facility facility, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(Facility facility, CancellationToken cancellationToken = default);
|
||||
Task<bool> SoftDeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Facility>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -10,10 +10,16 @@ public interface IOrderRepository
|
||||
string? search,
|
||||
Guid? statusId,
|
||||
Guid? facilityId,
|
||||
string? priority,
|
||||
string? requiredQualification,
|
||||
string? shiftType,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task AddAsync(Order order, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(Order order, CancellationToken cancellationToken = default);
|
||||
Task<bool> SoftDeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Order>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,20 @@ public interface IPermissionService
|
||||
{
|
||||
Task<bool> HasPermissionAsync(Guid userId, ModuleType module, PermissionAction action, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Liefert den effektiven <see cref="PermissionScope"/> für Modul/Aktion dieses Users, oder
|
||||
/// <c>null</c> wenn gar nicht gewährt. Scope-unabhängig vom reinen Endpunkt-Gate
|
||||
/// (<see cref="HasPermissionAsync"/>) — wird von datenzugreifenden Application-Services
|
||||
/// konsultiert, um Own-Scope-Filterung anzuwenden (siehe REQUIREMENTS.md FR-MA-6).
|
||||
/// </summary>
|
||||
Task<PermissionScope?> GetScopeAsync(Guid userId, ModuleType module, PermissionAction action, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Alle tatsächlich gewährten Modul/Aktion-Kombinationen dieses Users (Rollen-Default + Overrides aufgelöst).</summary>
|
||||
Task<IReadOnlyList<PermissionGrant>> GetGrantedPermissionsAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Entfernt den gecachten Rechte-Stand dieses einzelnen Users - nach einem individuellen Override oder Rollenwechsel.</summary>
|
||||
void InvalidateUserPermissions(Guid userId);
|
||||
|
||||
/// <summary>Entfernt den gecachten Rechte-Stand aller User mit dieser Rolle - nach einer Änderung der Rollen-Rechte-Matrix.</summary>
|
||||
Task InvalidateRolePermissionsAsync(Guid roleId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface ITimeEntryRepository
|
||||
{
|
||||
Task<TimeEntry?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<(IReadOnlyList<TimeEntry> Items, int TotalCount)> GetPagedAsync(
|
||||
Guid? statusId,
|
||||
Guid? employeeId,
|
||||
Guid? orderId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default,
|
||||
Guid? restrictToEmployeeId = null);
|
||||
Task AddAsync(TimeEntry timeEntry, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(TimeEntry timeEntry, CancellationToken cancellationToken = default);
|
||||
Task<bool> SoftDeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<TimeEntry>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -6,9 +6,12 @@ public interface IUserRepository
|
||||
{
|
||||
Task<User?> GetByUsernameAsync(string username, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Inklusive Role+RolePermissions+PermissionOverrides (für die Rechteauflösung) und Employee (für /api/auth/me).</summary>
|
||||
/// <summary>Inklusive Role+RolePermissions+PermissionOverrides (für die Rechteauflösung) und Employee (für /api/auth/me). Getrackt - für Mutationsflows (Override hinzufügen/entfernen).</summary>
|
||||
Task<User?> GetByIdWithPermissionsAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Wie <see cref="GetByIdWithPermissionsAsync"/>, aber AsNoTracking - für PermissionService's Cache (nie mutiert, wird über Request-Grenzen hinweg gehalten).</summary>
|
||||
Task<User?> GetByIdWithPermissionsNoTrackingAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Schlanker Lookup ohne Includes - für den SecurityStamp-Check bei jedem Request.</summary>
|
||||
Task<User?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -17,6 +20,9 @@ public interface IUserRepository
|
||||
|
||||
Task<IReadOnlyList<User>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Nur die Ids der User mit dieser Rolle - für die Cache-Invalidierung bei Rollen-Rechte-Änderungen.</summary>
|
||||
Task<IReadOnlyList<Guid>> GetUserIdsByRoleAsync(Guid roleId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<User?> GetByEmployeeIdAsync(Guid employeeId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> ExistsByUsernameAsync(string username, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -19,6 +19,8 @@ public interface IValueListRepository
|
||||
Task<bool> CanTransitionAsync(Guid fromItemId, Guid toItemId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<ValueListItemTransition>> GetTransitionsAsync(string key, CancellationToken cancellationToken = default);
|
||||
Task ReplaceTransitionsAsync(string key, IEnumerable<(Guid FromItemId, Guid ToItemId)> transitions, CancellationToken cancellationToken = default);
|
||||
Task<ValueListItemTransition?> GetSelfServiceTransitionAsync(Guid fromItemId, CancellationToken cancellationToken = default);
|
||||
Task<ValueListItemTransition?> GetTransitionAsync(Guid fromItemId, Guid toItemId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -13,8 +13,12 @@ public static class DependencyInjection
|
||||
services.AddScoped<IEmployeeService, EmployeeService>();
|
||||
services.AddScoped<IFacilityService, FacilityService>();
|
||||
services.AddScoped<IFacilityContactService, FacilityContactService>();
|
||||
services.AddScoped<IFacilityQualificationRateService, FacilityQualificationRateService>();
|
||||
services.AddScoped<IContractService, ContractService>();
|
||||
services.AddScoped<IOrderService, OrderService>();
|
||||
services.AddScoped<IAbsenceService, AbsenceService>();
|
||||
services.AddScoped<ITimeEntryService, TimeEntryService>();
|
||||
services.AddScoped<IDocumentService, DocumentService>();
|
||||
services.AddScoped<IValueListService, ValueListService>();
|
||||
services.AddScoped<ISessionAdminService, SessionAdminService>();
|
||||
services.AddScoped<IPasswordResetService, PasswordResetService>();
|
||||
|
||||
@@ -3,4 +3,4 @@ using OmsorgCore.Domain.Enums;
|
||||
namespace OmsorgCore.Application.Models;
|
||||
|
||||
/// <summary>Eine für einen User tatsächlich gewährte Modul/Aktion-Kombination (Ergebnis der Rechte-Auflösung).</summary>
|
||||
public record PermissionGrant(ModuleType Module, PermissionAction Action);
|
||||
public record PermissionGrant(ModuleType Module, PermissionAction Action, PermissionScope Scope);
|
||||
|
||||
@@ -3,4 +3,4 @@ using OmsorgCore.Domain.Enums;
|
||||
namespace OmsorgCore.Application.Models;
|
||||
|
||||
/// <summary>Ein einzelner UserPermissionOverride-Eintrag für die Admin-Ansicht/-Bearbeitung.</summary>
|
||||
public record PermissionOverrideSummary(Guid Id, ModuleType Module, PermissionAction Action, PermissionEffect Effect);
|
||||
public record PermissionOverrideSummary(Guid Id, ModuleType Module, PermissionAction Action, PermissionEffect Effect, PermissionScope Scope);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class AbsenceService : IAbsenceService
|
||||
{
|
||||
private const string StatusListKey = "AbsenceStatus";
|
||||
|
||||
private readonly IAbsenceRepository _absenceRepository;
|
||||
private readonly IPermissionService _permissionService;
|
||||
private readonly ICurrentUserService _currentUserService;
|
||||
private readonly IValueListRepository _valueListRepository;
|
||||
|
||||
public AbsenceService(
|
||||
IAbsenceRepository absenceRepository,
|
||||
IPermissionService permissionService,
|
||||
ICurrentUserService currentUserService,
|
||||
IValueListRepository valueListRepository)
|
||||
{
|
||||
_absenceRepository = absenceRepository;
|
||||
_permissionService = permissionService;
|
||||
_currentUserService = currentUserService;
|
||||
_valueListRepository = valueListRepository;
|
||||
}
|
||||
|
||||
public async Task<(IReadOnlyList<Absence> Items, int TotalCount)> GetPagedAsync(
|
||||
string? status,
|
||||
string? type,
|
||||
Guid? employeeId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Guid? restrictToEmployeeId = await ResolveOwnScopeRestrictionAsync(PermissionAction.View, cancellationToken);
|
||||
return await _absenceRepository.GetPagedAsync(status, type, employeeId, page, pageSize, cancellationToken, restrictToEmployeeId);
|
||||
}
|
||||
|
||||
public async Task<Absence?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var absence = await _absenceRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (absence is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Guid? restrictToEmployeeId = await ResolveOwnScopeRestrictionAsync(PermissionAction.View, cancellationToken);
|
||||
if (restrictToEmployeeId.HasValue && absence.EmployeeId != restrictToEmployeeId.Value)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return absence;
|
||||
}
|
||||
|
||||
public async Task<Absence?> CreateAsync(Absence absence, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Guid? restrictToEmployeeId = await ResolveOwnScopeRestrictionAsync(PermissionAction.Create, cancellationToken);
|
||||
if (restrictToEmployeeId.HasValue)
|
||||
{
|
||||
if (restrictToEmployeeId.Value == Guid.Empty)
|
||||
{
|
||||
// Own-Scope, aber kein User.EmployeeId verknüpft - fail-closed statt "für niemanden" anzulegen.
|
||||
return null;
|
||||
}
|
||||
|
||||
absence.EmployeeId = restrictToEmployeeId.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
// All-Scope-Aufrufer (z. B. Büro-Rollen/Administrator): CreateAbsenceRequest hat bewusst
|
||||
// kein employeeId-Feld ("im Namen von" ist nicht Teil dieser ersten UI, siehe
|
||||
// omsorgCore/CLAUDE.md). Own-Scope ist nur eine Sichtbarkeits-/Anlege-Einschränkung
|
||||
// ("nur eigene Daten"), kein Ausschlusskriterium dafür, ob man überhaupt einen eigenen
|
||||
// Antrag stellen darf - auch Büro-Rollen sind Mitarbeiter und wollen eigenen Urlaub
|
||||
// beantragen können (Vorfall 2026-08-10: Admin mit verknüpftem Mitarbeiter bekam grundlos
|
||||
// 400). Deshalb hier Fallback auf die eigene verknüpfte Mitarbeiter-Id; nur wenn die
|
||||
// Aufrufer:in selbst gar keinen Mitarbeiter verknüpft hat, bleibt es ein harter Fehler.
|
||||
var ownEmployeeId = _currentUserService.EmployeeId;
|
||||
if (ownEmployeeId is null || ownEmployeeId == Guid.Empty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
absence.EmployeeId = ownEmployeeId.Value;
|
||||
}
|
||||
|
||||
// Nicht den Entity-Default hartkodiert übernehmen - der Wert kommt aus der admin-editierbaren
|
||||
// ValueList "AbsenceStatus" (IsInitial-Flag, von DbSeeder.SeedValueListsAsync gesetzt), damit
|
||||
// ein Umbenennen über die Status-Verwaltung nicht lautlos bricht (analog OrderService.CreateAsync).
|
||||
absence.Status = await GetInitialStatusValueAsync(cancellationToken);
|
||||
|
||||
await _absenceRepository.AddAsync(absence, cancellationToken);
|
||||
await _absenceRepository.SaveChangesAsync(cancellationToken);
|
||||
return absence;
|
||||
}
|
||||
|
||||
public async Task<UpdateAbsenceResult> UpdateAsync(Guid id, Absence updates, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var absence = await _absenceRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (absence is null)
|
||||
{
|
||||
return UpdateAbsenceResult.Fail(UpdateAbsenceFailureReason.NotFound);
|
||||
}
|
||||
|
||||
Guid? restrictToEmployeeId = await ResolveOwnScopeRestrictionAsync(PermissionAction.Edit, cancellationToken);
|
||||
if (restrictToEmployeeId.HasValue && absence.EmployeeId != restrictToEmployeeId.Value)
|
||||
{
|
||||
// Wie GetByIdAsync: fremden Own-Scope-Antrag als "nicht gefunden" behandeln statt 403,
|
||||
// um nicht zu verraten, dass die Id überhaupt existiert.
|
||||
return UpdateAbsenceResult.Fail(UpdateAbsenceFailureReason.NotFound);
|
||||
}
|
||||
|
||||
if (absence.Status != await GetInitialStatusValueAsync(cancellationToken))
|
||||
{
|
||||
return UpdateAbsenceResult.Fail(UpdateAbsenceFailureReason.AlreadyDecided);
|
||||
}
|
||||
|
||||
absence.Type = updates.Type;
|
||||
absence.StartDate = updates.StartDate;
|
||||
absence.EndDate = updates.EndDate;
|
||||
absence.Reason = updates.Reason;
|
||||
absence.Substitute = updates.Substitute;
|
||||
absence.Note = updates.Note;
|
||||
absence.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _absenceRepository.UpdateAsync(absence, cancellationToken);
|
||||
await _absenceRepository.SaveChangesAsync(cancellationToken);
|
||||
return UpdateAbsenceResult.Ok(absence);
|
||||
}
|
||||
|
||||
public async Task<Absence?> DecideAsync(Guid id, string status, string? adminNote, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var absence = await _absenceRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (absence is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
absence.Status = status;
|
||||
absence.AdminNote = adminNote;
|
||||
absence.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _absenceRepository.UpdateAsync(absence, cancellationToken);
|
||||
await _absenceRepository.SaveChangesAsync(cancellationToken);
|
||||
return absence;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var deleted = await _absenceRepository.SoftDeleteAsync(id, cancellationToken);
|
||||
if (deleted)
|
||||
{
|
||||
await _absenceRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Absence>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default)
|
||||
=> _absenceRepository.GetDeletedAsync(search, cancellationToken);
|
||||
|
||||
public async Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var restored = await _absenceRepository.RestoreAsync(id, cancellationToken);
|
||||
if (restored)
|
||||
{
|
||||
await _absenceRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return restored;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der aktuell als "initial"/pending markierte Status-Wert der ValueList "AbsenceStatus"
|
||||
/// (per Default "Eingereicht", aber nicht hartkodiert - über Status-Verwaltung umbenennbar,
|
||||
/// ohne dass diese Logik oder der Controller (Decide-Endpoint) mitgeändert werden müssten).
|
||||
/// </summary>
|
||||
public async Task<string> GetInitialStatusValueAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var initial = await _valueListRepository.GetInitialItemAsync(StatusListKey, cancellationToken);
|
||||
return initial?.Value
|
||||
?? throw new InvalidOperationException("Kein initialer Abwesenheitsstatus konfiguriert (DbSeeder.SeedValueListsAsync fehlt).");
|
||||
}
|
||||
|
||||
/// <summary>Own-Scope-Filterung, siehe EmployeeService.ResolveOwnScopeRestrictionAsync (analoges Muster für ModuleType.Absences).</summary>
|
||||
private async Task<Guid?> ResolveOwnScopeRestrictionAsync(PermissionAction action, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_currentUserService.UserId is not { } userId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var scope = await _permissionService.GetScopeAsync(userId, ModuleType.Absences, action, cancellationToken);
|
||||
return scope == PermissionScope.Own ? (_currentUserService.EmployeeId ?? Guid.Empty) : null;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,29 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class ContractService : IContractService
|
||||
{
|
||||
private readonly IContractRepository _contractRepository;
|
||||
private readonly IPermissionService _permissionService;
|
||||
private readonly ICurrentUserService _currentUserService;
|
||||
|
||||
public ContractService(IContractRepository contractRepository)
|
||||
public ContractService(
|
||||
IContractRepository contractRepository,
|
||||
IPermissionService permissionService,
|
||||
ICurrentUserService currentUserService)
|
||||
{
|
||||
_contractRepository = contractRepository;
|
||||
_permissionService = permissionService;
|
||||
_currentUserService = currentUserService;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Contract>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
=> _contractRepository.GetAllAsync(cancellationToken);
|
||||
|
||||
public Task<(IReadOnlyList<Contract> Items, int TotalCount)> GetPagedAsync(
|
||||
public async Task<(IReadOnlyList<Contract> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
string? status,
|
||||
Guid? employeeId,
|
||||
@@ -23,10 +31,43 @@ public class ContractService : IContractService
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> _contractRepository.GetPagedAsync(search, status, employeeId, facilityId, page, pageSize, cancellationToken);
|
||||
{
|
||||
Guid? ownRestriction = await ResolveOwnScopeRestrictionAsync(cancellationToken);
|
||||
// Own-Scope erzwingt die eigene EmployeeId und überschreibt einen ggf. angeforderten
|
||||
// fremden employeeId-Filter - sonst könnte ein Own-User über den Query-Parameter fremde
|
||||
// Verträge abfragen.
|
||||
Guid? effectiveEmployeeId = ownRestriction ?? employeeId;
|
||||
return await _contractRepository.GetPagedAsync(search, status, effectiveEmployeeId, facilityId, page, pageSize, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<Contract?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _contractRepository.GetByIdAsync(id, cancellationToken);
|
||||
public async Task<Contract?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var contract = await _contractRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (contract is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Guid? ownRestriction = await ResolveOwnScopeRestrictionAsync(cancellationToken);
|
||||
if (ownRestriction.HasValue && contract.EmployeeId != ownRestriction.Value)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return contract;
|
||||
}
|
||||
|
||||
/// <summary>Own-Scope-Filterung, siehe EmployeeService.ResolveOwnScopeRestrictionAsync (analoges Muster für ModuleType.Contracts).</summary>
|
||||
private async Task<Guid?> ResolveOwnScopeRestrictionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_currentUserService.UserId is not { } userId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var scope = await _permissionService.GetScopeAsync(userId, ModuleType.Contracts, PermissionAction.View, cancellationToken);
|
||||
return scope == PermissionScope.Own ? (_currentUserService.EmployeeId ?? Guid.Empty) : null;
|
||||
}
|
||||
|
||||
public async Task<Contract> CreateAsync(Contract contract, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -61,4 +102,29 @@ public class ContractService : IContractService
|
||||
await _contractRepository.SaveChangesAsync(cancellationToken);
|
||||
return contract;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var deleted = await _contractRepository.SoftDeleteAsync(id, cancellationToken);
|
||||
if (deleted)
|
||||
{
|
||||
await _contractRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Contract>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default)
|
||||
=> _contractRepository.GetDeletedAsync(search, cancellationToken);
|
||||
|
||||
public async Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var restored = await _contractRepository.RestoreAsync(id, cancellationToken);
|
||||
if (restored)
|
||||
{
|
||||
await _contractRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return restored;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public enum DecideTimeEntryFailureReason
|
||||
{
|
||||
NotFound,
|
||||
InvalidStatusTransition
|
||||
}
|
||||
|
||||
public class DecideTimeEntryResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public DecideTimeEntryFailureReason? FailureReason { get; init; }
|
||||
public TimeEntry? TimeEntry { get; init; }
|
||||
|
||||
public static DecideTimeEntryResult Fail(DecideTimeEntryFailureReason reason) => new()
|
||||
{
|
||||
Success = false,
|
||||
FailureReason = reason
|
||||
};
|
||||
|
||||
public static DecideTimeEntryResult Ok(TimeEntry timeEntry) => new()
|
||||
{
|
||||
Success = true,
|
||||
TimeEntry = timeEntry
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class DocumentService : IDocumentService
|
||||
{
|
||||
private const string DocumentCategoryListKey = "DocumentCategory";
|
||||
|
||||
private readonly IDocumentRepository _documentRepository;
|
||||
private readonly IDocumentStorage _documentStorage;
|
||||
private readonly IDocumentUploadPolicy _uploadPolicy;
|
||||
private readonly IValueListRepository _valueListRepository;
|
||||
|
||||
public DocumentService(
|
||||
IDocumentRepository documentRepository,
|
||||
IDocumentStorage documentStorage,
|
||||
IDocumentUploadPolicy uploadPolicy,
|
||||
IValueListRepository valueListRepository)
|
||||
{
|
||||
_documentRepository = documentRepository;
|
||||
_documentStorage = documentStorage;
|
||||
_uploadPolicy = uploadPolicy;
|
||||
_valueListRepository = valueListRepository;
|
||||
}
|
||||
|
||||
public async Task<DocumentUploadResult> UploadAsync(
|
||||
string entityType,
|
||||
Guid entityId,
|
||||
string category,
|
||||
string? description,
|
||||
string fileName,
|
||||
string contentType,
|
||||
long sizeBytes,
|
||||
Stream content,
|
||||
Guid uploadedByUserId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_uploadPolicy.IsSizeAllowed(sizeBytes))
|
||||
{
|
||||
return new DocumentUploadResult(null, DocumentUploadError.FileTooLarge);
|
||||
}
|
||||
|
||||
if (!_uploadPolicy.IsContentTypeAllowed(contentType))
|
||||
{
|
||||
return new DocumentUploadResult(null, DocumentUploadError.ContentTypeNotAllowed);
|
||||
}
|
||||
|
||||
var allowedCategories = await _valueListRepository.GetActiveValuesAsync(DocumentCategoryListKey, cancellationToken);
|
||||
if (!allowedCategories.Contains(category))
|
||||
{
|
||||
return new DocumentUploadResult(null, DocumentUploadError.InvalidCategory);
|
||||
}
|
||||
|
||||
var document = new Document
|
||||
{
|
||||
EntityType = entityType,
|
||||
EntityId = entityId,
|
||||
Category = category,
|
||||
Description = description,
|
||||
FileName = fileName,
|
||||
ContentType = contentType,
|
||||
SizeBytes = sizeBytes,
|
||||
UploadedByUserId = uploadedByUserId
|
||||
};
|
||||
|
||||
document.StorageKey = await _documentStorage.SaveAsync(entityType, entityId, document.Id, fileName, content, cancellationToken);
|
||||
|
||||
await _documentRepository.AddAsync(document, cancellationToken);
|
||||
await _documentRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new DocumentUploadResult(document, DocumentUploadError.None);
|
||||
}
|
||||
|
||||
public async Task<DocumentUpdateResult> UpdateAsync(
|
||||
Guid id,
|
||||
string category,
|
||||
string? description,
|
||||
string fileName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var document = await _documentRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (document is null)
|
||||
{
|
||||
return new DocumentUpdateResult(null, DocumentUpdateError.NotFound);
|
||||
}
|
||||
|
||||
var allowedCategories = await _valueListRepository.GetActiveValuesAsync(DocumentCategoryListKey, cancellationToken);
|
||||
if (!allowedCategories.Contains(category))
|
||||
{
|
||||
return new DocumentUpdateResult(null, DocumentUpdateError.InvalidCategory);
|
||||
}
|
||||
|
||||
document.Category = category;
|
||||
document.Description = description;
|
||||
document.FileName = fileName;
|
||||
document.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _documentRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new DocumentUpdateResult(document, DocumentUpdateError.None);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Document>> GetByEntityAsync(string entityType, Guid entityId, CancellationToken cancellationToken = default)
|
||||
=> _documentRepository.GetByEntityAsync(entityType, entityId, cancellationToken);
|
||||
|
||||
public Task<Document?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _documentRepository.GetByIdAsync(id, cancellationToken);
|
||||
|
||||
public async Task<Stream?> OpenForDownloadAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var document = await _documentRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (document is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await _documentStorage.OpenReadAsync(document.StorageKey, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var deleted = await _documentRepository.SoftDeleteAsync(id, cancellationToken);
|
||||
if (deleted)
|
||||
{
|
||||
await _documentRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Document>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default)
|
||||
=> _documentRepository.GetDeletedAsync(search, cancellationToken);
|
||||
|
||||
public async Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var restored = await _documentRepository.RestoreAsync(id, cancellationToken);
|
||||
if (restored)
|
||||
{
|
||||
await _documentRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return restored;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,74 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class EmployeeService : IEmployeeService
|
||||
{
|
||||
private readonly IEmployeeRepository _employeeRepository;
|
||||
private readonly IPermissionService _permissionService;
|
||||
private readonly ICurrentUserService _currentUserService;
|
||||
|
||||
public EmployeeService(IEmployeeRepository employeeRepository)
|
||||
public EmployeeService(
|
||||
IEmployeeRepository employeeRepository,
|
||||
IPermissionService permissionService,
|
||||
ICurrentUserService currentUserService)
|
||||
{
|
||||
_employeeRepository = employeeRepository;
|
||||
_permissionService = permissionService;
|
||||
_currentUserService = currentUserService;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Employee>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
=> _employeeRepository.GetAllAsync(cancellationToken);
|
||||
|
||||
public Task<(IReadOnlyList<Employee> Items, int TotalCount)> GetPagedAsync(
|
||||
public async Task<(IReadOnlyList<Employee> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
string? status,
|
||||
string? employmentType,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> _employeeRepository.GetPagedAsync(search, status, employmentType, page, pageSize, cancellationToken);
|
||||
{
|
||||
Guid? restrictToEmployeeId = await ResolveOwnScopeRestrictionAsync(PermissionAction.View, cancellationToken);
|
||||
return await _employeeRepository.GetPagedAsync(search, status, employmentType, page, pageSize, cancellationToken, restrictToEmployeeId);
|
||||
}
|
||||
|
||||
public Task<Employee?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _employeeRepository.GetByIdAsync(id, cancellationToken);
|
||||
public async Task<Employee?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var employee = await _employeeRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (employee is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Guid? restrictToEmployeeId = await ResolveOwnScopeRestrictionAsync(PermissionAction.View, cancellationToken);
|
||||
if (restrictToEmployeeId.HasValue && employee.Id != restrictToEmployeeId.Value)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return employee;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Own-Scope-Filterung (siehe IPermissionService.GetScopeAsync): liefert die eigene
|
||||
/// EmployeeId, wenn der aktuelle User für Employees.<action> nur "Own" gewährt bekommt
|
||||
/// (Guid.Empty statt null, falls kein User.EmployeeId verknüpft ist — Own ohne Anker sieht
|
||||
/// dann nichts statt versehentlich alles). Null = keine Einschränkung (Scope "All" oder kein
|
||||
/// eingeloggter User, letzteres blockiert der [RequirePermission]-Endpunkt-Gate ohnehin schon).
|
||||
/// </summary>
|
||||
private async Task<Guid?> ResolveOwnScopeRestrictionAsync(PermissionAction action, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_currentUserService.UserId is not { } userId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var scope = await _permissionService.GetScopeAsync(userId, ModuleType.Employees, action, cancellationToken);
|
||||
return scope == PermissionScope.Own ? (_currentUserService.EmployeeId ?? Guid.Empty) : null;
|
||||
}
|
||||
|
||||
public async Task<Employee> CreateAsync(Employee employee, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -66,4 +109,29 @@ public class EmployeeService : IEmployeeService
|
||||
await _employeeRepository.SaveChangesAsync(cancellationToken);
|
||||
return employee;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var deleted = await _employeeRepository.SoftDeleteAsync(id, cancellationToken);
|
||||
if (deleted)
|
||||
{
|
||||
await _employeeRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Employee>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default)
|
||||
=> _employeeRepository.GetDeletedAsync(search, cancellationToken);
|
||||
|
||||
public async Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var restored = await _employeeRepository.RestoreAsync(id, cancellationToken);
|
||||
if (restored)
|
||||
{
|
||||
await _employeeRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return restored;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,4 +45,29 @@ public class FacilityContactService : IFacilityContactService
|
||||
await _facilityContactRepository.SaveChangesAsync(cancellationToken);
|
||||
return contact;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var deleted = await _facilityContactRepository.SoftDeleteAsync(id, cancellationToken);
|
||||
if (deleted)
|
||||
{
|
||||
await _facilityContactRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<FacilityContact>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default)
|
||||
=> _facilityContactRepository.GetDeletedAsync(search, cancellationToken);
|
||||
|
||||
public async Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var restored = await _facilityContactRepository.RestoreAsync(id, cancellationToken);
|
||||
if (restored)
|
||||
{
|
||||
await _facilityContactRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return restored;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class FacilityQualificationRateService : IFacilityQualificationRateService
|
||||
{
|
||||
private readonly IFacilityQualificationRateRepository _facilityQualificationRateRepository;
|
||||
|
||||
public FacilityQualificationRateService(IFacilityQualificationRateRepository facilityQualificationRateRepository)
|
||||
{
|
||||
_facilityQualificationRateRepository = facilityQualificationRateRepository;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<FacilityQualificationRate>> GetByFacilityIdAsync(Guid facilityId, CancellationToken cancellationToken = default)
|
||||
=> _facilityQualificationRateRepository.GetByFacilityIdAsync(facilityId, cancellationToken);
|
||||
|
||||
public Task<FacilityQualificationRate?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _facilityQualificationRateRepository.GetByIdAsync(id, cancellationToken);
|
||||
|
||||
public async Task<FacilityQualificationRate> CreateAsync(FacilityQualificationRate rate, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _facilityQualificationRateRepository.AddAsync(rate, cancellationToken);
|
||||
await _facilityQualificationRateRepository.SaveChangesAsync(cancellationToken);
|
||||
return rate;
|
||||
}
|
||||
|
||||
public async Task<FacilityQualificationRate?> UpdateAsync(Guid id, FacilityQualificationRate updates, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var rate = await _facilityQualificationRateRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (rate is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
rate.Qualification = updates.Qualification;
|
||||
rate.Rate = updates.Rate;
|
||||
rate.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _facilityQualificationRateRepository.UpdateAsync(rate, cancellationToken);
|
||||
await _facilityQualificationRateRepository.SaveChangesAsync(cancellationToken);
|
||||
return rate;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var deleted = await _facilityQualificationRateRepository.SoftDeleteAsync(id, cancellationToken);
|
||||
if (deleted)
|
||||
{
|
||||
await _facilityQualificationRateRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<FacilityQualificationRate>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default)
|
||||
=> _facilityQualificationRateRepository.GetDeletedAsync(search, cancellationToken);
|
||||
|
||||
public async Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var restored = await _facilityQualificationRateRepository.RestoreAsync(id, cancellationToken);
|
||||
if (restored)
|
||||
{
|
||||
await _facilityQualificationRateRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return restored;
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,11 @@ public class FacilityService : IFacilityService
|
||||
public Task<(IReadOnlyList<Facility> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
string? crmStatus,
|
||||
bool followUpDueOnly,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> _facilityRepository.GetPagedAsync(search, crmStatus, page, pageSize, cancellationToken);
|
||||
=> _facilityRepository.GetPagedAsync(search, crmStatus, followUpDueOnly, page, pageSize, cancellationToken);
|
||||
|
||||
public Task<Facility?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _facilityRepository.GetByIdAsync(id, cancellationToken);
|
||||
@@ -53,10 +54,36 @@ public class FacilityService : IFacilityService
|
||||
facility.BillingCity = updates.BillingCity;
|
||||
facility.BillingCountry = updates.BillingCountry;
|
||||
facility.CrmStatus = updates.CrmStatus;
|
||||
facility.FollowUpDueDate = updates.FollowUpDueDate;
|
||||
facility.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _facilityRepository.UpdateAsync(facility, cancellationToken);
|
||||
await _facilityRepository.SaveChangesAsync(cancellationToken);
|
||||
return facility;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var deleted = await _facilityRepository.SoftDeleteAsync(id, cancellationToken);
|
||||
if (deleted)
|
||||
{
|
||||
await _facilityRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Facility>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default)
|
||||
=> _facilityRepository.GetDeletedAsync(search, cancellationToken);
|
||||
|
||||
public async Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var restored = await _facilityRepository.RestoreAsync(id, cancellationToken);
|
||||
if (restored)
|
||||
{
|
||||
await _facilityRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return restored;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public interface IAbsenceService
|
||||
{
|
||||
Task<(IReadOnlyList<Absence> Items, int TotalCount)> GetPagedAsync(
|
||||
string? status,
|
||||
string? type,
|
||||
Guid? employeeId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<Absence?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Legt den Antrag an. Ist der aktuelle Nutzer nur mit Own-Scope berechtigt, wird
|
||||
/// <paramref name="absence"/>.EmployeeId ignoriert und serverseitig auf die eigene,
|
||||
/// per JWT verknüpfte Mitarbeiter-Id gesetzt - der Client kann sich nie als jemand
|
||||
/// anderes ausgeben. Gibt null zurück, wenn Own-Scope greift, aber kein Mitarbeiter
|
||||
/// verknüpft ist (fail-closed).
|
||||
/// </summary>
|
||||
Task<Absence?> CreateAsync(Absence absence, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Bearbeitet Zeitraum/Art/Grund/Vertretung/Nachricht - nur solange der Antrag noch nicht
|
||||
/// entschieden wurde (Status "Eingereicht"), sonst <see cref="UpdateAbsenceFailureReason.AlreadyDecided"/>.
|
||||
/// Own-Scope-Aufrufer dürfen nur ihre eigenen Anträge bearbeiten (wie bei GetByIdAsync).
|
||||
/// </summary>
|
||||
Task<UpdateAbsenceResult> UpdateAsync(Guid id, Absence updates, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<Absence?> DecideAsync(Guid id, string status, string? adminNote, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Der aktuell als "initial"/pending markierte Wert der ValueList "AbsenceStatus" (nicht hartkodiert, siehe AbsenceService).</summary>
|
||||
Task<string> GetInitialStatusValueAsync(CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Absence>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -16,4 +16,7 @@ public interface IContractService
|
||||
Task<Contract?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<Contract> CreateAsync(Contract contract, CancellationToken cancellationToken = default);
|
||||
Task<Contract?> UpdateAsync(Guid id, Contract updates, CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Contract>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public enum DocumentUploadError
|
||||
{
|
||||
None,
|
||||
InvalidCategory,
|
||||
FileTooLarge,
|
||||
ContentTypeNotAllowed
|
||||
}
|
||||
|
||||
public record DocumentUploadResult(Document? Document, DocumentUploadError Error);
|
||||
|
||||
public enum DocumentUpdateError
|
||||
{
|
||||
None,
|
||||
NotFound,
|
||||
InvalidCategory
|
||||
}
|
||||
|
||||
public record DocumentUpdateResult(Document? Document, DocumentUpdateError Error);
|
||||
|
||||
public interface IDocumentService
|
||||
{
|
||||
Task<DocumentUploadResult> UploadAsync(
|
||||
string entityType,
|
||||
Guid entityId,
|
||||
string category,
|
||||
string? description,
|
||||
string fileName,
|
||||
string contentType,
|
||||
long sizeBytes,
|
||||
Stream content,
|
||||
Guid uploadedByUserId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<DocumentUpdateResult> UpdateAsync(
|
||||
Guid id,
|
||||
string category,
|
||||
string? description,
|
||||
string fileName,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<Document>> GetByEntityAsync(string entityType, Guid entityId, CancellationToken cancellationToken = default);
|
||||
Task<Document?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<Stream?> OpenForDownloadAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Document>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -15,4 +15,7 @@ public interface IEmployeeService
|
||||
Task<Employee?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<Employee> CreateAsync(Employee employee, CancellationToken cancellationToken = default);
|
||||
Task<Employee?> UpdateAsync(Guid id, Employee updates, CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Employee>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -8,4 +8,7 @@ public interface IFacilityContactService
|
||||
Task<FacilityContact?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<FacilityContact> CreateAsync(FacilityContact contact, CancellationToken cancellationToken = default);
|
||||
Task<FacilityContact?> UpdateAsync(Guid id, FacilityContact updates, CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<FacilityContact>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public interface IFacilityQualificationRateService
|
||||
{
|
||||
Task<IReadOnlyList<FacilityQualificationRate>> GetByFacilityIdAsync(Guid facilityId, CancellationToken cancellationToken = default);
|
||||
Task<FacilityQualificationRate?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<FacilityQualificationRate> CreateAsync(FacilityQualificationRate rate, CancellationToken cancellationToken = default);
|
||||
Task<FacilityQualificationRate?> UpdateAsync(Guid id, FacilityQualificationRate updates, CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<FacilityQualificationRate>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -8,10 +8,14 @@ public interface IFacilityService
|
||||
Task<(IReadOnlyList<Facility> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
string? crmStatus,
|
||||
bool followUpDueOnly,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<Facility?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<Facility> CreateAsync(Facility facility, CancellationToken cancellationToken = default);
|
||||
Task<Facility?> UpdateAsync(Guid id, Facility updates, CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Facility>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -9,10 +9,16 @@ public interface IOrderService
|
||||
string? search,
|
||||
Guid? statusId,
|
||||
Guid? facilityId,
|
||||
string? priority,
|
||||
string? requiredQualification,
|
||||
string? shiftType,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<Order?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<Order> CreateAsync(Order order, CancellationToken cancellationToken = default);
|
||||
Task<UpdateOrderResult> UpdateAsync(Guid id, Order updates, CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Order>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,6 @@ public interface IRoleService
|
||||
/// <summary>Ersetzt die komplette RolePermission-Menge der Rolle durch die übergebene Menge (kein inkrementelles Patchen).</summary>
|
||||
Task<UpdateRolePermissionsResult> UpdatePermissionsAsync(
|
||||
Guid roleId,
|
||||
IReadOnlyList<(ModuleType Module, PermissionAction Action)> permissions,
|
||||
IReadOnlyList<(ModuleType Module, PermissionAction Action, PermissionScope Scope)> permissions,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public interface ITimeEntryService
|
||||
{
|
||||
Task<(IReadOnlyList<TimeEntry> Items, int TotalCount)> GetPagedAsync(
|
||||
Guid? statusId,
|
||||
Guid? employeeId,
|
||||
Guid? orderId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<TimeEntry?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Legt die Zeiterfassung an. Ist der aktuelle Nutzer nur mit Own-Scope berechtigt, wird
|
||||
/// <paramref name="timeEntry"/>.EmployeeId ignoriert und serverseitig auf die eigene, per JWT
|
||||
/// verknüpfte Mitarbeiter-Id gesetzt (analog AbsenceService.CreateAsync). StatusId wird immer
|
||||
/// serverseitig auf den initialen Status ("Entwurf") gesetzt. Gibt null zurück, wenn Own-Scope
|
||||
/// greift, aber kein Mitarbeiter verknüpft ist (fail-closed).
|
||||
/// </summary>
|
||||
Task<TimeEntry?> CreateAsync(TimeEntry timeEntry, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Bearbeitet Auftrag/Datum/Zeiten/Zuschlagsstunden - nur solange der aktuelle Status
|
||||
/// <see cref="ValueListItem.IsEditableByOwner"/> ist. Own-Scope-Aufrufer dürfen nur eigene
|
||||
/// Einträge bearbeiten (wie bei GetByIdAsync).
|
||||
/// </summary>
|
||||
Task<UpdateTimeEntryResult> UpdateAsync(Guid id, TimeEntry updates, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Löst die einzige Selbst-Einreichungs-Kante (RequiresApproval=false) ab dem aktuellen Status
|
||||
/// aus (Entwurf/Rückfrage -> Eingereicht). Kein Body nötig.
|
||||
/// </summary>
|
||||
Task<SubmitTimeEntryResult> SubmitAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Büro-Entscheidung entlang der Statuspipeline (z. B. ->Prüfung/->Rückfrage/->Freigegeben/->Abgerechnet).
|
||||
/// Validiert die Ziel-Transition über CanTransitionAsync und lehnt Kanten mit RequiresApproval=false ab
|
||||
/// (die gehören zu SubmitAsync, nicht hierher).
|
||||
/// </summary>
|
||||
Task<DecideTimeEntryResult> DecideAsync(Guid id, Guid statusId, string? adminNote, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<TimeEntry>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -42,12 +42,13 @@ public interface IUserService
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Upsert: existiert bereits ein Override für (module, action) bei diesem User, wird dessen Effect aktualisiert statt dupliziert.</summary>
|
||||
/// <summary>Upsert: existiert bereits ein Override für (module, action) bei diesem User, werden dessen Effect und Scope aktualisiert statt dupliziert.</summary>
|
||||
Task<AddPermissionOverrideResult> AddPermissionOverrideAsync(
|
||||
Guid userId,
|
||||
ModuleType module,
|
||||
PermissionAction action,
|
||||
PermissionEffect effect,
|
||||
PermissionScope scope,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RemovePermissionOverrideResult> RemovePermissionOverrideAsync(
|
||||
|
||||
@@ -8,8 +8,8 @@ public interface IValueListService
|
||||
Task<IReadOnlyList<ValueList>> GetListsAsync(CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<ValueListItem>> GetItemsAsync(string key, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ValueListItem> CreateItemAsync(string key, string value, int sortOrder, bool isDefault, bool isInitial, bool isTerminal, CancellationToken cancellationToken = default);
|
||||
Task<ValueListItem?> UpdateItemAsync(Guid id, string value, int sortOrder, bool isDefault, bool isInitial, bool isTerminal, CancellationToken cancellationToken = default);
|
||||
Task<ValueListItem> CreateItemAsync(string key, string value, int sortOrder, bool isDefault, bool isInitial, bool isTerminal, bool triggersFollowUp, CancellationToken cancellationToken = default);
|
||||
Task<ValueListItem?> UpdateItemAsync(Guid id, string value, int sortOrder, bool isDefault, bool isInitial, bool isTerminal, bool triggersFollowUp, CancellationToken cancellationToken = default);
|
||||
Task<DeleteValueListItemResult> DeleteItemAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<ValueListUsageEntry>> GetUsagesAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -23,10 +23,13 @@ public class OrderService : IOrderService
|
||||
string? search,
|
||||
Guid? statusId,
|
||||
Guid? facilityId,
|
||||
string? priority,
|
||||
string? requiredQualification,
|
||||
string? shiftType,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> _orderRepository.GetPagedAsync(search, statusId, facilityId, page, pageSize, cancellationToken);
|
||||
=> _orderRepository.GetPagedAsync(search, statusId, facilityId, priority, requiredQualification, shiftType, page, pageSize, cancellationToken);
|
||||
|
||||
public Task<Order?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _orderRepository.GetByIdAsync(id, cancellationToken);
|
||||
@@ -71,4 +74,29 @@ public class OrderService : IOrderService
|
||||
await _orderRepository.SaveChangesAsync(cancellationToken);
|
||||
return UpdateOrderResult.Ok(order);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var deleted = await _orderRepository.SoftDeleteAsync(id, cancellationToken);
|
||||
if (deleted)
|
||||
{
|
||||
await _orderRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Order>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default)
|
||||
=> _orderRepository.GetDeletedAsync(search, cancellationToken);
|
||||
|
||||
public async Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var restored = await _orderRepository.RestoreAsync(id, cancellationToken);
|
||||
if (restored)
|
||||
{
|
||||
await _orderRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return restored;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Application.Models;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
@@ -8,30 +9,53 @@ namespace OmsorgCore.Application.Services;
|
||||
/// <summary>
|
||||
/// Implementiert die Regel aus REQUIREMENTS.md Abschnitt 3/7 und Blueprint 6.5:
|
||||
/// Rollen-Default gilt, ein individueller Override (Grant oder Revoke) gewinnt immer.
|
||||
///
|
||||
/// Der Rechte-Join (Role→RolePermissions + PermissionOverrides + Employee) ist die teuerste,
|
||||
/// pro Request wiederholte Query der gesamten API (jeder [RequirePermission]-Endpunkt löst sie
|
||||
/// aus). Deshalb wird das Ergebnis 60s in einem IMemoryCache gehalten. Der SecurityStamp-Check
|
||||
/// (Session-Killswitch, siehe Program.cs OnTokenValidated) bleibt bewusst außen vor - der muss
|
||||
/// laut CLAUDE.md "sofort" wirken, unabhängig von jeder TTL. Die TTL hier ist nur ein
|
||||
/// Sicherheitsnetz; der Normalfall ist aktive Invalidierung bei jeder Rechte-Mutation
|
||||
/// (InvalidateUserPermissions/InvalidateRolePermissionsAsync, aufgerufen aus RoleService/UserService).
|
||||
/// </summary>
|
||||
public class PermissionService : IPermissionService
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(60);
|
||||
|
||||
public PermissionService(IUserRepository userRepository)
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IMemoryCache _cache;
|
||||
|
||||
public PermissionService(IUserRepository userRepository, IMemoryCache cache)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
public async Task<bool> HasPermissionAsync(Guid userId, ModuleType module, PermissionAction action, CancellationToken cancellationToken = default)
|
||||
{
|
||||
User? user = await _userRepository.GetByIdWithPermissionsAsync(userId, cancellationToken);
|
||||
User? user = await GetCachedUserAsync(userId, cancellationToken);
|
||||
if (user is null || !user.IsActive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return IsGranted(user, module, action);
|
||||
return ResolveScope(user, module, action) is not null;
|
||||
}
|
||||
|
||||
public async Task<PermissionScope?> GetScopeAsync(Guid userId, ModuleType module, PermissionAction action, CancellationToken cancellationToken = default)
|
||||
{
|
||||
User? user = await GetCachedUserAsync(userId, cancellationToken);
|
||||
if (user is null || !user.IsActive)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return ResolveScope(user, module, action);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PermissionGrant>> GetGrantedPermissionsAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
User? user = await _userRepository.GetByIdWithPermissionsAsync(userId, cancellationToken);
|
||||
User? user = await GetCachedUserAsync(userId, cancellationToken);
|
||||
if (user is null || !user.IsActive)
|
||||
{
|
||||
return Array.Empty<PermissionGrant>();
|
||||
@@ -42,9 +66,10 @@ public class PermissionService : IPermissionService
|
||||
{
|
||||
foreach (PermissionAction action in Enum.GetValues<PermissionAction>())
|
||||
{
|
||||
if (IsGranted(user, module, action))
|
||||
PermissionScope? scope = ResolveScope(user, module, action);
|
||||
if (scope is not null)
|
||||
{
|
||||
grants.Add(new PermissionGrant(module, action));
|
||||
grants.Add(new PermissionGrant(module, action, scope.Value));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,16 +77,50 @@ public class PermissionService : IPermissionService
|
||||
return grants;
|
||||
}
|
||||
|
||||
private static bool IsGranted(User user, ModuleType module, PermissionAction action)
|
||||
public void InvalidateUserPermissions(Guid userId)
|
||||
=> _cache.Remove(CacheKey(userId));
|
||||
|
||||
public async Task InvalidateRolePermissionsAsync(Guid roleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var userIds = await _userRepository.GetUserIdsByRoleAsync(roleId, cancellationToken);
|
||||
foreach (var userId in userIds)
|
||||
{
|
||||
InvalidateUserPermissions(userId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<User?> GetCachedUserAsync(Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_cache.TryGetValue(CacheKey(userId), out User? cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
User? user = await _userRepository.GetByIdWithPermissionsNoTrackingAsync(userId, cancellationToken);
|
||||
_cache.Set(CacheKey(userId), user, CacheDuration);
|
||||
return user;
|
||||
}
|
||||
|
||||
private static string CacheKey(Guid userId) => $"user-permissions:{userId}";
|
||||
|
||||
/// <summary>
|
||||
/// Löst Modul+Aktion für diesen User zu einem Scope auf, oder <c>null</c> wenn nicht gewährt.
|
||||
/// Ein Override ersetzt die Zelle vollständig (Grant+Scope) — es wird nicht mit dem
|
||||
/// Rollen-Scope gemergt, analog zur bestehenden Effect-Semantik.
|
||||
/// </summary>
|
||||
private static PermissionScope? ResolveScope(User user, ModuleType module, PermissionAction action)
|
||||
{
|
||||
UserPermissionOverride? override_ = user.PermissionOverrides
|
||||
.FirstOrDefault(o => o.Module == module && o.Action == action);
|
||||
|
||||
if (override_ is not null)
|
||||
{
|
||||
return override_.Effect == PermissionEffect.Grant;
|
||||
return override_.Effect == PermissionEffect.Grant ? override_.Scope : null;
|
||||
}
|
||||
|
||||
return user.Role.RolePermissions.Any(rp => rp.Module == module && rp.Action == action);
|
||||
RolePermission? rolePermission = user.Role.RolePermissions
|
||||
.FirstOrDefault(rp => rp.Module == module && rp.Action == action);
|
||||
|
||||
return rolePermission?.Scope;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@ namespace OmsorgCore.Application.Services;
|
||||
public class RoleService : IRoleService
|
||||
{
|
||||
private readonly IRoleRepository _roleRepository;
|
||||
private readonly IPermissionService _permissionService;
|
||||
|
||||
public RoleService(IRoleRepository roleRepository)
|
||||
public RoleService(IRoleRepository roleRepository, IPermissionService permissionService)
|
||||
{
|
||||
_roleRepository = roleRepository;
|
||||
_permissionService = permissionService;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Role>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
@@ -40,7 +42,7 @@ public class RoleService : IRoleService
|
||||
|
||||
public async Task<UpdateRolePermissionsResult> UpdatePermissionsAsync(
|
||||
Guid roleId,
|
||||
IReadOnlyList<(ModuleType Module, PermissionAction Action)> permissions,
|
||||
IReadOnlyList<(ModuleType Module, PermissionAction Action, PermissionScope Scope)> permissions,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var role = await _roleRepository.GetByIdWithPermissionsAsync(roleId, cancellationToken);
|
||||
@@ -52,11 +54,12 @@ public class RoleService : IRoleService
|
||||
role.RolePermissions.Clear();
|
||||
await _roleRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var newPermissions = permissions.Distinct()
|
||||
.Select(p => new RolePermission { RoleId = role.Id, Module = p.Module, Action = p.Action })
|
||||
var newPermissions = permissions.DistinctBy(p => (p.Module, p.Action))
|
||||
.Select(p => new RolePermission { RoleId = role.Id, Module = p.Module, Action = p.Action, Scope = p.Scope })
|
||||
.ToList();
|
||||
await _roleRepository.AddPermissionRangeAsync(newPermissions, cancellationToken);
|
||||
await _roleRepository.SaveChangesAsync(cancellationToken);
|
||||
await _permissionService.InvalidateRolePermissionsAsync(roleId, cancellationToken);
|
||||
return UpdateRolePermissionsResult.Ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public enum SubmitTimeEntryFailureReason
|
||||
{
|
||||
NotFound,
|
||||
NoSelfServiceTransition
|
||||
}
|
||||
|
||||
public class SubmitTimeEntryResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public SubmitTimeEntryFailureReason? FailureReason { get; init; }
|
||||
public TimeEntry? TimeEntry { get; init; }
|
||||
|
||||
public static SubmitTimeEntryResult Fail(SubmitTimeEntryFailureReason reason) => new()
|
||||
{
|
||||
Success = false,
|
||||
FailureReason = reason
|
||||
};
|
||||
|
||||
public static SubmitTimeEntryResult Ok(TimeEntry timeEntry) => new()
|
||||
{
|
||||
Success = true,
|
||||
TimeEntry = timeEntry
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class TimeEntryService : ITimeEntryService
|
||||
{
|
||||
private const string StatusListKey = "TimeEntryStatus";
|
||||
|
||||
private readonly ITimeEntryRepository _timeEntryRepository;
|
||||
private readonly IOrderRepository _orderRepository;
|
||||
private readonly IPermissionService _permissionService;
|
||||
private readonly ICurrentUserService _currentUserService;
|
||||
private readonly IValueListRepository _valueListRepository;
|
||||
|
||||
public TimeEntryService(
|
||||
ITimeEntryRepository timeEntryRepository,
|
||||
IOrderRepository orderRepository,
|
||||
IPermissionService permissionService,
|
||||
ICurrentUserService currentUserService,
|
||||
IValueListRepository valueListRepository)
|
||||
{
|
||||
_timeEntryRepository = timeEntryRepository;
|
||||
_orderRepository = orderRepository;
|
||||
_permissionService = permissionService;
|
||||
_currentUserService = currentUserService;
|
||||
_valueListRepository = valueListRepository;
|
||||
}
|
||||
|
||||
public async Task<(IReadOnlyList<TimeEntry> Items, int TotalCount)> GetPagedAsync(
|
||||
Guid? statusId,
|
||||
Guid? employeeId,
|
||||
Guid? orderId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Guid? restrictToEmployeeId = await ResolveOwnScopeRestrictionAsync(PermissionAction.View, cancellationToken);
|
||||
return await _timeEntryRepository.GetPagedAsync(statusId, employeeId, orderId, page, pageSize, cancellationToken, restrictToEmployeeId);
|
||||
}
|
||||
|
||||
public async Task<TimeEntry?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var timeEntry = await _timeEntryRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (timeEntry is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Guid? restrictToEmployeeId = await ResolveOwnScopeRestrictionAsync(PermissionAction.View, cancellationToken);
|
||||
if (restrictToEmployeeId.HasValue && timeEntry.EmployeeId != restrictToEmployeeId.Value)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return timeEntry;
|
||||
}
|
||||
|
||||
public async Task<TimeEntry?> CreateAsync(TimeEntry timeEntry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (await _orderRepository.GetByIdAsync(timeEntry.OrderId, cancellationToken) is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Guid? restrictToEmployeeId = await ResolveOwnScopeRestrictionAsync(PermissionAction.Create, cancellationToken);
|
||||
if (restrictToEmployeeId.HasValue)
|
||||
{
|
||||
if (restrictToEmployeeId.Value == Guid.Empty)
|
||||
{
|
||||
// Own-Scope, aber kein User.EmployeeId verknüpft - fail-closed (analog AbsenceService).
|
||||
return null;
|
||||
}
|
||||
|
||||
timeEntry.EmployeeId = restrictToEmployeeId.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
var ownEmployeeId = _currentUserService.EmployeeId;
|
||||
if (ownEmployeeId is null || ownEmployeeId == Guid.Empty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
timeEntry.EmployeeId = ownEmployeeId.Value;
|
||||
}
|
||||
|
||||
var initialStatus = await _valueListRepository.GetInitialItemAsync(StatusListKey, cancellationToken)
|
||||
?? throw new InvalidOperationException("Kein initialer Zeiterfassungsstatus konfiguriert (DbSeeder.SeedValueListsAsync fehlt).");
|
||||
timeEntry.StatusId = initialStatus.Id;
|
||||
|
||||
await _timeEntryRepository.AddAsync(timeEntry, cancellationToken);
|
||||
await _timeEntryRepository.SaveChangesAsync(cancellationToken);
|
||||
return timeEntry;
|
||||
}
|
||||
|
||||
public async Task<UpdateTimeEntryResult> UpdateAsync(Guid id, TimeEntry updates, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var timeEntry = await _timeEntryRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (timeEntry is null)
|
||||
{
|
||||
return UpdateTimeEntryResult.Fail(UpdateTimeEntryFailureReason.NotFound);
|
||||
}
|
||||
|
||||
Guid? restrictToEmployeeId = await ResolveOwnScopeRestrictionAsync(PermissionAction.Edit, cancellationToken);
|
||||
if (restrictToEmployeeId.HasValue && timeEntry.EmployeeId != restrictToEmployeeId.Value)
|
||||
{
|
||||
return UpdateTimeEntryResult.Fail(UpdateTimeEntryFailureReason.NotFound);
|
||||
}
|
||||
|
||||
var currentStatus = await _valueListRepository.GetItemByIdAsync(timeEntry.StatusId, cancellationToken);
|
||||
if (currentStatus is null || !currentStatus.IsEditableByOwner)
|
||||
{
|
||||
return UpdateTimeEntryResult.Fail(UpdateTimeEntryFailureReason.NotEditable);
|
||||
}
|
||||
|
||||
if (updates.OrderId != timeEntry.OrderId && await _orderRepository.GetByIdAsync(updates.OrderId, cancellationToken) is null)
|
||||
{
|
||||
return UpdateTimeEntryResult.Fail(UpdateTimeEntryFailureReason.OrderNotFound);
|
||||
}
|
||||
|
||||
timeEntry.OrderId = updates.OrderId;
|
||||
timeEntry.Date = updates.Date;
|
||||
timeEntry.Start = updates.Start;
|
||||
timeEntry.End = updates.End;
|
||||
timeEntry.BreakDuration = updates.BreakDuration;
|
||||
timeEntry.NightHours = updates.NightHours;
|
||||
timeEntry.SaturdayHours = updates.SaturdayHours;
|
||||
timeEntry.SundayHours = updates.SundayHours;
|
||||
timeEntry.HolidayHours = updates.HolidayHours;
|
||||
timeEntry.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _timeEntryRepository.UpdateAsync(timeEntry, cancellationToken);
|
||||
await _timeEntryRepository.SaveChangesAsync(cancellationToken);
|
||||
return UpdateTimeEntryResult.Ok(timeEntry);
|
||||
}
|
||||
|
||||
public async Task<SubmitTimeEntryResult> SubmitAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var timeEntry = await _timeEntryRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (timeEntry is null)
|
||||
{
|
||||
return SubmitTimeEntryResult.Fail(SubmitTimeEntryFailureReason.NotFound);
|
||||
}
|
||||
|
||||
Guid? restrictToEmployeeId = await ResolveOwnScopeRestrictionAsync(PermissionAction.Edit, cancellationToken);
|
||||
if (restrictToEmployeeId.HasValue && timeEntry.EmployeeId != restrictToEmployeeId.Value)
|
||||
{
|
||||
return SubmitTimeEntryResult.Fail(SubmitTimeEntryFailureReason.NotFound);
|
||||
}
|
||||
|
||||
var transition = await _valueListRepository.GetSelfServiceTransitionAsync(timeEntry.StatusId, cancellationToken);
|
||||
if (transition is null)
|
||||
{
|
||||
return SubmitTimeEntryResult.Fail(SubmitTimeEntryFailureReason.NoSelfServiceTransition);
|
||||
}
|
||||
|
||||
timeEntry.StatusId = transition.ToItemId;
|
||||
timeEntry.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _timeEntryRepository.UpdateAsync(timeEntry, cancellationToken);
|
||||
await _timeEntryRepository.SaveChangesAsync(cancellationToken);
|
||||
return SubmitTimeEntryResult.Ok(timeEntry);
|
||||
}
|
||||
|
||||
public async Task<DecideTimeEntryResult> DecideAsync(Guid id, Guid statusId, string? adminNote, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var timeEntry = await _timeEntryRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (timeEntry is null)
|
||||
{
|
||||
return DecideTimeEntryResult.Fail(DecideTimeEntryFailureReason.NotFound);
|
||||
}
|
||||
|
||||
var transition = await _valueListRepository.GetTransitionAsync(timeEntry.StatusId, statusId, cancellationToken);
|
||||
if (transition is null || !transition.RequiresApproval)
|
||||
{
|
||||
return DecideTimeEntryResult.Fail(DecideTimeEntryFailureReason.InvalidStatusTransition);
|
||||
}
|
||||
|
||||
timeEntry.StatusId = statusId;
|
||||
timeEntry.AdminNote = adminNote;
|
||||
timeEntry.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _timeEntryRepository.UpdateAsync(timeEntry, cancellationToken);
|
||||
await _timeEntryRepository.SaveChangesAsync(cancellationToken);
|
||||
return DecideTimeEntryResult.Ok(timeEntry);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var deleted = await _timeEntryRepository.SoftDeleteAsync(id, cancellationToken);
|
||||
if (deleted)
|
||||
{
|
||||
await _timeEntryRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<TimeEntry>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default)
|
||||
=> _timeEntryRepository.GetDeletedAsync(search, cancellationToken);
|
||||
|
||||
public async Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var restored = await _timeEntryRepository.RestoreAsync(id, cancellationToken);
|
||||
if (restored)
|
||||
{
|
||||
await _timeEntryRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return restored;
|
||||
}
|
||||
|
||||
/// <summary>Own-Scope-Filterung, siehe AbsenceService.ResolveOwnScopeRestrictionAsync (analoges Muster für ModuleType.TimeEntries).</summary>
|
||||
private async Task<Guid?> ResolveOwnScopeRestrictionAsync(PermissionAction action, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_currentUserService.UserId is not { } userId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var scope = await _permissionService.GetScopeAsync(userId, ModuleType.TimeEntries, action, cancellationToken);
|
||||
return scope == PermissionScope.Own ? (_currentUserService.EmployeeId ?? Guid.Empty) : null;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user