Migrate omsorgapp to browser SPA, add Docker/CI build setup
Docker-Images bauen und veröffentlichen / build (, omsorgCore/Dockerfile, omsorgcore) (push) Successful in 3s
Docker-Images bauen und veröffentlichen / build (, omsorgWeb/Dockerfile, omsorgweb) (push) Failing after 4s
Docker-Images bauen und veröffentlichen / build (VITE_OMSORG_CORE_URL=${{ vars.OMSORG_CORE_PUBLIC_URL }}, omsorgapp/Dockerfile, omsorgapp) (push) Successful in 3s
Docker-Images bauen und veröffentlichen / build (, omsorgCore/Dockerfile, omsorgcore) (push) Successful in 3s
Docker-Images bauen und veröffentlichen / build (, omsorgWeb/Dockerfile, omsorgweb) (push) Failing after 4s
Docker-Images bauen und veröffentlichen / build (VITE_OMSORG_CORE_URL=${{ vars.OMSORG_CORE_PUBLIC_URL }}, omsorgapp/Dockerfile, omsorgapp) (push) Successful in 3s
- omsorgapp: drop Electron, run as a plain Vite/React browser app; refresh token moves to an HttpOnly cookie (omsorgCore), CORS added for the new browser origin, document download/preview switched to Blob-based browser APIs. - Add Dockerfiles for omsorgCore, omsorgapp, and omsorgWeb, a docker-compose.yml wiring Postgres/MySQL/all three apps together, and a Gitea Actions workflow that builds and pushes images to the repo's container registry on push to main and on version tags. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e9e96a57dc
commit
598dfcd38a
@@ -0,0 +1,8 @@
|
|||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/bin
|
||||||
|
**/obj
|
||||||
|
omsorgCore/**/App_Data
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
*.log
|
||||||
@@ -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
@@ -23,3 +23,13 @@ Thumbs.db
|
|||||||
# .NET (omsorgCore)
|
# .NET (omsorgCore)
|
||||||
omsorgCore/**/bin/
|
omsorgCore/**/bin/
|
||||||
omsorgCore/**/obj/
|
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/
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Ein Monorepo für die OMSORG-Plattform, einer Software für ein Pflege-Zeitarbei
|
|||||||
| Verzeichnis | Rolle | Stack | Ist-Stand |
|
| Verzeichnis | Rolle | Stack | Ist-Stand |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `omsorgWeb/` | Öffentliche Website + **OMSORG Connect** (Mitarbeiter-App für Außendienst) | PHP, MySQL, vanilla JS, PWA | ✅ produktiv im Einsatz |
|
| `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 |
|
| `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:
|
Leitdokumente:
|
||||||
|
|||||||
+21
-17
@@ -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 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 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).
|
**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 |
|
| 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-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-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 | 🔶 (Dokumentenarchiv existiert bereits in Connect: `pages/dokumentenarchiv.php`, `actions/upload-dokument.php`; im Desktop-Modul fehlt es) |
|
| 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-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`) |
|
| 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
|
### 4.3 Einrichtungen & CRM
|
||||||
|
|
||||||
| ID | Anforderung | Akteur | Akzeptanzkriterium | Status |
|
| 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-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 /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-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 | ⬜ |
|
| 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) | ⬜ |
|
| 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 | ⬜ |
|
| 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)
|
### 4.4 Einsatzmanagement (Auftrag → Einsatz → Zuweisung)
|
||||||
|
|
||||||
| ID | Anforderung | Akteur | Akzeptanzkriterium | Status |
|
| 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-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: `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-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 | ⬜ |
|
| 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-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 | ⬜ |
|
| 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 |
|
| 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-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 | 🔶 (aktueller Status ist binär offen/geprüft über Admin-Anträge, kein granularer Workflow) |
|
| 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-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) |
|
| 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 |
|
| 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-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-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) |
|
| 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-5 | Ausgabe-Escaping gegen XSS | omsorgWeb CLAUDE.md | ✅ (`e()`-Helper) |
|
||||||
| NFR-6 | Automatische Backups + regelmäßige Wiederherstellungsprüfung | Blueprint 13 | ⬜ |
|
| 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-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-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-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") |
|
| 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.
|
2. Beziehungen werden über IDs hergestellt, nicht durch doppelte Texteingaben.
|
||||||
3. Stammdaten werden nur an einer zentralen Stelle gepflegt.
|
3. Stammdaten werden nur an einer zentralen Stelle gepflegt.
|
||||||
4. Änderungen werden nachvollziehbar protokolliert.
|
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.
|
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).
|
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.
|
8. Rechnungen entstehen ausschließlich aus freigegebenen Arbeitszeiten.
|
||||||
@@ -219,7 +219,11 @@ Verbindliche Regeln [Blueprint 19.8]:
|
|||||||
| Rechnungen/Zahlungen | voll | erstellen/freigeben | **kein Zugriff** | – |
|
| Rechnungen/Zahlungen | voll | erstellen/freigeben | **kein Zugriff** | – |
|
||||||
| Recruiting/CRM | voll | nutzen | voll | – |
|
| Recruiting/CRM | voll | nutzen | voll | – |
|
||||||
| Controlling | voll (inkl. Gewinn/Margen) | eingeschränkt (kein Gewinn) | **kein Zugriff** | – |
|
| 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].
|
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].
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -28,8 +28,8 @@ omsorgCore/
|
|||||||
src/
|
src/
|
||||||
OmsorgCore.Domain/ # Entitäten, Enums. Keine Abhängigkeit auf andere Projekte.
|
OmsorgCore.Domain/ # Entitäten, Enums. Keine Abhängigkeit auf andere Projekte.
|
||||||
Common/ # Entity, AuditableEntity, AuditRedactedAttribute (Basisklassen)
|
Common/ # Entity, AuditableEntity, AuditRedactedAttribute (Basisklassen)
|
||||||
Enums/ # ModuleType, PermissionAction, PermissionEffect, AuditEventCategory
|
Enums/ # ModuleType, PermissionAction, PermissionEffect, AuditEventCategory, DocumentEntityType
|
||||||
Entities/ # Employee, Facility, Contract, Order, TimeEntry, Invoice,
|
Entities/ # Employee, Facility, Contract, Order, TimeEntry, Invoice, Document,
|
||||||
# User, Role, RolePermission, UserPermissionOverride, AuditLogEntry
|
# User, Role, RolePermission, UserPermissionOverride, AuditLogEntry
|
||||||
OmsorgCore.Application/ # Business-Logik. Abhängig von Domain.
|
OmsorgCore.Application/ # Business-Logik. Abhängig von Domain.
|
||||||
Abstractions/ # Interfaces: IEmployeeRepository, IUserRepository,
|
Abstractions/ # Interfaces: IEmployeeRepository, IUserRepository,
|
||||||
@@ -42,7 +42,8 @@ omsorgCore/
|
|||||||
OmsorgCoreDbContext.cs
|
OmsorgCoreDbContext.cs
|
||||||
Configurations/ # ein IEntityTypeConfiguration<T> pro Entität
|
Configurations/ # ein IEntityTypeConfiguration<T> pro Entität
|
||||||
Migrations/ # EF-Core-Migrationen (InitialCreate bereits erzeugt)
|
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
|
Security/ # PasswordHasher, JwtOptions, JwtTokenGenerator, RefreshTokenOptions, RefreshTokenGenerator
|
||||||
DependencyInjection.cs # AddInfrastructure(configuration)
|
DependencyInjection.cs # AddInfrastructure(configuration)
|
||||||
OmsorgCore.Engine/ # Event-Schicht. Abhängig von Domain + Application.
|
OmsorgCore.Engine/ # Event-Schicht. Abhängig von Domain + Application.
|
||||||
@@ -51,7 +52,7 @@ omsorgCore/
|
|||||||
Handlers/ # Beispiel-Handler (EmployeeCreatedHandler), AuditEventHandler
|
Handlers/ # Beispiel-Handler (EmployeeCreatedHandler), AuditEventHandler
|
||||||
DependencyInjection.cs # AddEngine()
|
DependencyInjection.cs # AddEngine()
|
||||||
OmsorgCore.Api/ # ASP.NET Core Web API. Abhängig von Application+Infrastructure+Engine.
|
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, ...)
|
Contracts/ # Request-/Response-DTOs (LoginRequest, EmployeeResponse, ...)
|
||||||
Security/ # CurrentUserService, RequirePermissionAttribute
|
Security/ # CurrentUserService, RequirePermissionAttribute
|
||||||
Program.cs # einziger Ort, an dem alle Schichten verdrahtet werden
|
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.
|
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:
|
Rechteprüfung auf Controller-Actions:
|
||||||
```csharp
|
```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]`.
|
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.
|
**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
|
## 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>`.
|
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.
|
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`).
|
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` widerruft den vorgelegten Refresh-Token (`AuthService.RevokeAsync`, idempotent).
|
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`.
|
**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.
|
**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).
|
**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.
|
**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
|
## Datenbank
|
||||||
|
|
||||||
- Connection-String-Key: `ConnectionStrings:OmsorgCore` (Format `Host=...;Port=5432;Database=omsorg_core;Username=...;Password=...`).
|
- 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.
|
- `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.
|
- **`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
|
## 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).
|
- `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).
|
- **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.
|
- **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
|
## 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`.
|
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:**
|
**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`.
|
- `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.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.
|
**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
|
## 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).
|
- `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)`. Kein `omsorgapp`-UI-Modul dafür in diesem Schritt — nur das Backend-CRUD.
|
- `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`/`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).
|
- `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).
|
- 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.
|
- `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.
|
- 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).
|
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
|
```bash
|
||||||
# omsorgCore muss dafür lokal im Development-Modus laufen (Swagger nur dort aktiv):
|
# 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 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)
|
## 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.
|
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.
|
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.
|
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).
|
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.
|
7. Rechnungen entstehen ausschließlich aus freigegebener Zeiterfassung (FR-ZE-3/FR-RE-1) — muss bei Ausbau von `TimeEntry`/`Invoice` auf Domain-/Application-Ebene erzwungen werden, nicht nur als UI-Regel im Client.
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ ASP.NET Core liest Konfiguration in dieser Reihenfolge (später gewinnt):
|
|||||||
| `Auth:LoginLockoutMinutes` | `10` | `Auth__LoginLockoutMinutes` | Zeitfenster, in dem Fehlversuche gezählt werden, bevor die Sperre wieder abläuft. |
|
| `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. |
|
| `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
|
## Passwort-Reset / E-Mail
|
||||||
|
|
||||||
| Key | Default | Env-Var | Beschreibung |
|
| Key | Default | Env-Var | Beschreibung |
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Build-Kontext ist der Repo-Root (siehe .gitea/workflows/docker-build.yml und docker-compose.yml) -
|
||||||
|
# damit bleibt der Kontext für alle drei Dockerfiles im Monorepo einheitlich, auch wenn dieses
|
||||||
|
# Image allein aus omsorgCore/ besteht.
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY omsorgCore/ .
|
||||||
|
RUN dotnet restore OmsorgCore.sln
|
||||||
|
RUN dotnet publish src/OmsorgCore.Api/OmsorgCore.Api.csproj -c Release -o /app/publish --no-restore
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/publish .
|
||||||
|
|
||||||
|
# Migrationen laufen automatisch bei jedem Start (Program.cs: db.Database.MigrateAsync()) -
|
||||||
|
# kein separater Migrations-Schritt im Image nötig. Konfiguration (ConnectionStrings__OmsorgCore,
|
||||||
|
# Jwt__Secret, Cors__AllowedOrigins__0, ...) kommt ausschließlich über Env-Vars zur Laufzeit,
|
||||||
|
# nie ins Image gebacken (siehe omsorgCore/CLAUDE.md, Abschnitt "Secret-Handling").
|
||||||
|
ENV ASPNETCORE_URLS=http://+:8080
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENTRYPOINT ["dotnet", "OmsorgCore.Api.dll"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace OmsorgCore.Api.Contracts;
|
||||||
|
|
||||||
|
public record AbsenceDecisionRequest(string Status, string? AdminNote);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace OmsorgCore.Api.Contracts;
|
||||||
|
|
||||||
|
public record AbsenceResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid EmployeeId,
|
||||||
|
string EmployeeName,
|
||||||
|
string Type,
|
||||||
|
DateOnly StartDate,
|
||||||
|
DateOnly EndDate,
|
||||||
|
string? Reason,
|
||||||
|
string? Substitute,
|
||||||
|
string? Note,
|
||||||
|
string Status,
|
||||||
|
string? AdminNote,
|
||||||
|
DateTime CreatedAt);
|
||||||
@@ -2,4 +2,4 @@ using OmsorgCore.Domain.Enums;
|
|||||||
|
|
||||||
namespace OmsorgCore.Api.Contracts;
|
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,
|
int SortOrder,
|
||||||
bool IsDefault = false,
|
bool IsDefault = false,
|
||||||
bool IsInitial = 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? BillingStreet,
|
||||||
string? BillingPostalCode,
|
string? BillingPostalCode,
|
||||||
string? BillingCity,
|
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;
|
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);
|
public record ForgotPasswordRequestResponse(string Status);
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
namespace OmsorgCore.Api.Contracts;
|
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;
|
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? BillingStreet,
|
||||||
string? BillingPostalCode,
|
string? BillingPostalCode,
|
||||||
string? BillingCity,
|
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,
|
int SortOrder,
|
||||||
bool IsDefault = false,
|
bool IsDefault = false,
|
||||||
bool IsInitial = 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;
|
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,
|
int SortOrder,
|
||||||
bool IsDefault,
|
bool IsDefault,
|
||||||
bool IsInitial,
|
bool IsInitial,
|
||||||
bool IsTerminal);
|
bool IsTerminal,
|
||||||
|
bool TriggersFollowUp);
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
namespace OmsorgCore.Api.Contracts;
|
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 IEmailSender _emailSender;
|
||||||
private readonly EmailOptions _emailOptions;
|
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;
|
_emailSender = emailSender;
|
||||||
_emailOptions = emailOptions.Value;
|
_emailOptions = emailOptions.Value;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("password-reset-template")]
|
[HttpGet("password-reset-template")]
|
||||||
@@ -60,7 +62,18 @@ public class AdminEmailController : ControllerBase
|
|||||||
subject = EmailTemplateRenderer.Render(subject, values);
|
subject = EmailTemplateRenderer.Render(subject, values);
|
||||||
body = EmailTemplateRenderer.Render(body, values);
|
body = EmailTemplateRenderer.Render(body, values);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
await _emailSender.SendAsync(new EmailMessage(request.ToAddress, subject, body), cancellationToken);
|
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();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,17 @@ namespace OmsorgCore.Api.Controllers;
|
|||||||
[Route("api/auth")]
|
[Route("api/auth")]
|
||||||
public class AuthController : ControllerBase
|
public class AuthController : ControllerBase
|
||||||
{
|
{
|
||||||
|
private const string RefreshTokenCookieName = "refreshToken";
|
||||||
|
|
||||||
private readonly IAuthService _authService;
|
private readonly IAuthService _authService;
|
||||||
private readonly ICurrentUserService _currentUserService;
|
private readonly ICurrentUserService _currentUserService;
|
||||||
private readonly IPasswordResetService _passwordResetService;
|
private readonly IPasswordResetService _passwordResetService;
|
||||||
private readonly IUserService _userService;
|
private readonly IUserService _userService;
|
||||||
private readonly IDomainEventDispatcher _dispatcher;
|
private readonly IDomainEventDispatcher _dispatcher;
|
||||||
private readonly PasswordPolicyOptions _passwordPolicyOptions;
|
private readonly PasswordPolicyOptions _passwordPolicyOptions;
|
||||||
|
private readonly RefreshTokenOptions _refreshTokenOptions;
|
||||||
|
private readonly IWebHostEnvironment _environment;
|
||||||
|
private readonly ILogger<AuthController> _logger;
|
||||||
|
|
||||||
public AuthController(
|
public AuthController(
|
||||||
IAuthService authService,
|
IAuthService authService,
|
||||||
@@ -26,7 +31,10 @@ public class AuthController : ControllerBase
|
|||||||
IPasswordResetService passwordResetService,
|
IPasswordResetService passwordResetService,
|
||||||
IUserService userService,
|
IUserService userService,
|
||||||
IDomainEventDispatcher dispatcher,
|
IDomainEventDispatcher dispatcher,
|
||||||
IOptions<PasswordPolicyOptions> passwordPolicyOptions)
|
IOptions<PasswordPolicyOptions> passwordPolicyOptions,
|
||||||
|
IOptions<RefreshTokenOptions> refreshTokenOptions,
|
||||||
|
IWebHostEnvironment environment,
|
||||||
|
ILogger<AuthController> logger)
|
||||||
{
|
{
|
||||||
_authService = authService;
|
_authService = authService;
|
||||||
_currentUserService = currentUserService;
|
_currentUserService = currentUserService;
|
||||||
@@ -34,6 +42,25 @@ public class AuthController : ControllerBase
|
|||||||
_userService = userService;
|
_userService = userService;
|
||||||
_dispatcher = dispatcher;
|
_dispatcher = dispatcher;
|
||||||
_passwordPolicyOptions = passwordPolicyOptions.Value;
|
_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")]
|
[HttpPost("login")]
|
||||||
@@ -54,25 +81,37 @@ public class AuthController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
await _dispatcher.DispatchAsync(new AuditEvent(result.UserId, result.Username, ipAddress, "Login"), cancellationToken);
|
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")]
|
[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)
|
if (!result.Success || result.Token is null || result.RefreshToken is null || result.ExpiresAt is null)
|
||||||
{
|
{
|
||||||
return Unauthorized();
|
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")]
|
[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(
|
await _dispatcher.DispatchAsync(
|
||||||
new AuditEvent(_currentUserService.UserId, _currentUserService.Username, _currentUserService.IpAddress, "Logout"),
|
new AuditEvent(_currentUserService.UserId, _currentUserService.Username, _currentUserService.IpAddress, "Logout"),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
@@ -95,7 +134,7 @@ public class AuthController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
var permissions = profile.Permissions
|
var permissions = profile.Permissions
|
||||||
.Select(p => new PermissionDto(p.Module, p.Action))
|
.Select(p => new PermissionDto(p.Module, p.Action, p.Scope))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return Ok(new MeResponse(
|
return Ok(new MeResponse(
|
||||||
@@ -141,10 +180,24 @@ public class AuthController : ControllerBase
|
|||||||
{
|
{
|
||||||
var result = await _passwordResetService.RequestResetAsync(request.Username, cancellationToken);
|
var result = await _passwordResetService.RequestResetAsync(request.Username, cancellationToken);
|
||||||
if (result.Status == PasswordResetRequestStatus.Sent && result.Email is not null && result.RawPin is not null)
|
if (result.Status == PasswordResetRequestStatus.Sent && result.Email is not null && result.RawPin is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
await _dispatcher.DispatchAsync(
|
await _dispatcher.DispatchAsync(
|
||||||
new PasswordResetRequestedEvent(result.Email, result.RawPin), cancellationToken);
|
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";
|
var status = result.Status == PasswordResetRequestStatus.Sent ? "sent" : "cannot_reset";
|
||||||
return Ok(new ForgotPasswordRequestResponse(status));
|
return Ok(new ForgotPasswordRequestResponse(status));
|
||||||
|
|||||||
@@ -151,6 +151,14 @@ public class ContractsController : ControllerBase
|
|||||||
return updated is null ? NotFound() : Ok(ToResponse(updated));
|
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(
|
private async Task<string?> ValidateFieldsAsync(
|
||||||
string contractType,
|
string contractType,
|
||||||
Guid? employeeId,
|
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 StatusListKey = "EmployeeStatus";
|
||||||
private const string EmploymentTypeListKey = "EmploymentType";
|
private const string EmploymentTypeListKey = "EmploymentType";
|
||||||
|
private const string QualificationListKey = "Qualification";
|
||||||
|
|
||||||
private readonly IEmployeeService _employeeService;
|
private readonly IEmployeeService _employeeService;
|
||||||
private readonly IValueListRepository _valueListRepository;
|
private readonly IValueListRepository _valueListRepository;
|
||||||
@@ -220,6 +221,14 @@ public class EmployeesController : ControllerBase
|
|||||||
return updated is null ? NotFound() : Ok(ToResponse(updated));
|
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)
|
private static string? ValidateAddressFields(string? street, string? postalCode, string? city, string? country)
|
||||||
{
|
{
|
||||||
if (street is { Length: > 200 })
|
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;
|
return null;
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ public class FacilitiesController : ControllerBase
|
|||||||
{
|
{
|
||||||
private const string CrmStatusListKey = "CrmStatus";
|
private const string CrmStatusListKey = "CrmStatus";
|
||||||
private const string FacilityTypeListKey = "FacilityType";
|
private const string FacilityTypeListKey = "FacilityType";
|
||||||
|
private const string FollowUpPeriodsListKey = "FollowUpPeriods";
|
||||||
|
private const string BillingIntervalListKey = "BillingInterval";
|
||||||
|
|
||||||
private readonly IFacilityService _facilityService;
|
private readonly IFacilityService _facilityService;
|
||||||
private readonly IValueListRepository _valueListRepository;
|
private readonly IValueListRepository _valueListRepository;
|
||||||
@@ -34,6 +36,7 @@ public class FacilitiesController : ControllerBase
|
|||||||
public async Task<ActionResult<PagedResponse<FacilityResponse>>> GetAll(
|
public async Task<ActionResult<PagedResponse<FacilityResponse>>> GetAll(
|
||||||
[FromQuery] string? search,
|
[FromQuery] string? search,
|
||||||
[FromQuery] string? crmStatus,
|
[FromQuery] string? crmStatus,
|
||||||
|
[FromQuery] bool followUpDueOnly = false,
|
||||||
[FromQuery] int page = 1,
|
[FromQuery] int page = 1,
|
||||||
[FromQuery] int pageSize = 20,
|
[FromQuery] int pageSize = 20,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
@@ -41,7 +44,7 @@ public class FacilitiesController : ControllerBase
|
|||||||
page = Math.Max(page, 1);
|
page = Math.Max(page, 1);
|
||||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
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));
|
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)]
|
[RequirePermission(ModuleType.Facilities, PermissionAction.Edit)]
|
||||||
public async Task<ActionResult<FacilityResponse>> Update(Guid id, UpdateFacilityRequest request, CancellationToken cancellationToken)
|
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)
|
if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 300)
|
||||||
{
|
{
|
||||||
return BadRequest("Name ist erforderlich und darf maximal 300 Zeichen lang sein.");
|
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.");
|
return BadRequest("CrmStatus ist erforderlich und darf maximal 50 Zeichen lang sein.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var allowedCrmStatuses = await _valueListRepository.GetActiveValuesAsync(CrmStatusListKey, cancellationToken);
|
var crmStatusItems = await _valueListRepository.GetItemsAsync(CrmStatusListKey, cancellationToken);
|
||||||
if (!allowedCrmStatuses.Contains(request.CrmStatus))
|
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 })
|
if (request.FacilityType is { Length: > 100 })
|
||||||
@@ -165,10 +201,53 @@ public class FacilitiesController : ControllerBase
|
|||||||
return BadRequest(billingAddressError);
|
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
|
var updates = new Facility
|
||||||
{
|
{
|
||||||
Name = request.Name,
|
Name = request.Name,
|
||||||
CrmStatus = request.CrmStatus,
|
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,
|
FacilityType = request.FacilityType,
|
||||||
Website = request.Website,
|
Website = request.Website,
|
||||||
Street = request.Street,
|
Street = request.Street,
|
||||||
@@ -185,6 +264,14 @@ public class FacilitiesController : ControllerBase
|
|||||||
return updated is null ? NotFound() : Ok(ToResponse(updated));
|
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)
|
private static string? ValidateAddressFields(string? street, string? postalCode, string? city, string? country, string prefix)
|
||||||
{
|
{
|
||||||
if (street is { Length: > 200 })
|
if (street is { Length: > 200 })
|
||||||
@@ -224,5 +311,17 @@ public class FacilitiesController : ControllerBase
|
|||||||
facility.BillingStreet,
|
facility.BillingStreet,
|
||||||
facility.BillingPostalCode,
|
facility.BillingPostalCode,
|
||||||
facility.BillingCity,
|
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));
|
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)
|
private static string? ValidateRequest(string name, string? role, string? department, string? phoneNumber, string? email, string? notes)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(name) || name.Length > 200)
|
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
|
public class OrdersController : ControllerBase
|
||||||
{
|
{
|
||||||
private const string StatusListKey = "OrderStatus";
|
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 IOrderService _orderService;
|
||||||
private readonly IFacilityContactService _facilityContactService;
|
private readonly IFacilityContactService _facilityContactService;
|
||||||
@@ -39,6 +42,9 @@ public class OrdersController : ControllerBase
|
|||||||
[FromQuery] string? search,
|
[FromQuery] string? search,
|
||||||
[FromQuery] Guid? statusId,
|
[FromQuery] Guid? statusId,
|
||||||
[FromQuery] Guid? facilityId,
|
[FromQuery] Guid? facilityId,
|
||||||
|
[FromQuery] string? priority,
|
||||||
|
[FromQuery] string? requiredQualification,
|
||||||
|
[FromQuery] string? shiftType,
|
||||||
[FromQuery] int page = 1,
|
[FromQuery] int page = 1,
|
||||||
[FromQuery] int pageSize = 20,
|
[FromQuery] int pageSize = 20,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
@@ -46,7 +52,8 @@ public class OrdersController : ControllerBase
|
|||||||
page = Math.Max(page, 1);
|
page = Math.Max(page, 1);
|
||||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
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));
|
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)]
|
[RequirePermission(ModuleType.Orders, PermissionAction.Create)]
|
||||||
public async Task<ActionResult<OrderResponse>> Create(CreateOrderRequest request, CancellationToken cancellationToken)
|
public async Task<ActionResult<OrderResponse>> Create(CreateOrderRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var fieldError = ValidateFields(
|
var fieldError = await ValidateFieldsAsync(
|
||||||
request.FacilityId,
|
request.FacilityId,
|
||||||
request.StartDate,
|
request.StartDate,
|
||||||
request.EndDate,
|
request.EndDate,
|
||||||
@@ -70,7 +77,8 @@ public class OrdersController : ControllerBase
|
|||||||
request.ShiftType,
|
request.ShiftType,
|
||||||
request.RequiredHeadcount,
|
request.RequiredHeadcount,
|
||||||
request.Conditions,
|
request.Conditions,
|
||||||
request.Priority);
|
request.Priority,
|
||||||
|
cancellationToken);
|
||||||
if (fieldError is not null)
|
if (fieldError is not null)
|
||||||
{
|
{
|
||||||
return BadRequest(fieldError);
|
return BadRequest(fieldError);
|
||||||
@@ -106,7 +114,7 @@ public class OrdersController : ControllerBase
|
|||||||
[RequirePermission(ModuleType.Orders, PermissionAction.Edit)]
|
[RequirePermission(ModuleType.Orders, PermissionAction.Edit)]
|
||||||
public async Task<ActionResult<OrderResponse>> Update(Guid id, UpdateOrderRequest request, CancellationToken cancellationToken)
|
public async Task<ActionResult<OrderResponse>> Update(Guid id, UpdateOrderRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var fieldError = ValidateFields(
|
var fieldError = await ValidateFieldsAsync(
|
||||||
request.FacilityId,
|
request.FacilityId,
|
||||||
request.StartDate,
|
request.StartDate,
|
||||||
request.EndDate,
|
request.EndDate,
|
||||||
@@ -114,7 +122,8 @@ public class OrdersController : ControllerBase
|
|||||||
request.ShiftType,
|
request.ShiftType,
|
||||||
request.RequiredHeadcount,
|
request.RequiredHeadcount,
|
||||||
request.Conditions,
|
request.Conditions,
|
||||||
request.Priority);
|
request.Priority,
|
||||||
|
cancellationToken);
|
||||||
if (fieldError is not null)
|
if (fieldError is not null)
|
||||||
{
|
{
|
||||||
return BadRequest(fieldError);
|
return BadRequest(fieldError);
|
||||||
@@ -160,6 +169,14 @@ public class OrdersController : ControllerBase
|
|||||||
return Ok(ToResponse(result.Order!));
|
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)
|
private async Task<string?> ValidateFacilityContactAsync(Guid? facilityContactId, Guid facilityId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (facilityContactId is null)
|
if (facilityContactId is null)
|
||||||
@@ -176,7 +193,7 @@ public class OrdersController : ControllerBase
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? ValidateFields(
|
private async Task<string?> ValidateFieldsAsync(
|
||||||
Guid facilityId,
|
Guid facilityId,
|
||||||
DateOnly startDate,
|
DateOnly startDate,
|
||||||
DateOnly? endDate,
|
DateOnly? endDate,
|
||||||
@@ -184,7 +201,8 @@ public class OrdersController : ControllerBase
|
|||||||
string? shiftType,
|
string? shiftType,
|
||||||
int requiredHeadcount,
|
int requiredHeadcount,
|
||||||
string? conditions,
|
string? conditions,
|
||||||
string priority)
|
string priority,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (facilityId == Guid.Empty)
|
if (facilityId == Guid.Empty)
|
||||||
{
|
{
|
||||||
@@ -206,19 +224,33 @@ public class OrdersController : ControllerBase
|
|||||||
return "RequiredHeadcount muss mindestens 1 sein.";
|
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 })
|
if (conditions is { Length: > 500 })
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ public class RolesController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
var permissions = role.RolePermissions
|
var permissions = role.RolePermissions
|
||||||
.Select(rp => new PermissionDto(rp.Module, rp.Action))
|
.Select(rp => new PermissionDto(rp.Module, rp.Action, rp.Scope))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return Ok(new RolePermissionsResponse(role.Id, role.Name, permissions));
|
return Ok(new RolePermissionsResponse(role.Id, role.Name, permissions));
|
||||||
@@ -70,7 +70,7 @@ public class RolesController : ControllerBase
|
|||||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||||
public async Task<IActionResult> UpdatePermissions(Guid id, UpdateRolePermissionsRequest request, CancellationToken cancellationToken)
|
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);
|
var result = await _roleService.UpdatePermissionsAsync(id, parsed, cancellationToken);
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using OmsorgCore.Api.Contracts;
|
||||||
|
using OmsorgCore.Api.Security;
|
||||||
|
using OmsorgCore.Application.Services;
|
||||||
|
using OmsorgCore.Domain.Entities;
|
||||||
|
using OmsorgCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace OmsorgCore.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strukturierte Zeiterfassung pro Schicht (FR-ZE-1) mit Statuspipeline
|
||||||
|
/// Entwurf -> Eingereicht -> Prüfung -> Rückfrage -> Freigegeben -> Abgerechnet (FR-ZE-2). Außendienst
|
||||||
|
/// darf nur Create/View/Edit mit PermissionScope.Own (eigene Einträge, EmployeeId wird serverseitig
|
||||||
|
/// aus dem JWT gesetzt, siehe TimeEntryService.CreateAsync) und die Selbst-Einreichungs-Kante über
|
||||||
|
/// <see cref="Submit"/> auslösen; Büro-Rollen entscheiden über <see cref="Decide"/> (Approve).
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/time-entries")]
|
||||||
|
public class TimeEntriesController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ITimeEntryService _timeEntryService;
|
||||||
|
|
||||||
|
public TimeEntriesController(ITimeEntryService timeEntryService)
|
||||||
|
{
|
||||||
|
_timeEntryService = timeEntryService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[RequirePermission(ModuleType.TimeEntries, PermissionAction.View)]
|
||||||
|
public async Task<ActionResult<PagedResponse<TimeEntryResponse>>> GetAll(
|
||||||
|
[FromQuery] Guid? statusId,
|
||||||
|
[FromQuery] Guid? employeeId,
|
||||||
|
[FromQuery] Guid? orderId,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
page = Math.Max(page, 1);
|
||||||
|
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||||
|
|
||||||
|
var (items, totalCount) = await _timeEntryService.GetPagedAsync(statusId, employeeId, orderId, page, pageSize, cancellationToken);
|
||||||
|
return Ok(new PagedResponse<TimeEntryResponse>(items.Select(ToResponse).ToList(), totalCount, page, pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
[RequirePermission(ModuleType.TimeEntries, PermissionAction.View)]
|
||||||
|
public async Task<ActionResult<TimeEntryResponse>> GetById(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var timeEntry = await _timeEntryService.GetByIdAsync(id, cancellationToken);
|
||||||
|
return timeEntry is null ? NotFound() : Ok(ToResponse(timeEntry));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[RequirePermission(ModuleType.TimeEntries, PermissionAction.Create)]
|
||||||
|
public async Task<ActionResult<TimeEntryResponse>> Create(CreateTimeEntryRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var fieldError = ValidateFields(request.Start, request.End, request.NightHours, request.SaturdayHours, request.SundayHours, request.HolidayHours);
|
||||||
|
if (fieldError is not null)
|
||||||
|
{
|
||||||
|
return BadRequest(fieldError);
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeEntry = new TimeEntry
|
||||||
|
{
|
||||||
|
OrderId = request.OrderId,
|
||||||
|
Date = request.Date,
|
||||||
|
Start = request.Start,
|
||||||
|
End = request.End,
|
||||||
|
BreakDuration = request.BreakDuration,
|
||||||
|
NightHours = request.NightHours,
|
||||||
|
SaturdayHours = request.SaturdayHours,
|
||||||
|
SundayHours = request.SundayHours,
|
||||||
|
HolidayHours = request.HolidayHours
|
||||||
|
};
|
||||||
|
|
||||||
|
var created = await _timeEntryService.CreateAsync(timeEntry, cancellationToken);
|
||||||
|
if (created is null)
|
||||||
|
{
|
||||||
|
return BadRequest("Zeiterfassung konnte nicht angelegt werden - entweder kein verknüpfter Mitarbeiter oder der Auftrag existiert nicht.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var reloaded = await _timeEntryService.GetByIdAsync(created.Id, cancellationToken);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = created.Id }, ToResponse(reloaded!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
[RequirePermission(ModuleType.TimeEntries, PermissionAction.Edit)]
|
||||||
|
public async Task<ActionResult<TimeEntryResponse>> Update(Guid id, UpdateTimeEntryRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var fieldError = ValidateFields(request.Start, request.End, request.NightHours, request.SaturdayHours, request.SundayHours, request.HolidayHours);
|
||||||
|
if (fieldError is not null)
|
||||||
|
{
|
||||||
|
return BadRequest(fieldError);
|
||||||
|
}
|
||||||
|
|
||||||
|
var updates = new TimeEntry
|
||||||
|
{
|
||||||
|
OrderId = request.OrderId,
|
||||||
|
Date = request.Date,
|
||||||
|
Start = request.Start,
|
||||||
|
End = request.End,
|
||||||
|
BreakDuration = request.BreakDuration,
|
||||||
|
NightHours = request.NightHours,
|
||||||
|
SaturdayHours = request.SaturdayHours,
|
||||||
|
SundayHours = request.SundayHours,
|
||||||
|
HolidayHours = request.HolidayHours
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _timeEntryService.UpdateAsync(id, updates, cancellationToken);
|
||||||
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
return result.FailureReason switch
|
||||||
|
{
|
||||||
|
UpdateTimeEntryFailureReason.NotEditable => BadRequest("Die Zeiterfassung wurde bereits zur Prüfung übergeben und kann nicht mehr bearbeitet werden."),
|
||||||
|
UpdateTimeEntryFailureReason.OrderNotFound => BadRequest("Der angegebene Auftrag existiert nicht."),
|
||||||
|
_ => NotFound()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var reloaded = await _timeEntryService.GetByIdAsync(result.TimeEntry!.Id, cancellationToken);
|
||||||
|
return Ok(ToResponse(reloaded!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/submit")]
|
||||||
|
[RequirePermission(ModuleType.TimeEntries, PermissionAction.Edit)]
|
||||||
|
public async Task<ActionResult<TimeEntryResponse>> Submit(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await _timeEntryService.SubmitAsync(id, cancellationToken);
|
||||||
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
return result.FailureReason == SubmitTimeEntryFailureReason.NoSelfServiceTransition
|
||||||
|
? BadRequest("Aus dem aktuellen Status ist keine Einreichung möglich.")
|
||||||
|
: NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var reloaded = await _timeEntryService.GetByIdAsync(result.TimeEntry!.Id, cancellationToken);
|
||||||
|
return Ok(ToResponse(reloaded!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/decision")]
|
||||||
|
[RequirePermission(ModuleType.TimeEntries, PermissionAction.Approve)]
|
||||||
|
public async Task<ActionResult<TimeEntryResponse>> Decide(Guid id, TimeEntryDecisionRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (request.AdminNote is { Length: > 500 })
|
||||||
|
{
|
||||||
|
return BadRequest("AdminNote darf maximal 500 Zeichen lang sein.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _timeEntryService.DecideAsync(id, request.StatusId, request.AdminNote, cancellationToken);
|
||||||
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
return result.FailureReason == DecideTimeEntryFailureReason.InvalidStatusTransition
|
||||||
|
? BadRequest("Der Statuswechsel ist nicht zulässig.")
|
||||||
|
: NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var reloaded = await _timeEntryService.GetByIdAsync(result.TimeEntry!.Id, cancellationToken);
|
||||||
|
return Ok(ToResponse(reloaded!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}")]
|
||||||
|
[RequirePermission(ModuleType.TimeEntries, PermissionAction.Delete)]
|
||||||
|
public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var deleted = await _timeEntryService.DeleteAsync(id, cancellationToken);
|
||||||
|
return deleted ? NoContent() : NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// End < Start ist bewusst erlaubt (Nachtschichten, die über Mitternacht gehen) - keine
|
||||||
|
// Start/Ende-Reihenfolge-Prüfung wie bei Absence.StartDate/EndDate.
|
||||||
|
private static string? ValidateFields(TimeOnly start, TimeOnly end, decimal nightHours, decimal saturdayHours, decimal sundayHours, decimal holidayHours)
|
||||||
|
{
|
||||||
|
if (nightHours < 0 || saturdayHours < 0 || sundayHours < 0 || holidayHours < 0)
|
||||||
|
{
|
||||||
|
return "Zuschlagsstunden dürfen nicht negativ sein.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TimeEntryResponse ToResponse(TimeEntry timeEntry)
|
||||||
|
=> new(
|
||||||
|
timeEntry.Id,
|
||||||
|
timeEntry.EmployeeId,
|
||||||
|
timeEntry.Employee is null ? string.Empty : $"{timeEntry.Employee.FirstName} {timeEntry.Employee.LastName}",
|
||||||
|
timeEntry.OrderId,
|
||||||
|
timeEntry.Order?.FacilityId ?? Guid.Empty,
|
||||||
|
timeEntry.Order?.Facility?.Name ?? string.Empty,
|
||||||
|
timeEntry.Date,
|
||||||
|
timeEntry.Start,
|
||||||
|
timeEntry.End,
|
||||||
|
timeEntry.BreakDuration,
|
||||||
|
timeEntry.NightHours,
|
||||||
|
timeEntry.SaturdayHours,
|
||||||
|
timeEntry.SundayHours,
|
||||||
|
timeEntry.HolidayHours,
|
||||||
|
timeEntry.StatusId,
|
||||||
|
timeEntry.Status?.Value ?? string.Empty,
|
||||||
|
timeEntry.Status?.IsEditableByOwner ?? false,
|
||||||
|
timeEntry.AdminNote,
|
||||||
|
timeEntry.CreatedAt);
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using OmsorgCore.Api.Contracts;
|
||||||
|
using OmsorgCore.Api.Security;
|
||||||
|
using OmsorgCore.Application.Services;
|
||||||
|
using OmsorgCore.Domain.Entities;
|
||||||
|
using OmsorgCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace OmsorgCore.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Papierkorb: listet und stellt soft-gelöschte Datensätze der 5 Core-Objekte mit vollem CRUD
|
||||||
|
/// wieder her. Reine API-Gruppierung für die Papierkorb-Seite in omsorgapp — kein eigener
|
||||||
|
/// ModuleType/eigenes Recht, jede Route ist über das Recht des jeweiligen Objekts gegated
|
||||||
|
/// (z. B. ModuleType.Employees + PermissionAction.Recover), analog zu den bestehenden
|
||||||
|
/// Delete-Endpoints in EmployeesController/FacilitiesController/etc.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/trash")]
|
||||||
|
public class TrashController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IEmployeeService _employeeService;
|
||||||
|
private readonly IFacilityService _facilityService;
|
||||||
|
private readonly IContractService _contractService;
|
||||||
|
private readonly IOrderService _orderService;
|
||||||
|
private readonly IFacilityContactService _facilityContactService;
|
||||||
|
private readonly IFacilityQualificationRateService _facilityQualificationRateService;
|
||||||
|
private readonly IAbsenceService _absenceService;
|
||||||
|
private readonly ITimeEntryService _timeEntryService;
|
||||||
|
|
||||||
|
public TrashController(
|
||||||
|
IEmployeeService employeeService,
|
||||||
|
IFacilityService facilityService,
|
||||||
|
IContractService contractService,
|
||||||
|
IOrderService orderService,
|
||||||
|
IFacilityContactService facilityContactService,
|
||||||
|
IFacilityQualificationRateService facilityQualificationRateService,
|
||||||
|
IAbsenceService absenceService,
|
||||||
|
ITimeEntryService timeEntryService)
|
||||||
|
{
|
||||||
|
_employeeService = employeeService;
|
||||||
|
_facilityService = facilityService;
|
||||||
|
_contractService = contractService;
|
||||||
|
_orderService = orderService;
|
||||||
|
_facilityContactService = facilityContactService;
|
||||||
|
_facilityQualificationRateService = facilityQualificationRateService;
|
||||||
|
_absenceService = absenceService;
|
||||||
|
_timeEntryService = timeEntryService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("employees")]
|
||||||
|
[RequirePermission(ModuleType.Employees, PermissionAction.Recover)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<TrashEmployeeResponse>>> GetDeletedEmployees([FromQuery] string? search, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var employees = await _employeeService.GetDeletedAsync(search, cancellationToken);
|
||||||
|
return Ok(employees.Select(e => new TrashEmployeeResponse(e.Id, e.FirstName, e.LastName, e.DeletedAt)).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("employees/{id:guid}/restore")]
|
||||||
|
[RequirePermission(ModuleType.Employees, PermissionAction.Recover)]
|
||||||
|
public async Task<IActionResult> RestoreEmployee(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var restored = await _employeeService.RestoreAsync(id, cancellationToken);
|
||||||
|
return restored ? NoContent() : NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("facilities")]
|
||||||
|
[RequirePermission(ModuleType.Facilities, PermissionAction.Recover)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<TrashFacilityResponse>>> GetDeletedFacilities([FromQuery] string? search, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var facilities = await _facilityService.GetDeletedAsync(search, cancellationToken);
|
||||||
|
return Ok(facilities.Select(f => new TrashFacilityResponse(f.Id, f.Name, f.DeletedAt)).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("facilities/{id:guid}/restore")]
|
||||||
|
[RequirePermission(ModuleType.Facilities, PermissionAction.Recover)]
|
||||||
|
public async Task<IActionResult> RestoreFacility(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var restored = await _facilityService.RestoreAsync(id, cancellationToken);
|
||||||
|
return restored ? NoContent() : NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("contracts")]
|
||||||
|
[RequirePermission(ModuleType.Contracts, PermissionAction.Recover)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<TrashContractResponse>>> GetDeletedContracts([FromQuery] string? search, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var contracts = await _contractService.GetDeletedAsync(search, cancellationToken);
|
||||||
|
return Ok(contracts.Select(c => new TrashContractResponse(c.Id, c.ContractType, c.DeletedAt)).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("contracts/{id:guid}/restore")]
|
||||||
|
[RequirePermission(ModuleType.Contracts, PermissionAction.Recover)]
|
||||||
|
public async Task<IActionResult> RestoreContract(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var restored = await _contractService.RestoreAsync(id, cancellationToken);
|
||||||
|
return restored ? NoContent() : NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("orders")]
|
||||||
|
[RequirePermission(ModuleType.Orders, PermissionAction.Recover)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<TrashOrderResponse>>> GetDeletedOrders([FromQuery] string? search, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var orders = await _orderService.GetDeletedAsync(search, cancellationToken);
|
||||||
|
return Ok(orders.Select(o => new TrashOrderResponse(o.Id, o.RequiredQualification, o.DeletedAt)).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("orders/{id:guid}/restore")]
|
||||||
|
[RequirePermission(ModuleType.Orders, PermissionAction.Recover)]
|
||||||
|
public async Task<IActionResult> RestoreOrder(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var restored = await _orderService.RestoreAsync(id, cancellationToken);
|
||||||
|
return restored ? NoContent() : NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("facility-contacts")]
|
||||||
|
[RequirePermission(ModuleType.Facilities, PermissionAction.Recover)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<TrashFacilityContactResponse>>> GetDeletedFacilityContacts([FromQuery] string? search, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var contacts = await _facilityContactService.GetDeletedAsync(search, cancellationToken);
|
||||||
|
return Ok(contacts.Select(c => new TrashFacilityContactResponse(c.Id, c.FacilityId, c.Name, c.DeletedAt)).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("facility-contacts/{id:guid}/restore")]
|
||||||
|
[RequirePermission(ModuleType.Facilities, PermissionAction.Recover)]
|
||||||
|
public async Task<IActionResult> RestoreFacilityContact(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var restored = await _facilityContactService.RestoreAsync(id, cancellationToken);
|
||||||
|
return restored ? NoContent() : NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("facility-qualification-rates")]
|
||||||
|
[RequirePermission(ModuleType.Facilities, PermissionAction.Recover)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<TrashFacilityQualificationRateResponse>>> GetDeletedFacilityQualificationRates([FromQuery] string? search, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var rates = await _facilityQualificationRateService.GetDeletedAsync(search, cancellationToken);
|
||||||
|
return Ok(rates.Select(r => new TrashFacilityQualificationRateResponse(r.Id, r.FacilityId, r.Qualification, r.DeletedAt)).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("facility-qualification-rates/{id:guid}/restore")]
|
||||||
|
[RequirePermission(ModuleType.Facilities, PermissionAction.Recover)]
|
||||||
|
public async Task<IActionResult> RestoreFacilityQualificationRate(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var restored = await _facilityQualificationRateService.RestoreAsync(id, cancellationToken);
|
||||||
|
return restored ? NoContent() : NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("absences")]
|
||||||
|
[RequirePermission(ModuleType.Absences, PermissionAction.Recover)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<TrashAbsenceResponse>>> GetDeletedAbsences([FromQuery] string? search, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var absences = await _absenceService.GetDeletedAsync(search, cancellationToken);
|
||||||
|
return Ok(absences.Select(a => new TrashAbsenceResponse(a.Id, a.Type, a.DeletedAt)).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("absences/{id:guid}/restore")]
|
||||||
|
[RequirePermission(ModuleType.Absences, PermissionAction.Recover)]
|
||||||
|
public async Task<IActionResult> RestoreAbsence(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var restored = await _absenceService.RestoreAsync(id, cancellationToken);
|
||||||
|
return restored ? NoContent() : NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("time-entries")]
|
||||||
|
[RequirePermission(ModuleType.TimeEntries, PermissionAction.Recover)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<TrashTimeEntryResponse>>> GetDeletedTimeEntries([FromQuery] string? search, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var timeEntries = await _timeEntryService.GetDeletedAsync(search, cancellationToken);
|
||||||
|
return Ok(timeEntries.Select(t => new TrashTimeEntryResponse(t.Id, t.Date, t.DeletedAt)).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("time-entries/{id:guid}/restore")]
|
||||||
|
[RequirePermission(ModuleType.TimeEntries, PermissionAction.Recover)]
|
||||||
|
public async Task<IActionResult> RestoreTimeEntry(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var restored = await _timeEntryService.RestoreAsync(id, cancellationToken);
|
||||||
|
return restored ? NoContent() : NotFound();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ public class UsersController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.View)]
|
[RequirePermission(ModuleType.Users, PermissionAction.View)]
|
||||||
public async Task<ActionResult<IReadOnlyList<UserResponse>>> GetAll(CancellationToken cancellationToken)
|
public async Task<ActionResult<IReadOnlyList<UserResponse>>> GetAll(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var users = await _userService.GetAllAsync(cancellationToken);
|
var users = await _userService.GetAllAsync(cancellationToken);
|
||||||
@@ -40,7 +40,7 @@ public class UsersController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Create)]
|
[RequirePermission(ModuleType.Users, PermissionAction.Create)]
|
||||||
public async Task<ActionResult<UserResponse>> Create(CreateUserRequest request, CancellationToken cancellationToken)
|
public async Task<ActionResult<UserResponse>> Create(CreateUserRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(request.Username) || request.Username.Length > 100)
|
if (string.IsNullOrWhiteSpace(request.Username) || request.Username.Length > 100)
|
||||||
@@ -86,7 +86,7 @@ public class UsersController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/reset-password")]
|
[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)
|
public async Task<IActionResult> ResetPassword(Guid id, ResetUserPasswordRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var (parseError, mode, pinValidity) = ParseModeAndPinValidity(request.Mode, request.PinValidityDays, request.InitialPassword);
|
var (parseError, mode, pinValidity) = ParseModeAndPinValidity(request.Mode, request.PinValidityDays, request.InitialPassword);
|
||||||
@@ -120,7 +120,7 @@ public class UsersController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
[HttpPut("{id:guid}")]
|
||||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
[RequirePermission(ModuleType.Users, PermissionAction.Edit)]
|
||||||
public async Task<IActionResult> Update(Guid id, UpdateUserRequest request, CancellationToken cancellationToken)
|
public async Task<IActionResult> Update(Guid id, UpdateUserRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = await _userService.UpdateAsync(id, request.RoleId, request.IsActive, 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(
|
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")]
|
[HttpPost("{id:guid}/permission-overrides")]
|
||||||
@@ -185,7 +185,7 @@ public class UsersController : ControllerBase
|
|||||||
public async Task<ActionResult<UserPermissionOverrideResponse>> AddPermissionOverride(
|
public async Task<ActionResult<UserPermissionOverrideResponse>> AddPermissionOverride(
|
||||||
Guid id, AddUserPermissionOverrideRequest request, CancellationToken cancellationToken)
|
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)
|
if (!result.Success)
|
||||||
{
|
{
|
||||||
return result.FailureReason switch
|
return result.FailureReason switch
|
||||||
@@ -196,7 +196,7 @@ public class UsersController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
var o = result.Override!;
|
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}")]
|
[HttpDelete("{id:guid}/permission-overrides/{overrideId:guid}")]
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ namespace OmsorgCore.Api.Controllers;
|
|||||||
/// Verwaltet die konfigurierbaren Auswahllisten (Mitarbeiterstatus, Beschäftigungsart, CRM-Status,
|
/// Verwaltet die konfigurierbaren Auswahllisten (Mitarbeiterstatus, Beschäftigungsart, CRM-Status,
|
||||||
/// Einrichtungstyp, Vertragstyp/-status, Auftragsstatus) — siehe omsorgCore/CLAUDE.md, Abschnitt
|
/// Einrichtungstyp, Vertragstyp/-status, Auftragsstatus) — siehe omsorgCore/CLAUDE.md, Abschnitt
|
||||||
/// "Konfigurierbare Auswahllisten". Lesen ist für jeden eingeloggten Nutzer erlaubt (die aufrufenden
|
/// "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
|
/// Formulare gehören zu unterschiedlichen Modulen), Schreiben ist eine eigene Admin-Funktion
|
||||||
/// dasselbe Recht wie die übrige "Einstellungen"-Seite (<see cref="ModuleType.UserManagement"/>).
|
/// (<see cref="ModuleType.Configuration"/>), getrennt von der Benutzer-/Rechteverwaltung.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
@@ -42,7 +42,7 @@ public class ValueListsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{key}/items")]
|
[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)
|
public async Task<ActionResult<ValueListItemResponse>> CreateItem(string key, CreateValueListItemRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(request.Value) || request.Value.Length > 100)
|
if (string.IsNullOrWhiteSpace(request.Value) || request.Value.Length > 100)
|
||||||
@@ -53,7 +53,7 @@ public class ValueListsController : ControllerBase
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var item = await _valueListService.CreateItemAsync(
|
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));
|
return Ok(ToResponse(item));
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException ex)
|
catch (InvalidOperationException ex)
|
||||||
@@ -63,7 +63,7 @@ public class ValueListsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{key}/items/{id:guid}")]
|
[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)
|
public async Task<ActionResult<ValueListItemResponse>> UpdateItem(string key, Guid id, UpdateValueListItemRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(request.Value) || request.Value.Length > 100)
|
if (string.IsNullOrWhiteSpace(request.Value) || request.Value.Length > 100)
|
||||||
@@ -72,12 +72,12 @@ public class ValueListsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
var item = await _valueListService.UpdateItemAsync(
|
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));
|
return item is null ? NotFound() : Ok(ToResponse(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{key}/items/{id:guid}")]
|
[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)
|
public async Task<IActionResult> DeleteItem(string key, Guid id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = await _valueListService.DeleteItemAsync(id, cancellationToken);
|
var result = await _valueListService.DeleteItemAsync(id, cancellationToken);
|
||||||
@@ -97,7 +97,7 @@ public class ValueListsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{key}/items/{id:guid}/usages")]
|
[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)
|
public async Task<ActionResult<IReadOnlyList<ValueListUsageResponse>>> GetUsages(string key, Guid id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var usages = await _valueListService.GetUsagesAsync(id, 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)
|
public async Task<ActionResult<IReadOnlyList<ValueListTransitionResponse>>> GetTransitions(string key, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var transitions = await _valueListService.GetTransitionsAsync(key, 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")]
|
[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)
|
public async Task<IActionResult> ReplaceTransitions(string key, List<ValueListTransitionRequest> request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await _valueListService.ReplaceTransitionsAsync(key, request.Select(t => (t.FromItemId, t.ToItemId)), 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)
|
private static ValueListItemResponse ToResponse(ValueListItem item)
|
||||||
=> new(item.Id, item.Value, item.SortOrder, item.IsDefault, item.IsInitial, item.IsTerminal);
|
=> new(item.Id, item.Value, item.SortOrder, item.IsDefault, item.IsInitial, item.IsTerminal, item.TriggersFollowUp);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ builder.Services.AddSwaggerGen(options =>
|
|||||||
// Schichten verdrahten: Domain kennt niemanden, Application kennt nur Domain,
|
// Schichten verdrahten: Domain kennt niemanden, Application kennt nur Domain,
|
||||||
// Infrastructure implementiert Application-Interfaces, Engine ist die Event-Schicht
|
// Infrastructure implementiert Application-Interfaces, Engine ist die Event-Schicht
|
||||||
// auf denselben Daten, Api verdrahtet alles nur hier.
|
// auf denselben Daten, Api verdrahtet alles nur hier.
|
||||||
|
builder.Services.AddMemoryCache();
|
||||||
builder.Services.AddApplication();
|
builder.Services.AddApplication();
|
||||||
builder.Services.AddInfrastructure(builder.Configuration);
|
builder.Services.AddInfrastructure(builder.Configuration);
|
||||||
builder.Services.AddEngine();
|
builder.Services.AddEngine();
|
||||||
@@ -58,6 +59,18 @@ builder.Services.AddEmail(builder.Configuration);
|
|||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
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();
|
var jwtOptions = builder.Configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>() ?? new JwtOptions();
|
||||||
|
|
||||||
builder.Services
|
builder.Services
|
||||||
@@ -183,6 +196,8 @@ if (app.Environment.IsDevelopment())
|
|||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
|
app.UseCors("Frontend");
|
||||||
|
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
|||||||
@@ -30,4 +30,13 @@ public class CurrentUserService : ICurrentUserService
|
|||||||
public string? RoleName => _httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.Role);
|
public string? RoleName => _httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.Role);
|
||||||
|
|
||||||
public string? IpAddress => _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
|
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": "*",
|
"AllowedHosts": "*",
|
||||||
|
"Cors": {
|
||||||
|
"AllowedOrigins": []
|
||||||
|
},
|
||||||
"Jwt": {
|
"Jwt": {
|
||||||
"Issuer": "OmsorgCore",
|
"Issuer": "OmsorgCore",
|
||||||
"Audience": "OmsorgClients",
|
"Audience": "OmsorgClients",
|
||||||
@@ -21,6 +24,11 @@
|
|||||||
"MaxLoginFailures": 5,
|
"MaxLoginFailures": 5,
|
||||||
"LoginLockoutMinutes": 10
|
"LoginLockoutMinutes": 10
|
||||||
},
|
},
|
||||||
|
"Storage": {
|
||||||
|
"DocumentsRootPath": "App_Data/documents",
|
||||||
|
"MaxDocumentSizeBytes": 20971520,
|
||||||
|
"AllowedDocumentContentTypes": "application/pdf,image/jpeg,image/png"
|
||||||
|
},
|
||||||
"Email": {
|
"Email": {
|
||||||
"PinExpiryMinutes": 5,
|
"PinExpiryMinutes": 5,
|
||||||
"MaxAttempts": 3,
|
"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);
|
CancellationToken cancellationToken = default);
|
||||||
Task AddAsync(Contract contract, CancellationToken cancellationToken = default);
|
Task AddAsync(Contract contract, CancellationToken cancellationToken = default);
|
||||||
Task UpdateAsync(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);
|
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,4 +12,12 @@ public interface ICurrentUserService
|
|||||||
string? Username { get; }
|
string? Username { get; }
|
||||||
string? RoleName { get; }
|
string? RoleName { get; }
|
||||||
string? IpAddress { 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<Employee?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
Task<IReadOnlyList<Employee>> GetAllAsync(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(
|
Task<(IReadOnlyList<Employee> Items, int TotalCount)> GetPagedAsync(
|
||||||
string? search,
|
string? search,
|
||||||
string? status,
|
string? status,
|
||||||
string? employmentType,
|
string? employmentType,
|
||||||
int page,
|
int page,
|
||||||
int pageSize,
|
int pageSize,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default,
|
||||||
|
Guid? restrictToEmployeeId = null);
|
||||||
Task AddAsync(Employee employee, CancellationToken cancellationToken = default);
|
Task AddAsync(Employee employee, CancellationToken cancellationToken = default);
|
||||||
Task UpdateAsync(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);
|
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,5 +8,8 @@ public interface IFacilityContactRepository
|
|||||||
Task<IReadOnlyList<FacilityContact>> GetByFacilityIdAsync(Guid facilityId, CancellationToken cancellationToken = default);
|
Task<IReadOnlyList<FacilityContact>> GetByFacilityIdAsync(Guid facilityId, CancellationToken cancellationToken = default);
|
||||||
Task AddAsync(FacilityContact contact, CancellationToken cancellationToken = default);
|
Task AddAsync(FacilityContact contact, CancellationToken cancellationToken = default);
|
||||||
Task UpdateAsync(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);
|
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
using OmsorgCore.Domain.Entities;
|
||||||
|
|
||||||
|
namespace OmsorgCore.Application.Abstractions;
|
||||||
|
|
||||||
|
public interface IFacilityQualificationRateRepository
|
||||||
|
{
|
||||||
|
Task<FacilityQualificationRate?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
|
Task<IReadOnlyList<FacilityQualificationRate>> GetByFacilityIdAsync(Guid facilityId, CancellationToken cancellationToken = default);
|
||||||
|
Task AddAsync(FacilityQualificationRate rate, CancellationToken cancellationToken = default);
|
||||||
|
Task UpdateAsync(FacilityQualificationRate rate, CancellationToken cancellationToken = default);
|
||||||
|
Task<bool> SoftDeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
|
Task<IReadOnlyList<FacilityQualificationRate>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||||
|
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
|
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -9,10 +9,14 @@ public interface IFacilityRepository
|
|||||||
Task<(IReadOnlyList<Facility> Items, int TotalCount)> GetPagedAsync(
|
Task<(IReadOnlyList<Facility> Items, int TotalCount)> GetPagedAsync(
|
||||||
string? search,
|
string? search,
|
||||||
string? crmStatus,
|
string? crmStatus,
|
||||||
|
bool followUpDueOnly,
|
||||||
int page,
|
int page,
|
||||||
int pageSize,
|
int pageSize,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
Task AddAsync(Facility facility, CancellationToken cancellationToken = default);
|
Task AddAsync(Facility facility, CancellationToken cancellationToken = default);
|
||||||
Task UpdateAsync(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);
|
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,16 @@ public interface IOrderRepository
|
|||||||
string? search,
|
string? search,
|
||||||
Guid? statusId,
|
Guid? statusId,
|
||||||
Guid? facilityId,
|
Guid? facilityId,
|
||||||
|
string? priority,
|
||||||
|
string? requiredQualification,
|
||||||
|
string? shiftType,
|
||||||
int page,
|
int page,
|
||||||
int pageSize,
|
int pageSize,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
Task AddAsync(Order order, CancellationToken cancellationToken = default);
|
Task AddAsync(Order order, CancellationToken cancellationToken = default);
|
||||||
Task UpdateAsync(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);
|
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,20 @@ public interface IPermissionService
|
|||||||
{
|
{
|
||||||
Task<bool> HasPermissionAsync(Guid userId, ModuleType module, PermissionAction action, CancellationToken cancellationToken = default);
|
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>
|
/// <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);
|
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);
|
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);
|
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>
|
/// <summary>Schlanker Lookup ohne Includes - für den SecurityStamp-Check bei jedem Request.</summary>
|
||||||
Task<User?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
Task<User?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
@@ -17,6 +20,9 @@ public interface IUserRepository
|
|||||||
|
|
||||||
Task<IReadOnlyList<User>> GetAllAsync(CancellationToken cancellationToken = default);
|
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<User?> GetByEmployeeIdAsync(Guid employeeId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<bool> ExistsByUsernameAsync(string username, 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<bool> CanTransitionAsync(Guid fromItemId, Guid toItemId, CancellationToken cancellationToken = default);
|
||||||
Task<IReadOnlyList<ValueListItemTransition>> GetTransitionsAsync(string key, 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 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);
|
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,12 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IEmployeeService, EmployeeService>();
|
services.AddScoped<IEmployeeService, EmployeeService>();
|
||||||
services.AddScoped<IFacilityService, FacilityService>();
|
services.AddScoped<IFacilityService, FacilityService>();
|
||||||
services.AddScoped<IFacilityContactService, FacilityContactService>();
|
services.AddScoped<IFacilityContactService, FacilityContactService>();
|
||||||
|
services.AddScoped<IFacilityQualificationRateService, FacilityQualificationRateService>();
|
||||||
services.AddScoped<IContractService, ContractService>();
|
services.AddScoped<IContractService, ContractService>();
|
||||||
services.AddScoped<IOrderService, OrderService>();
|
services.AddScoped<IOrderService, OrderService>();
|
||||||
|
services.AddScoped<IAbsenceService, AbsenceService>();
|
||||||
|
services.AddScoped<ITimeEntryService, TimeEntryService>();
|
||||||
|
services.AddScoped<IDocumentService, DocumentService>();
|
||||||
services.AddScoped<IValueListService, ValueListService>();
|
services.AddScoped<IValueListService, ValueListService>();
|
||||||
services.AddScoped<ISessionAdminService, SessionAdminService>();
|
services.AddScoped<ISessionAdminService, SessionAdminService>();
|
||||||
services.AddScoped<IPasswordResetService, PasswordResetService>();
|
services.AddScoped<IPasswordResetService, PasswordResetService>();
|
||||||
|
|||||||
@@ -3,4 +3,4 @@ using OmsorgCore.Domain.Enums;
|
|||||||
namespace OmsorgCore.Application.Models;
|
namespace OmsorgCore.Application.Models;
|
||||||
|
|
||||||
/// <summary>Eine für einen User tatsächlich gewährte Modul/Aktion-Kombination (Ergebnis der Rechte-Auflösung).</summary>
|
/// <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;
|
namespace OmsorgCore.Application.Models;
|
||||||
|
|
||||||
/// <summary>Ein einzelner UserPermissionOverride-Eintrag für die Admin-Ansicht/-Bearbeitung.</summary>
|
/// <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>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<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.Application.Abstractions;
|
||||||
using OmsorgCore.Domain.Entities;
|
using OmsorgCore.Domain.Entities;
|
||||||
|
using OmsorgCore.Domain.Enums;
|
||||||
|
|
||||||
namespace OmsorgCore.Application.Services;
|
namespace OmsorgCore.Application.Services;
|
||||||
|
|
||||||
public class ContractService : IContractService
|
public class ContractService : IContractService
|
||||||
{
|
{
|
||||||
private readonly IContractRepository _contractRepository;
|
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;
|
_contractRepository = contractRepository;
|
||||||
|
_permissionService = permissionService;
|
||||||
|
_currentUserService = currentUserService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<IReadOnlyList<Contract>> GetAllAsync(CancellationToken cancellationToken = default)
|
public Task<IReadOnlyList<Contract>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||||
=> _contractRepository.GetAllAsync(cancellationToken);
|
=> _contractRepository.GetAllAsync(cancellationToken);
|
||||||
|
|
||||||
public Task<(IReadOnlyList<Contract> Items, int TotalCount)> GetPagedAsync(
|
public async Task<(IReadOnlyList<Contract> Items, int TotalCount)> GetPagedAsync(
|
||||||
string? search,
|
string? search,
|
||||||
string? status,
|
string? status,
|
||||||
Guid? employeeId,
|
Guid? employeeId,
|
||||||
@@ -23,10 +31,43 @@ public class ContractService : IContractService
|
|||||||
int page,
|
int page,
|
||||||
int pageSize,
|
int pageSize,
|
||||||
CancellationToken cancellationToken = default)
|
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)
|
public async Task<Contract?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||||
=> _contractRepository.GetByIdAsync(id, cancellationToken);
|
{
|
||||||
|
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)
|
public async Task<Contract> CreateAsync(Contract contract, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -61,4 +102,29 @@ public class ContractService : IContractService
|
|||||||
await _contractRepository.SaveChangesAsync(cancellationToken);
|
await _contractRepository.SaveChangesAsync(cancellationToken);
|
||||||
return contract;
|
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.Application.Abstractions;
|
||||||
using OmsorgCore.Domain.Entities;
|
using OmsorgCore.Domain.Entities;
|
||||||
|
using OmsorgCore.Domain.Enums;
|
||||||
|
|
||||||
namespace OmsorgCore.Application.Services;
|
namespace OmsorgCore.Application.Services;
|
||||||
|
|
||||||
public class EmployeeService : IEmployeeService
|
public class EmployeeService : IEmployeeService
|
||||||
{
|
{
|
||||||
private readonly IEmployeeRepository _employeeRepository;
|
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;
|
_employeeRepository = employeeRepository;
|
||||||
|
_permissionService = permissionService;
|
||||||
|
_currentUserService = currentUserService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<IReadOnlyList<Employee>> GetAllAsync(CancellationToken cancellationToken = default)
|
public Task<IReadOnlyList<Employee>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||||
=> _employeeRepository.GetAllAsync(cancellationToken);
|
=> _employeeRepository.GetAllAsync(cancellationToken);
|
||||||
|
|
||||||
public Task<(IReadOnlyList<Employee> Items, int TotalCount)> GetPagedAsync(
|
public async Task<(IReadOnlyList<Employee> Items, int TotalCount)> GetPagedAsync(
|
||||||
string? search,
|
string? search,
|
||||||
string? status,
|
string? status,
|
||||||
string? employmentType,
|
string? employmentType,
|
||||||
int page,
|
int page,
|
||||||
int pageSize,
|
int pageSize,
|
||||||
CancellationToken cancellationToken = default)
|
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)
|
public async Task<Employee?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||||
=> _employeeRepository.GetByIdAsync(id, cancellationToken);
|
{
|
||||||
|
var employee = await _employeeRepository.GetByIdAsync(id, cancellationToken);
|
||||||
|
if (employee is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Guid? restrictToEmployeeId = await ResolveOwnScopeRestrictionAsync(PermissionAction.View, cancellationToken);
|
||||||
|
if (restrictToEmployeeId.HasValue && employee.Id != restrictToEmployeeId.Value)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return employee;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Own-Scope-Filterung (siehe IPermissionService.GetScopeAsync): liefert die eigene
|
||||||
|
/// EmployeeId, wenn der aktuelle User für Employees.<action> nur "Own" gewährt bekommt
|
||||||
|
/// (Guid.Empty statt null, falls kein User.EmployeeId verknüpft ist — Own ohne Anker sieht
|
||||||
|
/// dann nichts statt versehentlich alles). Null = keine Einschränkung (Scope "All" oder kein
|
||||||
|
/// eingeloggter User, letzteres blockiert der [RequirePermission]-Endpunkt-Gate ohnehin schon).
|
||||||
|
/// </summary>
|
||||||
|
private async Task<Guid?> ResolveOwnScopeRestrictionAsync(PermissionAction action, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_currentUserService.UserId is not { } userId)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scope = await _permissionService.GetScopeAsync(userId, ModuleType.Employees, action, cancellationToken);
|
||||||
|
return scope == PermissionScope.Own ? (_currentUserService.EmployeeId ?? Guid.Empty) : null;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Employee> CreateAsync(Employee employee, CancellationToken cancellationToken = default)
|
public async Task<Employee> CreateAsync(Employee employee, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -66,4 +109,29 @@ public class EmployeeService : IEmployeeService
|
|||||||
await _employeeRepository.SaveChangesAsync(cancellationToken);
|
await _employeeRepository.SaveChangesAsync(cancellationToken);
|
||||||
return employee;
|
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);
|
await _facilityContactRepository.SaveChangesAsync(cancellationToken);
|
||||||
return contact;
|
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(
|
public Task<(IReadOnlyList<Facility> Items, int TotalCount)> GetPagedAsync(
|
||||||
string? search,
|
string? search,
|
||||||
string? crmStatus,
|
string? crmStatus,
|
||||||
|
bool followUpDueOnly,
|
||||||
int page,
|
int page,
|
||||||
int pageSize,
|
int pageSize,
|
||||||
CancellationToken cancellationToken = default)
|
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)
|
public Task<Facility?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||||
=> _facilityRepository.GetByIdAsync(id, cancellationToken);
|
=> _facilityRepository.GetByIdAsync(id, cancellationToken);
|
||||||
@@ -53,10 +54,36 @@ public class FacilityService : IFacilityService
|
|||||||
facility.BillingCity = updates.BillingCity;
|
facility.BillingCity = updates.BillingCity;
|
||||||
facility.BillingCountry = updates.BillingCountry;
|
facility.BillingCountry = updates.BillingCountry;
|
||||||
facility.CrmStatus = updates.CrmStatus;
|
facility.CrmStatus = updates.CrmStatus;
|
||||||
|
facility.FollowUpDueDate = updates.FollowUpDueDate;
|
||||||
facility.UpdatedAt = DateTime.UtcNow;
|
facility.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
await _facilityRepository.UpdateAsync(facility, cancellationToken);
|
await _facilityRepository.UpdateAsync(facility, cancellationToken);
|
||||||
await _facilityRepository.SaveChangesAsync(cancellationToken);
|
await _facilityRepository.SaveChangesAsync(cancellationToken);
|
||||||
return facility;
|
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?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
Task<Contract> CreateAsync(Contract contract, CancellationToken cancellationToken = default);
|
Task<Contract> CreateAsync(Contract contract, CancellationToken cancellationToken = default);
|
||||||
Task<Contract?> UpdateAsync(Guid id, Contract updates, 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?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
Task<Employee> CreateAsync(Employee employee, CancellationToken cancellationToken = default);
|
Task<Employee> CreateAsync(Employee employee, CancellationToken cancellationToken = default);
|
||||||
Task<Employee?> UpdateAsync(Guid id, Employee updates, 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?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
Task<FacilityContact> CreateAsync(FacilityContact contact, CancellationToken cancellationToken = default);
|
Task<FacilityContact> CreateAsync(FacilityContact contact, CancellationToken cancellationToken = default);
|
||||||
Task<FacilityContact?> UpdateAsync(Guid id, FacilityContact updates, 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(
|
Task<(IReadOnlyList<Facility> Items, int TotalCount)> GetPagedAsync(
|
||||||
string? search,
|
string? search,
|
||||||
string? crmStatus,
|
string? crmStatus,
|
||||||
|
bool followUpDueOnly,
|
||||||
int page,
|
int page,
|
||||||
int pageSize,
|
int pageSize,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
Task<Facility?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
Task<Facility?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
Task<Facility> CreateAsync(Facility facility, CancellationToken cancellationToken = default);
|
Task<Facility> CreateAsync(Facility facility, CancellationToken cancellationToken = default);
|
||||||
Task<Facility?> UpdateAsync(Guid id, Facility updates, 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,
|
string? search,
|
||||||
Guid? statusId,
|
Guid? statusId,
|
||||||
Guid? facilityId,
|
Guid? facilityId,
|
||||||
|
string? priority,
|
||||||
|
string? requiredQualification,
|
||||||
|
string? shiftType,
|
||||||
int page,
|
int page,
|
||||||
int pageSize,
|
int pageSize,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
Task<Order?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
Task<Order?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
Task<Order> CreateAsync(Order order, CancellationToken cancellationToken = default);
|
Task<Order> CreateAsync(Order order, CancellationToken cancellationToken = default);
|
||||||
Task<UpdateOrderResult> UpdateAsync(Guid id, Order updates, 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>
|
/// <summary>Ersetzt die komplette RolePermission-Menge der Rolle durch die übergebene Menge (kein inkrementelles Patchen).</summary>
|
||||||
Task<UpdateRolePermissionsResult> UpdatePermissionsAsync(
|
Task<UpdateRolePermissionsResult> UpdatePermissionsAsync(
|
||||||
Guid roleId,
|
Guid roleId,
|
||||||
IReadOnlyList<(ModuleType Module, PermissionAction Action)> permissions,
|
IReadOnlyList<(ModuleType Module, PermissionAction Action, PermissionScope Scope)> permissions,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using OmsorgCore.Domain.Entities;
|
||||||
|
|
||||||
|
namespace OmsorgCore.Application.Services;
|
||||||
|
|
||||||
|
public interface ITimeEntryService
|
||||||
|
{
|
||||||
|
Task<(IReadOnlyList<TimeEntry> Items, int TotalCount)> GetPagedAsync(
|
||||||
|
Guid? statusId,
|
||||||
|
Guid? employeeId,
|
||||||
|
Guid? orderId,
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
Task<TimeEntry?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Legt die Zeiterfassung an. Ist der aktuelle Nutzer nur mit Own-Scope berechtigt, wird
|
||||||
|
/// <paramref name="timeEntry"/>.EmployeeId ignoriert und serverseitig auf die eigene, per JWT
|
||||||
|
/// verknüpfte Mitarbeiter-Id gesetzt (analog AbsenceService.CreateAsync). StatusId wird immer
|
||||||
|
/// serverseitig auf den initialen Status ("Entwurf") gesetzt. Gibt null zurück, wenn Own-Scope
|
||||||
|
/// greift, aber kein Mitarbeiter verknüpft ist (fail-closed).
|
||||||
|
/// </summary>
|
||||||
|
Task<TimeEntry?> CreateAsync(TimeEntry timeEntry, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bearbeitet Auftrag/Datum/Zeiten/Zuschlagsstunden - nur solange der aktuelle Status
|
||||||
|
/// <see cref="ValueListItem.IsEditableByOwner"/> ist. Own-Scope-Aufrufer dürfen nur eigene
|
||||||
|
/// Einträge bearbeiten (wie bei GetByIdAsync).
|
||||||
|
/// </summary>
|
||||||
|
Task<UpdateTimeEntryResult> UpdateAsync(Guid id, TimeEntry updates, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Löst die einzige Selbst-Einreichungs-Kante (RequiresApproval=false) ab dem aktuellen Status
|
||||||
|
/// aus (Entwurf/Rückfrage -> Eingereicht). Kein Body nötig.
|
||||||
|
/// </summary>
|
||||||
|
Task<SubmitTimeEntryResult> SubmitAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Büro-Entscheidung entlang der Statuspipeline (z. B. ->Prüfung/->Rückfrage/->Freigegeben/->Abgerechnet).
|
||||||
|
/// Validiert die Ziel-Transition über CanTransitionAsync und lehnt Kanten mit RequiresApproval=false ab
|
||||||
|
/// (die gehören zu SubmitAsync, nicht hierher).
|
||||||
|
/// </summary>
|
||||||
|
Task<DecideTimeEntryResult> DecideAsync(Guid id, Guid statusId, string? adminNote, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
|
Task<IReadOnlyList<TimeEntry>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||||
|
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -42,12 +42,13 @@ public interface IUserService
|
|||||||
Guid userId,
|
Guid userId,
|
||||||
CancellationToken cancellationToken = default);
|
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(
|
Task<AddPermissionOverrideResult> AddPermissionOverrideAsync(
|
||||||
Guid userId,
|
Guid userId,
|
||||||
ModuleType module,
|
ModuleType module,
|
||||||
PermissionAction action,
|
PermissionAction action,
|
||||||
PermissionEffect effect,
|
PermissionEffect effect,
|
||||||
|
PermissionScope scope,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<RemovePermissionOverrideResult> RemovePermissionOverrideAsync(
|
Task<RemovePermissionOverrideResult> RemovePermissionOverrideAsync(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user