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

- 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:
Felix Kemmler
2026-08-10 17:42:45 +02:00
co-authored by Claude Sonnet 5
parent e9e96a57dc
commit 598dfcd38a
461 changed files with 59753 additions and 2859 deletions
+8
View File
@@ -0,0 +1,8 @@
**/node_modules
**/dist
**/bin
**/obj
omsorgCore/**/App_Data
.git
.gitea
*.log
+55
View File
@@ -0,0 +1,55 @@
name: Docker-Images bauen und veröffentlichen
on:
push:
branches: [main]
tags: ["v*"]
env:
REGISTRY: git.omsorg-pflegedienste.de
IMAGE_NAMESPACE: admin/omsorg
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- image: omsorgcore
dockerfile: omsorgCore/Dockerfile
build_args: ""
- image: omsorgapp
dockerfile: omsorgapp/Dockerfile
build_args: "VITE_OMSORG_CORE_URL=${{ vars.OMSORG_CORE_PUBLIC_URL }}"
- image: omsorgweb
dockerfile: omsorgWeb/Dockerfile
build_args: ""
steps:
- name: Code auschecken
uses: actions/checkout@v4
- name: Bei Registry anmelden
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u "${{ gitea.actor }}" --password-stdin
- name: Image bauen und pushen
run: |
BUILD_ARGS=""
if [ -n "${{ matrix.build_args }}" ]; then
BUILD_ARGS="--build-arg ${{ matrix.build_args }}"
fi
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/${{ matrix.image }}"
TAGS="-t $IMAGE:latest -t $IMAGE:${{ gitea.sha }}"
# Bei einem Tag-Push (z.B. v0.1.0) zusätzlich mit dem Tag-Namen selbst versionieren,
# damit ein bestimmter Release pinnbar bleibt statt nur :latest/:<sha>.
if [ "${{ gitea.ref_type }}" = "tag" ]; then
TAGS="$TAGS -t $IMAGE:${{ gitea.ref_name }}"
fi
docker buildx build \
--push \
-f "${{ matrix.dockerfile }}" \
$TAGS \
$BUILD_ARGS \
.
+10
View File
@@ -23,3 +23,13 @@ Thumbs.db
# .NET (omsorgCore)
omsorgCore/**/bin/
omsorgCore/**/obj/
omsorgCore/**/App_Data/
# Lokal hochgeladene Dokumente (Storage:DocumentsRootPath, appsettings.Development.json) - Laufzeitdaten, kein Repo-Inhalt
omsorgCore/src/OmsorgCore.Api/data/
# Zufällig im Repo-Root abgelegte lokale Dateien, kein Projektinhalt
/Arbeitsstunden.ods
/.~lock.*#
# VS-Code-Extension-Report (Codezeilen-Statistik), kein Projektinhalt
/.VSCodeCounter/
+1 -1
View File
@@ -9,7 +9,7 @@ Ein Monorepo für die OMSORG-Plattform, einer Software für ein Pflege-Zeitarbei
| Verzeichnis | Rolle | Stack | Ist-Stand |
|---|---|---|---|
| `omsorgWeb/` | Öffentliche Website + **OMSORG Connect** (Mitarbeiter-App für Außendienst) | PHP, MySQL, vanilla JS, PWA | ✅ produktiv im Einsatz |
| `omsorgapp/` | **OMSORG Desktop** — Software für Büromitarbeiter (Sabina, Malik, Sabrina, Sascha) | Electron + React + Vite | 🔶 frühes Grundgerüst (Release 0.1.1), lokale JSON-Datenhaltung |
| `omsorgapp/` | **OMSORG Desktop** — Software für Büromitarbeiter (Sabina, Malik, Sabrina, Sascha), als Browser-Tab genutzt (kein Electron) | React + Vite | 🔶 frühes Grundgerüst (Release 0.1.1), Daten kommen bereits über `omsorgCore` (kein lokaler Datenspeicher mehr) |
| `omsorgCore/` | **OMSORG Backend** — gemeinsame Datenbasis + Automatisierung (Core + Engine als eine Komponente) | C# / .NET 8, ASP.NET Core (Controller), PostgreSQL/EF Core, JWT-Auth | 🔶 Grundgerüst steht (Domain/Application/Infrastructure/Engine/Api, Rechtesystem, Auth, Employee-Endpoint end-to-end verifiziert); noch keine Anbindung von `omsorgWeb`/`omsorgapp`, DB-Migration noch nicht gegen echte Postgres getestet |
Leitdokumente:
+21 -17
View File
@@ -41,7 +41,7 @@ Umfasst alle drei Plattform-Ebenen: OMSORG Desktop, OMSORG Connect, OMSORG Backe
|---|---|---|
| **OMSORG Desktop** | Vollständige Unternehmensmodule für Büromitarbeiter | 🔶 `omsorgapp/` — Electron/React, Release 0.1.1. Vorhanden: `HomePage`, `HomeStats`, `ContractWidget`, `EmployeesPage` mit Tabs/Detailpanel. Lokale JSON-DB (`~/Documents/Omsorg Business Controls Pro/database/omsorg-local-db.json`), noch keine SQLite/Server-Anbindung. |
| **OMSORG Connect** | Mobile/Web-App für Außendienst | ✅ `omsorgWeb/mitarbeiter-app/` — PHP/MySQL, produktiv als PWA (Manifest + Service Worker). Deckt bereits Zeiterfassung/Stundennachweis, Urlaub, Abwesenheit, Fortbildung, Dokumente, News, Benefits, Werben, Einsatzanweisung, Dienstplan, Bewertungen ab. |
| **OMSORG Backend (Core + Engine)** | Eine Backend-Komponente, zwei interne Schichten: **Datenschicht (Core)** — gemeinsame Datenbasis, 6 Objekte; **Event-Schicht (Engine)** — Ereignis→Aktion-Automatisierung ohne eigene UI, arbeitet auf denselben Objekten der Datenschicht | 🔶 `omsorgCore/` — Grundgerüst steht (C#/.NET 8, ASP.NET Core Controller, EF Core/PostgreSQL, JWT-Auth, Rollen+Permission-Override-Rechtesystem, In-Process-Event-Dispatcher). `Employee`, `Facility` (+ `FacilityContact`), `Contract` und `Order` haben inzwischen volles Repository/Service/Controller; `TimeEntry`/`Invoice` existieren weiterhin nur als Domain-Entitäten, aber ohne Endpunkte. `omsorgapp` ist für Mitarbeiter jetzt **echt angebunden** (Login/Refresh sowie Employees-CRUD laufen über `omsorgCore`, keine lokale JSON-Datenhaltung mehr für dieses Modul), ebenso Kunden/Einrichtungen (`FacilitiesPage`). **Noch keine Anbindung** von `omsorgWeb` (MySQL) an dieses Backend, und die übrigen `omsorgapp`-Fachmodule (Disposition, ...) nutzen weiterhin die lokale JSON-DB — die Insellösungen bestehen dort technisch weiter, bis diese Migration erfolgt. DB-Migration wurde gegen eine echte PostgreSQL-Instanz verifiziert (siehe `omsorgCore/CLAUDE.md`, "Verifiziert"); die neueste Migration (`Order`/Statuspipeline) noch nicht. Details: `omsorgCore/CLAUDE.md`. |
| **OMSORG Backend (Core + Engine)** | Eine Backend-Komponente, zwei interne Schichten: **Datenschicht (Core)** — gemeinsame Datenbasis, 6 Objekte; **Event-Schicht (Engine)** — Ereignis→Aktion-Automatisierung ohne eigene UI, arbeitet auf denselben Objekten der Datenschicht | 🔶 `omsorgCore/` — Grundgerüst steht (C#/.NET 8, ASP.NET Core Controller, EF Core/PostgreSQL, JWT-Auth, Rollen+Permission-Override-Rechtesystem, In-Process-Event-Dispatcher). `Employee`, `Facility` (+ `FacilityContact`), `Contract`, `Order`, `Absence` und `TimeEntry` haben inzwischen volles Repository/Service/Controller; `Invoice` existiert weiterhin nur als Domain-Entität, aber ohne Endpunkte. `omsorgapp` ist für Mitarbeiter jetzt **echt angebunden** (Login/Refresh sowie Employees-CRUD laufen über `omsorgCore`, keine lokale JSON-Datenhaltung mehr für dieses Modul), ebenso Kunden/Einrichtungen (`FacilitiesPage`) und Aufträge/Disposition (`OrdersPage`). **Noch keine Anbindung** von `omsorgWeb` (MySQL) an dieses Backend, und die übrigen `omsorgapp`-Fachmodule (Kalkulation, Fahrzeuge, Rechnungen, Controlling) sind weiterhin reine `PlaceholderPage`-Stubs ohne Datenhaltung. DB-Migration wurde gegen eine echte PostgreSQL-Instanz verifiziert (siehe `omsorgCore/CLAUDE.md`, "Verifiziert"); die neueste Migration (`Order`/Statuspipeline) noch nicht. Details: `omsorgCore/CLAUDE.md`. |
**Kernrisiko für die Roadmap:** Solange das Backend (Core + Engine) nicht existiert, sind Connect (MySQL) und Desktop (JSON) zwei Insellösungen — genau das, was Blueprint Kap. 19.9 ausschließt. Phase 1 der Roadmap muss dies zuerst auflösen (siehe Abschnitt 10).
@@ -75,29 +75,29 @@ Umfasst alle drei Plattform-Ebenen: OMSORG Desktop, OMSORG Connect, OMSORG Backe
| ID | Anforderung | Akteur | Akzeptanzkriterium | Status |
|---|---|---|---|---|
| FR-MA-1 | Stammdaten erfassen (Name, Geburtsdatum, Adresse, Kontakt, Notfallkontakt, Beschäftigungsart, Qualifikation, Ein-/Austritt, Status) [Blueprint 19.1] | Sabina, Malik, Sabrina | Datensatz anlegen/bearbeiten mit Pflichtfeldern; Validierung verhindert unvollständige Sätze | ✅ (alle Felder in `Employee`-Entity + `EmployeeForm` in `omsorgapp` vorhanden; Validierung serverseitig in `EmployeesController` (Pflichtfelder, Längen, Ein-/Austrittslogik, Beschäftigungsart-Allowlist) und clientseitig verdrahtet; `omsorgapp` spricht für Mitarbeiter jetzt über `employeesClient.cjs` echt gegen `omsorgCore`, keine JSON-lokale Persistenz mehr für dieses Modul — Abgleich mit Connect/`omsorgWeb` weiterhin offen, siehe FR-CORE-1) |
| FR-MA-2 | Arbeitsvertragsdaten (Beginn/Ende, Arbeitszeit, Stundenlohn, Zuschläge, Überstunden, Urlaubsanspruch, Probezeit) [Blueprint 19.1] | Sabina, Malik, Sabrina | Vertragsfelder je Mitarbeiter editierbar, Historie bei Änderung nachvollziehbar | 🔶 (`Contract`-Entity in `omsorgCore` um alle geforderten Felder erweitert, volles Repository/Service/Controller (`ContractsController`, `GET/POST/PUT /api/contracts`) nach dem Facility-Muster, gegated über `[RequirePermission(ModuleType.Contracts, ...)]`; Änderungshistorie automatisch über den generischen `AuditSaveChangesInterceptor` abgedeckt — noch kein `omsorgapp`-UI-Modul dafür) |
| FR-MA-3 | Dokumente, Qualifikationen, Fortbildungen, Führerschein, Gesundheitsnachweise, Notizen, Historie je Mitarbeiter | Sabina, Malik, Sabrina | Upload/Anzeige je Kategorie, Zugriffsprotokoll | 🔶 (Dokumentenarchiv existiert bereits in Connect: `pages/dokumentenarchiv.php`, `actions/upload-dokument.php`; im Desktop-Modul fehlt es) |
| FR-MA-2 | Arbeitsvertragsdaten (Beginn/Ende, Arbeitszeit, Stundenlohn, Zuschläge, Überstunden, Urlaubsanspruch, Probezeit) [Blueprint 19.1] | Sabina, Malik, Sabrina | Vertragsfelder je Mitarbeiter editierbar, Historie bei Änderung nachvollziehbar | (`Contract`-Entity in `omsorgCore` um alle geforderten Felder erweitert, volles Repository/Service/Controller (`ContractsController`, `GET/POST/PUT/DELETE /api/contracts`) nach dem Facility-Muster, gegated über `[RequirePermission(ModuleType.Contracts, ...)]`; Änderungshistorie automatisch über den generischen `AuditSaveChangesInterceptor` abgedeckt; `omsorgapp`-UI jetzt vorhanden — "Verträge"-Tab in `EmployeeDetailPanel` (`ContractsList`/`ContractForm`/`Create-`/`EditContractDialog.jsx`), Anlegen/Bearbeiten/Löschen funktionsfähig gegen `omsorgCore`, neue Verträge starten als "Entwurf", Statuswechsel nur im Bearbeiten-Formular) |
| FR-MA-3 | Dokumente, Qualifikationen, Fortbildungen, Führerschein, Gesundheitsnachweise, Notizen, Historie je Mitarbeiter | Sabina, Malik, Sabrina | Upload/Anzeige je Kategorie, Zugriffsprotokoll | (Backend: `omsorgCore` `DocumentsController`/`Document`-Entität, Dateien auf Disk + Metadaten in Postgres, Zugriffsprotokoll über `AuditEvent "DocumentDownloaded"`; Frontend: "Dokumente"-Tab in der Personalakte (`omsorgapp`, `DocumentsList`/`UploadDocumentDialog`/`EditDocumentDialog`/`DocumentViewerDialog`) mit Upload, Bearbeiten, In-App-Vorschau (PDF/Bild) und Download je Kategorie, rechtegegated — siehe `omsorgCore/CLAUDE.md`/`omsorgapp/CLAUDE.md` "Dokumentenarchiv"/"Dokumente". Offen: `omsorgWeb`-Insellösung (`pages/dokumentenarchiv.php`) ist noch nicht abgelöst/migriert, siehe FR-CORE-1) |
| FR-MA-4 | Eintrittsdatum löst Dashboard-Hinweis auf bevorstehenden Mitarbeiterstart aus [Blueprint 7] | alle Büro-Rollen | X Tage vor Eintritt erscheint Engine-Hinweis im Dashboard | ⬜ (abhängig von Engine, siehe 4.10) |
| FR-MA-5 | Bei Büromitarbeitern kann der Mitarbeiterdatensatz mit einem Benutzerkonto + individuellen Rechten verknüpft werden [Blueprint 19.1] | Sabina, Malik | Rechteliste pro Modul (sehen/anlegen/bearbeiten/löschen/exportieren/freigeben) editierbar | 🔶 (`users`-Tabelle mit Rolle in `omsorgWeb/mitarbeiter-app` vorhanden, aber nur grobe Rolle, keine granularen Einzelrechte) |
| FR-MA-5 | Bei Büromitarbeitern kann der Mitarbeiterdatensatz mit einem Benutzerkonto + individuellen Rechten verknüpft werden [Blueprint 19.1] | Sabina, Malik | Rechteliste pro Modul (sehen/anlegen/bearbeiten/löschen/exportieren/freigeben) editierbar | (in `omsorgCore`, nicht mehr `omsorgWeb`: `User.EmployeeId` verknüpft ein Benutzerkonto mit dem `Employee`-Datensatz, `UsersController.Create`/`UserService.CreateForEmployeeAsync` erzwingt genau 1 Konto je Mitarbeiter; Rechte sind granular pro Modul × Aktion — Rolle als Vorlage (`RolePermission`) plus individuelle Grant/Revoke-Ausnahmen je Nutzer (`UserPermissionOverride`), inkl. `Recover` seit dem Soft-Delete-Schritt; Admin-UI in `omsorgapp`: `RolesPanel`/`RolePermissionMatrix`/`UserOverridesPanel` unter "Einstellungen") |
| FR-MA-6 | Außendienstmitarbeiter sehen ausschließlich eigene Daten [Blueprint 4.2] | Außendienst | Query/Route liefert nur Datensätze mit `user_id = aktueller Nutzer` | ✅ (Session-basierter Zugriff in `omsorgWeb/mitarbeiter-app`, z. B. `pages/urlaubsantrag.php`) |
### 4.3 Einrichtungen & CRM
| ID | Anforderung | Akteur | Akzeptanzkriterium | Status |
|---|---|---|---|---|
| FR-EIN-1 | Stammdaten (Name, Art, Adresse, Rechnungsadresse, Telefon, E-Mail, Website, Status) [Blueprint 19.2] | Sabina, Malik, Sabrina, Sascha (Leads) | Datensatz anlegen/bearbeiten | 🔶 (`Facility`-Entity + volles Repository/Service/Controller (`FacilitiesController`, `GET/POST/PUT /api/facilities`) in `omsorgCore` vorhanden, serverseitige Rechteprüfung über `[RequirePermission(ModuleType.Facilities, ...)]`; Felder Name/Art(`FacilityType`)/Adresse/Rechnungsadresse/**Website**/Status(`CrmStatus`) abgedeckt — Adresse und Rechnungsadresse sind je strukturierte Felder (Straße/PLZ/Ort/Land, analog Mitarbeiter-Adresse), kein Freitext; UI-Modul in `omsorgapp` vorhanden (`FacilitiesPage`, Sidebar-Tab "Kunden", analog `EmployeesPage`) — Anlegen/Bearbeiten funktionsfähig gegen `omsorgCore`; **Telefon/E-Mail sind entgegen dieser Zeile bisher nicht als eigene `Facility`-Felder modelliert** — nur `FacilityContact` (FR-EIN-2) trägt Telefon/E-Mail je Ansprechpartner, es gibt kein allgemeines Einrichtungs-Telefon/-E-Mail; das war schon vor dieser Änderung so dokumentiert, aber sachlich falsch — noch zu klären/nachzuziehen) |
| FR-EIN-2 | Mehrere Ansprechpartner je Einrichtung mit Funktion, Abteilung, Kontaktwegen, Notizen | Sabina, Malik, Sabrina, Sascha | Liste von Ansprechpartnern editierbar, mind. 1:n-Beziehung | 🔶 (neue Entität `FacilityContact` (1:n zu `Facility`) in `omsorgCore`, `FacilityContactsController` unter `GET/POST /api/facilities/{facilityId}/contacts`, `PUT .../contacts/{id}`, gegated über dieselben `ModuleType.Facilities`-Rechte wie die Einrichtung selbst; Felder Name/Funktion(`Role`)/Abteilung(`Department`)/Telefon/E-Mail/Notizen abgedeckt; UI-Liste im `FacilityDetailPanel` (`omsorgapp`) anlegen/bearbeiten funktionsfähig; **kein Löschen** — konsistent mit dem noch fehlenden Soft-Delete für die übrigen Core-Objekte, siehe `omsorgCore/CLAUDE.md` "Offene Punkte") |
| FR-EIN-3 | CRM-Status-Pipeline: Lead → kontaktiert → kein Bedarf → Wiedervorlage → Interesse → Angebot → Kunde → Bestandskunde [Blueprint, omsorg.md] | Sascha, Sabrina | Statuswechsel wird protokolliert; bei „kein Bedarf" wird automatisch Wiedervorlage in 14 Tagen erzeugt | |
| FR-EIN-4 | Konditionen (Verrechnungssatz, Zuschläge, Fahrtkosten, Mindeststunden, Zahlungsziel etc.) je Einrichtung [Blueprint 19.2] | Sabina, Malik, Sabrina | Konditionssatz ist Grundlage für Rechnungserstellung (siehe FR-RE-1) | |
| FR-EIN-1 | Stammdaten (Name, Art, Adresse, Rechnungsadresse, Telefon, E-Mail, Website, Status) [Blueprint 19.2] | Sabina, Malik, Sabrina, Sascha (Leads) | Datensatz anlegen/bearbeiten | (`Facility`-Entity + volles Repository/Service/Controller (`FacilitiesController`, `GET/POST/PUT /api/facilities`) in `omsorgCore`, serverseitige Rechteprüfung über `[RequirePermission(ModuleType.Facilities, ...)]`; Felder Name/Art(`FacilityType`)/Adresse/Rechnungsadresse/Website/Status(`CrmStatus`) abgedeckt — Adresse und Rechnungsadresse sind je strukturierte Felder (Straße/PLZ/Ort/Land, analog Mitarbeiter-Adresse), kein Freitext; UI-Modul in `omsorgapp` vorhanden (`FacilitiesPage`, Sidebar-Tab "Kunden", analog `EmployeesPage`) — Anlegen/Bearbeiten funktionsfähig gegen `omsorgCore`. **Bewusste Abweichung von der Zeile:** kein allgemeines Telefon/E-Mail direkt auf `Facility` — Kontaktwege leben ausschließlich pro Ansprechpartner auf `FacilityContact` (FR-EIN-2), da eine Einrichtung i. d. R. mehrere Ansprechpartner mit je eigenen Kontaktdaten hat und ein zusätzliches, nicht klar zuordenbares "allgemeines" Einrichtungstelefon eine zweite, redundante Kontakt-Datenquelle wäre (widerspricht dem Grundsatz "jede Information wird nur einmal gespeichert", siehe Root-`CLAUDE.md`). Diese Entscheidung ist absichtlich, kein offener Punkt.) |
| FR-EIN-2 | Mehrere Ansprechpartner je Einrichtung mit Funktion, Abteilung, Kontaktwegen, Notizen | Sabina, Malik, Sabrina, Sascha | Liste von Ansprechpartnern editierbar, mind. 1:n-Beziehung | (neue Entität `FacilityContact` (1:n zu `Facility`) in `omsorgCore`, `FacilityContactsController` unter `GET/POST/PUT/DELETE /api/facilities/{facilityId}/contacts[/...]`, gegated über dieselben `ModuleType.Facilities`-Rechte wie die Einrichtung selbst; Felder Name/Funktion(`Role`)/Abteilung(`Department`)/Telefon/E-Mail/Notizen abgedeckt; UI-Liste im `FacilityDetailPanel` (`omsorgapp`) anlegen/bearbeiten/löschen funktionsfähig; Löschen ist Soft-Delete (`IsDeleted`/`DeletedAt`) und über die neue "Papierkorb"-Seite (`ModuleType.Facilities`+`PermissionAction.Recover`) wiederherstellbar) |
| FR-EIN-3 | CRM-Status-Pipeline: Lead → kontaktiert → kein Bedarf → Wiedervorlage → Interesse → Angebot → Kunde → Bestandskunde [Blueprint, omsorg.md] | Sascha, Sabrina | Statuswechsel wird protokolliert; bei „kein Bedarf" wird automatisch Wiedervorlage in 14 Tagen erzeugt | ✅ (Statuswechsel-Protokollierung läuft automatisch über den bestehenden `AuditSaveChangesInterceptor`, kein Zusatzcode nötig; neues Feld `Facility.FollowUpDueDate` (nullable), gesetzt über `FacilitiesController.Update`, wenn der neu gewählte `CrmStatus`-Wert das Flag `TriggersFollowUp` trägt (aktuell nur "Kein Bedarf" — generisch statt hartcodiert, damit künftig weitere Status dieselbe Mechanik nutzen können) — serverseitig muss dann ein `FollowUpDays` aus der admin-editierbaren `ValueList` `"FollowUpPeriods"` (7/14/21/31 Tage, Default 14) mitgegeben werden; `omsorgapp` zeigt dafür beim Statuswechsel auf einen solchen Status einen Auswahldialog (`FollowUpDaysDialog.jsx` in `EditFacilityDialog.jsx`, 14 Tage vorausgewählt) und das Fälligkeitsdatum im `FacilityDetailPanel`; Dashboard-Widget "Fällige Wiedervorlagen" (`FollowUpWidget.jsx`, in `HomePage.jsx` eingebunden) zeigt fällige Einrichtungen an; die Statuspipeline erzwingt wie bei `OrderStatus` erlaubte Übergänge (`ValueListItemTransition` für `"CrmStatus"`, geseedet mit der Vorwärtskette der Pipeline plus jederzeitigem Rückfall auf "Kein Bedarf"/"Wiedervorlage", admin-editierbar über dieselbe "Erlaubte Übergänge"-Matrix wie beim Auftragsstatus, `FacilitiesController.Update` prüft serverseitig via `CanTransitionAsync`, das CRM-Status-Dropdown in `FacilityForm.jsx` (nur im `EditFacilityDialog`) zeigt dem Nutzer dabei ebenfalls nur den aktuellen Status plus die laut `GET /api/value-lists/CrmStatus/transitions` erlaubten Zielstatus an — vorher listete das Dropdown ungefiltert alle CRM-Status auf, ein unzulässiger Wechsel wurde erst nach "Speichern" per 400-Fehler vom Server abgelehnt). Die wählbare Frist statt einer starren 14-Tage-Automatik ist eine bewusste, finale Designentscheidung — kein offener Punkt.) |
| FR-EIN-4 | Konditionen (Verrechnungssatz, Zuschläge, Fahrtkosten, Mindeststunden, Zahlungsziel etc.) je Einrichtung [Blueprint 19.2] | Sabina, Malik, Sabrina | Konditionssatz ist Grundlage für Rechnungserstellung (siehe FR-RE-1) | ✅ (Backend: elf der zwölf Blueprint-19.2-Felder als flache, nullable Spalten auf `Facility` — Verrechnungssatz, vier Zuschläge (als Prozent), Fahrtkosten (Pauschale je Einsatz), Mindeststunden, Pausenregelung, Abrechnungsintervall (validiert gegen neue `ValueList` `"BillingInterval"`), Zahlungsziel, individuelle Vereinbarungen —, nur über `PUT /api/facilities/{id}` pflegbar; qualifikationsabhängige Preise als neue 1:n-Unterressource `FacilityQualificationRate` (`GET/POST/PUT/DELETE /api/facilities/{facilityId}/qualification-rates[/...]`, Qualifikation gegen die bestehende `ValueList` `"Qualification"` validiert) nach dem `FacilityContact`-Muster, inkl. Soft-Delete/Papierkorb; Migration `AddFacilityConditionsAndQualificationRates` erfolgreich gegen die echte Postgres-Instanz angewendet (automatisch beim API-Start); `omsorgapp`-UI (Konditionen-Fieldset in `FacilityForm.jsx`, `FacilityQualificationRatesList` im `FacilityDetailPanel`) vorhanden, `omsorgapp/api-client-ts` regeneriert und gebaut (Methodennamen/Feldnamen gegen den generierten Client verifiziert). Akzeptanzkriterium "Grundlage für Rechnungserstellung" im engeren Sinn bewusst weiterhin nicht vollständig erfüllt, da FR-RE-1/`Invoice` diese Daten noch nicht konsumiert — dieser Schritt legt nur die Datenbasis.) |
| FR-EIN-5 | Verknüpfte Historie: Verträge, Aufträge, zugewiesene Mitarbeiter, Nachweise, Rechnungen, Zahlungen, Mahnungen, Kommunikation | alle Büro-Rollen (rechteabhängig) | Einrichtungsakte zeigt konsolidierte Historie ohne Datenduplizierung | ⬜ |
### 4.4 Einsatzmanagement (Auftrag → Einsatz → Zuweisung)
| ID | Anforderung | Akteur | Akzeptanzkriterium | Status |
|---|---|---|---|---|
| FR-EM-1 | Auftrag erfassen (Einrichtung, Ansprechpartner, Qualifikation, Zeitraum, Schichtart, Anzahl Mitarbeiter, Konditionen, Priorität) [Blueprint 19.4] | Sabrina | Auftragsdatensatz mit Pflichtfeldern anlegbar | 🔶 (`Order`-Entity in `omsorgCore` um alle geforderten Felder erweitert, volles Repository/Service/Controller (`OrdersController`, `GET/POST/PUT /api/orders`) nach dem Facility/Contract-Muster, gegated über `[RequirePermission(ModuleType.Orders, ...)]`; Ansprechpartner (`FacilityContactId`) wird gegen die angegebene Einrichtung cross-validiert — noch kein `omsorgapp`-UI-Modul dafür) |
| FR-EM-2 | Auftragsstatus-Pipeline: Anfrage → Prüfung → offen → teilweise besetzt → vollständig besetzt → aktiv → abgeschlossen/storniert [Blueprint 19.4] | Sabrina | Statuswechsel nur in zulässiger Reihenfolge, sichtbar im Dashboard | 🔶 (Pipeline serverseitig erzwungen: `OrderStatusDefinition`/`OrderStatusTransition` in `omsorgCore` bilden die Status und erlaubten Übergänge **DB-konfigurierbar** statt hartcodiert ab, Standard-Pipeline per `DbSeeder.SeedOrderStatusesAsync` geseedet, `OrderService.UpdateAsync` lehnt unzulässige Übergänge mit `400` ab; Dashboard-Sichtbarkeit fehlt noch — kein `omsorgapp`-UI) |
| FR-EM-3 | Mitarbeiterzuweisung prüft Qualifikation, Verfügbarkeit, Arbeitszeit, Abwesenheiten, Überschneidungen, Vertragsbedingungen [Blueprint 19.4] | Sabrina | System verhindert/warnt bei Konflikten vor Zuweisung | ⬜ |
| FR-EM-1 | Auftrag erfassen (Einrichtung, Ansprechpartner, Qualifikation, Zeitraum, Schichtart, Anzahl Mitarbeiter, Konditionen, Priorität) [Blueprint 19.4] | Sabrina | Auftragsdatensatz mit Pflichtfeldern anlegbar | (`Order`-Entity in `omsorgCore` um alle geforderten Felder erweitert, volles Repository/Service/Controller (`OrdersController`, `GET/POST/PUT/DELETE /api/orders`) nach dem Facility/Contract-Muster, gegated über `[RequirePermission(ModuleType.Orders, ...)]`; Ansprechpartner (`FacilityContactId`) wird gegen die angegebene Einrichtung cross-validiert; `omsorgapp`-UI-Modul vorhanden (Sidebar-Tab "Disposition" → `OrdersPage`/`OrderForm`/`OrderDetailPanel`/`Create-`/`EditOrderDialog`, über `src/api/ordersApi.js` echt gegen `omsorgCore`), alle Pflichtfelder inkl. abhängigem Ansprechpartner-Dropdown (lädt Kontakte der gewählten Einrichtung nach) und Qualifikation/Schichtart/Priorität als admin-editierbare Auswahllisten abgedeckt) |
| FR-EM-2 | Auftragsstatus-Pipeline: Anfrage → Prüfung → offen → teilweise besetzt → vollständig besetzt → aktiv → abgeschlossen/storniert [Blueprint 19.4] | Sabrina | Statuswechsel nur in zulässiger Reihenfolge, sichtbar im Dashboard | (Pipeline serverseitig erzwungen: Status und erlaubte Übergänge sind **DB-konfigurierbar** über das generische `ValueList`/`ValueListItem`/`ValueListItemTransition`-Modell (Liste `"OrderStatus"`, gleiches Muster wie CRM-Status bei Facilities, siehe `omsorgCore/CLAUDE.md` "Konfigurierbare Auswahllisten") statt eines eigenen `OrderStatusDefinition`/`OrderStatusTransition`-Modells, Standard-Pipeline per `DbSeeder` geseedet, `OrderService.UpdateAsync` lehnt unzulässige Übergänge über `ValueListRepository.CanTransitionAsync` mit `400` ab; `omsorgapp`-`OrderForm.jsx` filtert das Status-Dropdown im Bearbeiten-Formular serverseitig identisch auf die laut `/api/value-lists/OrderStatus/transitions` erlaubten Zielstatus (kein Client/Server-Auseinanderlaufen); Dashboard-Sichtbarkeit jetzt über `OrderStatusWidget.jsx` in `HomePage.jsx` (Auftragsanzahl je Status, rechtegegated über `hasPermission("Orders","View")`, analog `FollowUpWidget.jsx`)) |
| FR-EM-3 | Mitarbeiterzuweisung prüft Qualifikation, Verfügbarkeit, Arbeitszeit, Abwesenheiten, Überschneidungen, Vertragsbedingungen [Blueprint 19.4] | Sabrina | System verhindert/warnt bei Konflikten vor Zuweisung | ⬜ (weiterhin offen — es gibt noch keine Mitarbeiterzuweisung/Einsatz-Entität, nur den Auftrag selbst. Die Abwesenheitsdatenbasis für den "Abwesenheiten"-Teil der Konfliktprüfung existiert jetzt aber bereits in `omsorgCore` (`Absence`, siehe FR-CON-1/`omsorgCore/CLAUDE.md`) — von der eigentlichen Zuweisungs-Konfliktprüfung wird sie noch nicht konsumiert) |
| FR-EM-4 | Nach Zuweisung erhält Mitarbeiter automatisch Einsatzanweisung über OMSORG Connect [Blueprint 19.4] | System → Außendienst | Einsatzanweisung erscheint in Connect ohne manuellen Zusatzschritt | 🔶 (Connect hat bereits `pages/einsatzanweisung.php` inkl. Admin-Upload `actions/einsatzanweisung-action.php`; automatische Erzeugung aus Zuweisung fehlt, da Aufträge/Zuweisung noch nicht existieren) |
| FR-EM-5 | Krankmeldung löst Ersatzbesetzungs-Workflow aus [omsorg.md, Blueprint 20.2] | System, Sabrina | Bei Krankmeldung erscheint Einsatz als "muss neu besetzt werden" inkl. Vorschlägen | ⬜ |
@@ -105,8 +105,8 @@ Umfasst alle drei Plattform-Ebenen: OMSORG Desktop, OMSORG Connect, OMSORG Backe
| ID | Anforderung | Akteur | Akzeptanzkriterium | Status |
|---|---|---|---|---|
| FR-ZE-1 | Erfassung von Beginn, Pause, Ende, Einrichtung, Auftrag, Nacht-/Wochenend-/Feiertagsstunden [Blueprint 19.5] | Außendienst | Eintrag pro Schicht mit Pflichtfeldern | 🔶 (aktuell nur monatlicher Upload via `pages/stundennachweis.php`/`actions/submit-stundennachweis.php`, keine strukturierte Beginn/Pause/Ende-Erfassung pro Schicht) |
| FR-ZE-2 | Statuspipeline: Entwurf → eingereicht → Prüfung → Rückfrage → freigegeben → abgerechnet [Blueprint 19.5] | Außendienst, Sabrina | Statuswechsel sichtbar für beide Seiten, Rückfrage möglich | 🔶 (aktueller Status ist binär offen/geprüft über Admin-Anträge, kein granularer Workflow) |
| FR-ZE-1 | Erfassung von Beginn, Pause, Ende, Einrichtung, Auftrag, Nacht-/Wochenend-/Feiertagsstunden [Blueprint 19.5] | Außendienst | Eintrag pro Schicht mit Pflichtfeldern | (`TimeEntry` in `omsorgCore`, volles CRUD über `TimeEntriesController`; Einrichtung wird über `Order.FacilityId` aufgelöst statt redundant gespeichert; Erfassung über `omsorgWeb/mitarbeiter-app` `pages/stundenerfassung.php`, Prüfung/Freigabe über `omsorgapp` "Zeiterfassung") |
| FR-ZE-2 | Statuspipeline: Entwurf → eingereicht → Prüfung → Rückfrage → freigegeben → abgerechnet [Blueprint 19.5] | Außendienst, Sabrina | Statuswechsel sichtbar für beide Seiten, Rückfrage möglich | (ValueList `"TimeEntryStatus"` mit `ValueListItemTransition`-Graph wie `OrderStatus`, zusätzlich `RequiresApproval` je Kante — Außendienst löst nur die Selbst-Einreichungs-Kanten über `POST /api/time-entries/{id}/submit` aus, Sabrina/Büro entscheidet über `POST /api/time-entries/{id}/decision`) |
| FR-ZE-3 | Nur freigegebene Zeiten dürfen in Rechnungsstellung einfließen [Blueprint 19.5, 19.8] | System | Rechnungslauf ignoriert nicht-freigegebene Datensätze; Verstoß ist technisch unmöglich, nicht nur UI-Regel | ⬜ |
| FR-ZE-4 | Optionale Unterschrift/Bestätigung der Einrichtung, Upload als Foto/PDF | Außendienst | Upload-Feld vorhanden, an Zeiterfassungssatz gekoppelt | 🔶 (Upload existiert für Monatsnachweis, nicht pro Einzel-Einsatz) |
@@ -140,7 +140,7 @@ Umfasst alle drei Plattform-Ebenen: OMSORG Desktop, OMSORG Connect, OMSORG Backe
| ID | Anforderung | Akteur | Akzeptanzkriterium | Status |
|---|---|---|---|---|
| FR-CON-1 | Zeiterfassung, digitaler Tätigkeitsnachweis, Fahrtenbuch, Einsatzanweisung, Krankmeldung, Urlaub, Dokumente, News, Benefits, Ansprechpartner [omsorg.md, Blueprint 4.2] | Außendienst | Jede Funktion als eigener Menüpunkt erreichbar | 🔶 (vorhanden: Stundennachweis-Upload, Urlaubs-/Abwesenheitsantrag, Dokumentenarchiv, News, Benefits, Einsatzanweisung, Fortbildung, Dienstplan, Werben, Bewertung — **Fahrtenbuch fehlt vollständig**, Zeiterfassung ist Upload statt Live-Erfassung) |
| FR-CON-1 | Zeiterfassung, digitaler Tätigkeitsnachweis, Fahrtenbuch, Einsatzanweisung, Krankmeldung, Urlaub, Dokumente, News, Benefits, Ansprechpartner [omsorg.md, Blueprint 4.2] | Außendienst | Jede Funktion als eigener Menüpunkt erreichbar | 🔶 (produktiv über `omsorgWeb/mitarbeiter-app-legacy` (eigene MySQL): Stundennachweis-Upload, Urlaubs-/Abwesenheitsantrag, Dokumentenarchiv, News, Benefits, Einsatzanweisung, Fortbildung, Dienstplan, Werben, Bewertung — **Fahrtenbuch fehlt vollständig**, Zeiterfassung ist Upload statt Live-Erfassung. Der aktiv weiterentwickelte Neuaufbau `omsorgWeb/mitarbeiter-app` (gegen `omsorgCore`, keine eigene Datenhaltung mehr) deckt bisher Login/Passwort sowie Urlaubs-/Abwesenheits-/Krankmeldungsanträge ab (`pages/urlaubsantrag.php` gegen den neuen `AbsencesController`, Datenbasis zugleich für FR-EM-3 — siehe `omsorgCore/CLAUDE.md` "Abwesenheits-/Urlaubs-/Krankmeldungsanträge"); alle anderen Menüpunkte müssen im Neuaufbau noch nachgezogen werden, bis die Legacy-App abgelöst werden kann) |
| FR-CON-2 | Automatische Synchronisation mit OMSORG Desktop [omsorg.md] | System | Änderung in Connect ist ohne manuellen Export in Desktop sichtbar | ⬜ (blockiert durch fehlendes OMSORG Backend, s. Abschnitt 2) |
| FR-CON-3 | PWA-Installierbarkeit ("Add to Home Screen") | Außendienst | Manifest + Service Worker vorhanden und funktionsfähig | ✅ (`manifest.webmanifest`, `service-worker.js`) |
| FR-CON-4 | Login-Rate-Limiting: 5 Fehlversuche → 10 Min. Sperre | Außendienst/alle | Nach 5 Fehlversuchen wird Login für 10 Minuten blockiert | ✅ (`index.php`, `login_attempts`-Tabelle, Migration 004) |
@@ -180,7 +180,7 @@ Die Event-Schicht ist Teil des Backends (siehe Abschnitt 2) und läuft im selben
| NFR-5 | Ausgabe-Escaping gegen XSS | omsorgWeb CLAUDE.md | ✅ (`e()`-Helper) |
| NFR-6 | Automatische Backups + regelmäßige Wiederherstellungsprüfung | Blueprint 13 | ⬜ |
| NFR-7 | Nachvollziehbare Änderungshistorie / Protokollierung sicherheitsrelevanter Vorgänge | Blueprint 13, 19.8 | 🔶 (Login-Versuche protokolliert; generisches Audit-Log für Datensatzänderungen fehlt) |
| NFR-8 | Aufbewahrungs- und Löschkonzept, Schutz vor unbefugtem Export | Blueprint 13 | |
| NFR-8 | Aufbewahrungs- und Löschkonzept, Schutz vor unbefugtem Export | Blueprint 13 | 🔶 (Löschkonzept-Teil umgesetzt: `Employee`/`Facility`/`Contract`/`Order`/`FacilityContact` werden ausschließlich soft-gelöscht (`IsDeleted`/`DeletedAt`, kein Hard-Delete), gegated über `PermissionAction.Delete` je Modul, und über eine neue "Papierkorb"-Seite (`PermissionAction.Recover`) wiederherstellbar — nichts geht verloren, nichts wird unkontrolliert entfernt; **noch offen:** Aufbewahrungsfristen/automatisches endgültiges Löschen nach Frist sowie expliziter Schutz vor unbefugtem Export) |
| NFR-9 | Berechtigungsprüfung auf Oberflächen- **und** Datenebene | Blueprint 13, 19.8 | 🔶 (in Connect vorhanden für Außendienst-Eigendaten; granulare Rechtematrix für Büromitarbeiter fehlt) |
| NFR-10 | White-Label-Fähigkeit: Branding (Name, Logo, Farben, Schrift, Vorlagen) von Geschäftslogik getrennt | Blueprint 14 | ⬜ (aktuell Branding fest im Code/CSS von `omsorgWeb`/`omsorgapp`) |
| NFR-11 | Vollständige Mandantentrennung für spätere Multi-Tenant-Nutzung | Blueprint 14 | ⬜ (nicht Teil der aktuellen Datenmodelle; explizit erst „bei Bedarf") |
@@ -198,7 +198,7 @@ Verbindliche Regeln [Blueprint 19.8]:
2. Beziehungen werden über IDs hergestellt, nicht durch doppelte Texteingaben.
3. Stammdaten werden nur an einer zentralen Stelle gepflegt.
4. Änderungen werden nachvollziehbar protokolliert.
5. Löschungen sensibler/geschäftsrelevanter Daten erfolgen nicht unkontrolliert (Soft-Delete/Archivierung statt Hard-Delete).
5. Löschungen sensibler/geschäftsrelevanter Daten erfolgen nicht unkontrolliert (Soft-Delete/Archivierung statt Hard-Delete). ✅ umgesetzt für alle Core-Objekte mit vollem CRUD (`Employee`, `Facility`, `Contract`, `Order`, `FacilityContact`, `Absence`, `TimeEntry`) — `IsDeleted`/`DeletedAt` statt Hard-Delete, wiederherstellbar über die "Papierkorb"-Seite; `Invoice` hat noch kein CRUD, daher hier noch nicht relevant.
6. Berechtigungen werden auf Daten- und Funktionsebene geprüft.
7. Automatisierungen (Event-Schicht) greifen ausschließlich auf verlässliche, freigegebene Daten zu — dadurch, dass Datenschicht und Event-Schicht im selben Backend-Prozess laufen, ist dies technisch einfacher garantierbar als bei getrennten Services (kein Risiko von veralteten/inkonsistenten Zwischenständen durch asynchrone Synchronisation).
8. Rechnungen entstehen ausschließlich aus freigegebenen Arbeitszeiten.
@@ -219,7 +219,11 @@ Verbindliche Regeln [Blueprint 19.8]:
| Rechnungen/Zahlungen | voll | erstellen/freigeben | **kein Zugriff** | |
| Recruiting/CRM | voll | nutzen | voll | |
| Controlling | voll (inkl. Gewinn/Margen) | eingeschränkt (kein Gewinn) | **kein Zugriff** | |
| Benutzerverwaltung | voll | | | |
| Benutzerkonten (anlegen, (de)aktivieren, Passwort zurücksetzen) | voll | | | |
| Rollen & Rechte (Rechte-Matrix, individuelle Ausnahmen) | voll | | | |
| Konfiguration (Status-Verwaltung/Auswahllisten) | voll | | | |
Die drei letzten Zeilen waren bis 2026-08-09 ein einziger Punkt "Benutzerverwaltung" — technisch jetzt als drei getrennte Rechte (`ModuleType.Users`/`UserManagement`/`Configuration`, siehe `omsorgCore/CLAUDE.md`, Abschnitt "Rechtesystem") umgesetzt, damit z. B. künftig Status-Verwaltung an Sabrina vergeben werden kann, ohne ihr auch Zugriff auf die Rechte-Matrix zu geben. Aktuell hat davon nur Sabina/Malik überhaupt Zugriff (Basis-Rollen-Seed unverändert).
Für zukünftige Büromitarbeiter: Rolle als Vorlage, zusätzlich granular je Modul einstellbar: sehen / lesen / anlegen / bearbeiten / löschen / exportieren / freigeben [Blueprint 6.5].
+101
View File
@@ -0,0 +1,101 @@
name: omsorg
# WICHTIG (der fehleranfälligste Punkt in diesem Setup):
# - omsorgapp.build.args.VITE_OMSORG_CORE_URL muss die vom BROWSER erreichbare Adresse von
# omsorgcore sein (hier: der auf dem Host published Port), NICHT der interne Compose-DNS-Name -
# Vite bäckt diese URL zur Build-Zeit in den JS-Bundle ein (siehe omsorgapp/src/api/config.js).
# - omsorgweb.environment.OMSORG_CORE_URL ist dagegen der interne Servicename (http://omsorgcore:8080),
# da PHP dort serverseitig per cURL aufruft (kein Browser-Kontext), siehe
# omsorgWeb/docker/bootstrap-config.php.
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: omsorg_core
POSTGRES_USER: omsorg_core
POSTGRES_PASSWORD: omsorg_core_dev_password
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U omsorg_core"]
interval: 5s
timeout: 5s
retries: 10
mysql:
image: mysql:8.0
environment:
MYSQL_DATABASE: omsorg_web
MYSQL_USER: omsorg_web
MYSQL_PASSWORD: omsorg_web_dev_password
MYSQL_ROOT_PASSWORD: omsorg_web_root_dev_password
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 5s
timeout: 5s
retries: 10
omsorgcore:
build:
context: .
dockerfile: omsorgCore/Dockerfile
depends_on:
postgres:
condition: service_healthy
environment:
ASPNETCORE_ENVIRONMENT: Development
ConnectionStrings__OmsorgCore: "Host=postgres;Port=5432;Database=omsorg_core;Username=omsorg_core;Password=omsorg_core_dev_password"
Jwt__Secret: "CHANGE_ME_LOCAL_DEV_SECRET_MIN_32_CHARS_LONG"
Cors__AllowedOrigins__0: "http://localhost:5173"
ports:
- "8080:8080"
volumes:
- omsorgcore_documents:/app/App_Data/documents
omsorgapp:
build:
context: .
dockerfile: omsorgapp/Dockerfile
args:
VITE_OMSORG_CORE_URL: "http://localhost:8080"
ports:
- "5173:80"
depends_on:
- omsorgcore
omsorgweb:
build:
context: .
dockerfile: omsorgWeb/Dockerfile
depends_on:
mysql:
condition: service_healthy
omsorgcore:
condition: service_started
environment:
DB_HOST: mysql
DB_NAME: omsorg_web
DB_USER: omsorg_web
DB_PASSWORD: omsorg_web_dev_password
OMSORG_CORE_URL: "http://omsorgcore:8080"
ports:
- "8081:80"
volumes:
- web_uploads:/var/www/html/mitarbeiter-app-legacy/uploads
- web_downloads:/var/www/html/mitarbeiter-app-legacy/downloads
- web_fortbildung:/var/www/html/mitarbeiter-app-legacy/fortbildung-materials
- web_avatars:/var/www/html/mitarbeiter-app-legacy/assets/avatars
- web_data:/var/www/html/mitarbeiter-app-legacy/data
volumes:
postgres_data:
mysql_data:
omsorgcore_documents:
web_uploads:
web_downloads:
web_fortbildung:
web_avatars:
web_data:
+101 -23
View File
@@ -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.
+8
View File
@@ -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 |
+21
View File
@@ -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 -&gt; Eingereicht -&gt; Prüfung -&gt; Rückfrage -&gt; Freigegeben -&gt; 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);
}
+15
View File
@@ -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);
}
@@ -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.&lt;action&gt; 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 -&gt; Eingereicht). Kein Body nötig.
/// </summary>
Task<SubmitTimeEntryResult> SubmitAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Büro-Entscheidung entlang der Statuspipeline (z. B. -&gt;Prüfung/-&gt;Rückfrage/-&gt;Freigegeben/-&gt;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(

Some files were not shown because too many files have changed in this diff Show More