Add facilities, contracts, orders, value lists, audit log, and desktop app modules
Extends omsorgCore with full CRUD for Facility/Contract/Order plus configurable value lists and an audit trail, and wires the omsorgapp frontend up to the new facilities, settings, and audit-log modules; includes a sidebar active-nav-item highlight. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
ee74ed65f5
commit
e9e96a57dc
@@ -0,0 +1,149 @@
|
||||
# Infrastruktur bei IONOS – Kosten- und Risikoaufstellung
|
||||
|
||||
Entscheidungsgrundlage für den Aufbau der Firmeninfrastruktur (Prod-Datenbank, Backend, Backups).
|
||||
**Kein HA vorerst.** Verglichen werden zwei Varianten:
|
||||
|
||||
- **Variante A:** Managed-Datenbank (IONOS DBaaS PostgreSQL) + günstiger Backend-VPS
|
||||
- **Variante B:** Alles auf **einem** VPS selbst gehostet (Backend + Datenbank zusammen)
|
||||
|
||||
---
|
||||
|
||||
## 1. Wichtig vorab: zwei verschiedene IONOS-Produktlinien
|
||||
|
||||
Der große Preisunterschied kommt daher, dass IONOS zwei sehr unterschiedliche Server-Produkte hat:
|
||||
|
||||
| | **VPS Linux** (fester Tarif) | **Compute Engine / DBaaS** |
|
||||
|---|---|---|
|
||||
| CPU | **vCores** (geteilt / oversubscribed) | **Dedicated Cores** (garantiert, nie geteilt) |
|
||||
| Preis | fester Monatspreis, sehr günstig | pay-per-use, Premium |
|
||||
| Traffic | i. d. R. inklusive | separat berechnet |
|
||||
| Beispiel | 6 vCores / 8 GB / 240 GB ≈ **18 €/Monat** | 1 Dedicated Core allein ≈ **43 €/Monat** |
|
||||
| Wofür | Standard-Workloads, kleine/mittlere Firmen | garantierte Leistung, HA, Managed Services |
|
||||
|
||||
> **Merksatz:** vCore ≠ Dedicated Core. Für ein Setup ohne HA ist der feste **VPS Linux** die richtige (und deutlich günstigere) Wahl. Die Compute-Engine-Sätze lohnen erst, wenn du garantierte, nicht geteilte Leistung brauchst.
|
||||
|
||||
---
|
||||
|
||||
## 2. Annahmen & Preisbasis
|
||||
|
||||
- Alle Preise **netto** (zzgl. 19 % MwSt.; für die Firma i. d. R. als Vorsteuer abziehbar).
|
||||
- **Referenz-VPS:** 6 vCores / 8 GB RAM / 240 GB SSD = **18,00 €/Monat** (dein genannter Tarif).
|
||||
- ⚠️ **Verlängerungspreis prüfen** – IONOS-VPS-Aktionspreise steigen oft nach den ersten Monaten.
|
||||
- ⚠️ Prüfen, ob **Backup inklusive** ist oder als Acronis-Add-on extra kommt.
|
||||
- **Managed-DB (DBaaS):** kein einfacher Listen-Stückpreis; wird nach Cluster-Ressourcen abgerechnet und läuft auf **dedizierten** Enterprise-Ressourcen (per-Minute). Der DB-Betrag unten ist eine **Schätzung** – im IONOS-Preisrechner / DCD verifizieren.
|
||||
|
||||
### Ergänzende Ressourcensätze (Compute Engine, netto, pro 30 Tage = 720 h)
|
||||
|
||||
| Ressource | Satz | Kosten / 30 Tage |
|
||||
|---|---|---|
|
||||
| 1 Dedicated Core | 0,06 € / Std. | 43,20 € |
|
||||
| 1 GB RAM | 0,007 € / Std. | 5,04 € |
|
||||
| 1 GB SSD Standard | 0,07 € / 30 Tage | 0,07 € |
|
||||
| 1 GB S3-Backup | 0,007 € / 30 Tage | 0,007 € |
|
||||
|
||||
---
|
||||
|
||||
## 3. Variante A – Managed-DB + Backend-VPS
|
||||
|
||||
| Position | Konfiguration | Kosten / 30 Tage |
|
||||
|---|---|---|
|
||||
| Backend-VPS (fester Tarif) | z. B. 2–4 vCores / 4 GB / 160 GB | ~9,00 € |
|
||||
| Managed-DB (DBaaS, 1 Knoten, **geschätzt**) | ~2 Cores / 4 GB / 40 GB, dediziert | ~109,00 € |
|
||||
| DB-Backups (täglich + PITR 1 Woche) | im DBaaS **inklusive** | 0,00 € |
|
||||
| Offsite-DB-Dumps (extern, ~50 GB) | für Retention > 1 Woche | ~0,35 € |
|
||||
| **Summe Variante A** | | **~118 €/Monat netto** (~140 € brutto) |
|
||||
|
||||
**Was du dafür bekommst:** kein DB-Betrieb (Updates, Failover, Backup, Monitoring automatisch), 99,95 % SLA, PITR.
|
||||
**Der Aufpreis** entsteht durch die dedizierten DB-Ressourcen – nicht durch „Abzocke", sondern anderes Produkttier.
|
||||
|
||||
---
|
||||
|
||||
## 4. Variante B – Alles auf einem VPS
|
||||
|
||||
Backend **und** PostgreSQL (+ ggf. Gitea) auf einer Maschine. Du verwaltest die DB komplett selbst.
|
||||
|
||||
| Position | Konfiguration | Kosten / 30 Tage |
|
||||
|---|---|---|
|
||||
| 1× VPS Linux | 6 vCores / 8 GB / 240 GB | 18,00 € |
|
||||
| Offsite-Backup (extern, ~100 GB) | verschlüsselt, versioniert | ~0,70 € |
|
||||
| *(optional)* IONOS-Backup-Add-on (Acronis) | managed, einfaches Restore | Add-on-Preis prüfen |
|
||||
| **Summe Variante B** | | **~19 €/Monat netto** (~23 € brutto) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Backups vom VPS – Optionen
|
||||
|
||||
Wichtig: **Snapshots/Images ≠ Backup.** Ein Snapshot ist ein Point-in-Time-Abbild; für echte Datensicherheit brauchst du versionierte, extern liegende Backups.
|
||||
|
||||
| Option | Was | Aufwand | Kosten |
|
||||
|---|---|---|---|
|
||||
| **Externer S3 + restic/borg** | DIY, verschlüsselt, dedupliziert, versioniert, append-only gegen Ransomware | mittel (Script + Cron) | ~0,007 €/GB/Monat |
|
||||
| **IONOS Backup Service (Acronis)** | agentenbasiert, managed, einfaches Restore | gering | per GB (Add-on) |
|
||||
| **Snapshots / Images** | schnelles Rollback des ganzen Servers | gering | Storage-Satz |
|
||||
|
||||
> **3-2-1-Regel für „überlebenswichtige" Daten:** 3 Kopien, 2 Medien, **1 außerhalb IONOS**. Wenn VPS **und** Backup bei IONOS liegen, ist der Provider ein Klumpenrisiko. Mindestens eine Kopie gehört zu einem **anderen Anbieter** (z. B. Backblaze B2, Wasabi) oder offline.
|
||||
> **Datenbank-Dumps:** in Variante B täglich `pg_dump` + WAL-Archivierung; extern schieben.
|
||||
|
||||
---
|
||||
|
||||
## 6. Kostenvergleich (Überblick)
|
||||
|
||||
| Setup | Netto / Monat | Brutto / Monat |
|
||||
|---|---|---|
|
||||
| **B – alles auf 1 VPS** (kein HA) | **~19 €** | ~23 € |
|
||||
| **A – Managed DBaaS + Backend-VPS** (kein HA) | **~118 €** | ~140 € |
|
||||
|
||||
Der Unterschied ist **~6-fach** – und er ist kein „reiner Preis", sondern der Preis fürs **Abgeben des DB-Betriebs** (Automatik-Backups, PITR, Failover-fähige Infrastruktur, dedizierte Leistung).
|
||||
|
||||
---
|
||||
|
||||
## 7. Risiken
|
||||
|
||||
### Variante B – Alles auf einem VPS
|
||||
|
||||
| Risiko | Bewertung |
|
||||
|---|---|
|
||||
| **Ein Server = ein Ausfallpunkt** (Backend + DB zusammen) | **hoch** – fällt der VPS aus, ist alles offline |
|
||||
| **Kompletter DB-Betrieb bei dir** (Patching, Backup, PITR, Monitoring) | **hoch** – Zeit + Postgres-Know-how nötig |
|
||||
| **Ungetestete Backups = keine Backups** | **hoch** |
|
||||
| **vCores geteilt** → Leistung kann unter Last der Nachbarn schwanken | mittel |
|
||||
| Sicherheitshärtung komplett selbst | mittel |
|
||||
| Single-Provider-Risiko (VPS + Backup bei IONOS) | hoch – mit Offsite-Backup entschärfbar |
|
||||
| **Vorteile:** sehr günstig, volle Kontrolle, kein Lock-in, jede PG-Version | – |
|
||||
|
||||
### Variante A – Managed-DB + Backend-VPS
|
||||
|
||||
| Risiko | Bewertung |
|
||||
|---|---|
|
||||
| Höhere Kosten (~6×) | mittel |
|
||||
| Vendor-Lock-in an DBaaS | mittel – PostgreSQL portabel, Migration per `pg_dump` |
|
||||
| PITR nur 1 Woche | mittel – längere Retention über eigene Offsite-Dumps |
|
||||
| Backend-VPS bleibt in deiner Verantwortung | mittel |
|
||||
| Single-Provider-Risiko (DB + VPS bei IONOS) | hoch – mit Offsite-Backup entschärfbar |
|
||||
| **Vorteile:** kein DB-Betrieb, Auto-Backup/PITR, Failover-fähig, SLA | – |
|
||||
|
||||
### Für beide Varianten
|
||||
|
||||
- **Spiegeln ≠ Backup:** Replikation schützt gegen Ausfall, **nicht** gegen Löschfehler/Ransomware.
|
||||
- **Restore-Tests einplanen** – regelmäßig komplette Wiederherstellung üben.
|
||||
- **Offsite-Backup ist Pflicht**, egal welche Variante.
|
||||
- **HA später:** sobald Ausfallzeit teuer wird, DB-Replica bzw. zweiter Standort ergänzen.
|
||||
|
||||
---
|
||||
|
||||
## 8. Empfehlung
|
||||
|
||||
Ohne HA-Bedarf und mit Kostenfokus ist **Variante B (alles auf einem VPS, ~19 €/Monat)** für den Start absolut vertretbar – **vorausgesetzt**, die Offsite-Backups (versioniert, extern, getestet) sitzen wirklich. Das ist bei einem kombinierten Backend-+-DB-Server der entscheidende Schutz.
|
||||
|
||||
**Wechsle auf Variante A oder ergänze HA**, sobald:
|
||||
- Ausfallzeit richtig Geld/Vertrauen kostet, **oder**
|
||||
- niemand im Team dauerhaft Zeit/Know-how für DB-Betrieb hat, **oder**
|
||||
- die Datenbank so groß/kritisch wird, dass manuelles Backup-Handling zu riskant ist.
|
||||
|
||||
---
|
||||
|
||||
## 9. Hinweise
|
||||
|
||||
- Zahlen sind eine **Schätzung** zur Entscheidungsfindung; verbindliche Preise im **IONOS-Preisrechner / DCD** prüfen (v. a. DBaaS-Anteil und VPS-**Verlängerungspreis**).
|
||||
- Preise Stand der Recherche; IONOS passt Preise gelegentlich an.
|
||||
- Dies ist keine Steuer- oder Vertragsberatung.
|
||||
+6
-6
@@ -41,7 +41,7 @@ Umfasst alle drei Plattform-Ebenen: OMSORG Desktop, OMSORG Connect, OMSORG Backe
|
||||
|---|---|---|
|
||||
| **OMSORG Desktop** | Vollständige Unternehmensmodule für Büromitarbeiter | 🔶 `omsorgapp/` — Electron/React, Release 0.1.1. Vorhanden: `HomePage`, `HomeStats`, `ContractWidget`, `EmployeesPage` mit Tabs/Detailpanel. Lokale JSON-DB (`~/Documents/Omsorg Business Controls Pro/database/omsorg-local-db.json`), noch keine SQLite/Server-Anbindung. |
|
||||
| **OMSORG Connect** | Mobile/Web-App für Außendienst | ✅ `omsorgWeb/mitarbeiter-app/` — PHP/MySQL, produktiv als PWA (Manifest + Service Worker). Deckt bereits Zeiterfassung/Stundennachweis, Urlaub, Abwesenheit, Fortbildung, Dokumente, News, Benefits, Werben, Einsatzanweisung, Dienstplan, Bewertungen ab. |
|
||||
| **OMSORG Backend (Core + Engine)** | Eine Backend-Komponente, zwei interne Schichten: **Datenschicht (Core)** — gemeinsame Datenbasis, 6 Objekte; **Event-Schicht (Engine)** — Ereignis→Aktion-Automatisierung ohne eigene UI, arbeitet auf denselben Objekten der Datenschicht | 🔶 `omsorgCore/` — Grundgerüst steht (C#/.NET 8, ASP.NET Core Controller, EF Core/PostgreSQL, JWT-Auth, Rollen+Permission-Override-Rechtesystem, In-Process-Event-Dispatcher). Bisher nur `Employee` mit vollem Repository/Service/Controller; die anderen 5 Core-Objekte existieren 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). **Noch keine Anbindung** von `omsorgWeb` (MySQL) an dieses Backend, und die übrigen `omsorgapp`-Fachmodule (Kunden, Disposition, ...) nutzen weiterhin die lokale JSON-DB — die Insellösungen bestehen dort technisch weiter, bis diese Migration erfolgt. DB-Migration wurde noch nicht gegen eine echte PostgreSQL-Instanz verifiziert. 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` 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`. |
|
||||
|
||||
**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,7 +75,7 @@ Umfasst alle drei Plattform-Ebenen: OMSORG Desktop, OMSORG Connect, OMSORG Backe
|
||||
| ID | Anforderung | Akteur | Akzeptanzkriterium | Status |
|
||||
|---|---|---|---|---|
|
||||
| FR-MA-1 | Stammdaten erfassen (Name, Geburtsdatum, Adresse, Kontakt, Notfallkontakt, Beschäftigungsart, Qualifikation, Ein-/Austritt, Status) [Blueprint 19.1] | Sabina, Malik, Sabrina | Datensatz anlegen/bearbeiten mit Pflichtfeldern; Validierung verhindert unvollständige Sätze | ✅ (alle Felder in `Employee`-Entity + `EmployeeForm` in `omsorgapp` vorhanden; Validierung serverseitig in `EmployeesController` (Pflichtfelder, Längen, Ein-/Austrittslogik, Beschäftigungsart-Allowlist) und clientseitig verdrahtet; `omsorgapp` spricht für Mitarbeiter jetzt über `employeesClient.cjs` echt gegen `omsorgCore`, keine JSON-lokale Persistenz mehr für dieses Modul — Abgleich mit Connect/`omsorgWeb` weiterhin offen, siehe FR-CORE-1) |
|
||||
| FR-MA-2 | Arbeitsvertragsdaten (Beginn/Ende, Arbeitszeit, Stundenlohn, Zuschläge, Überstunden, Urlaubsanspruch, Probezeit) [Blueprint 19.1] | Sabina, Malik, Sabrina | Vertragsfelder je Mitarbeiter editierbar, Historie bei Änderung nachvollziehbar | ⬜ |
|
||||
| FR-MA-2 | Arbeitsvertragsdaten (Beginn/Ende, Arbeitszeit, Stundenlohn, Zuschläge, Überstunden, Urlaubsanspruch, Probezeit) [Blueprint 19.1] | Sabina, Malik, Sabrina | Vertragsfelder je Mitarbeiter editierbar, Historie bei Änderung nachvollziehbar | 🔶 (`Contract`-Entity in `omsorgCore` um alle geforderten Felder erweitert, volles Repository/Service/Controller (`ContractsController`, `GET/POST/PUT /api/contracts`) nach dem Facility-Muster, gegated über `[RequirePermission(ModuleType.Contracts, ...)]`; Änderungshistorie automatisch über den generischen `AuditSaveChangesInterceptor` abgedeckt — noch kein `omsorgapp`-UI-Modul dafür) |
|
||||
| FR-MA-3 | Dokumente, Qualifikationen, Fortbildungen, Führerschein, Gesundheitsnachweise, Notizen, Historie je Mitarbeiter | Sabina, Malik, Sabrina | Upload/Anzeige je Kategorie, Zugriffsprotokoll | 🔶 (Dokumentenarchiv existiert bereits in Connect: `pages/dokumentenarchiv.php`, `actions/upload-dokument.php`; im Desktop-Modul fehlt es) |
|
||||
| FR-MA-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) |
|
||||
@@ -85,8 +85,8 @@ Umfasst alle drei Plattform-Ebenen: OMSORG Desktop, OMSORG Connect, OMSORG Backe
|
||||
|
||||
| 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 | ⬜ |
|
||||
| FR-EIN-2 | Mehrere Ansprechpartner je Einrichtung mit Funktion, Abteilung, Kontaktwegen, Notizen | Sabina, Malik, Sabrina, Sascha | Liste von Ansprechpartnern editierbar, mind. 1:n-Beziehung | ⬜ |
|
||||
| FR-EIN-1 | Stammdaten (Name, Art, Adresse, Rechnungsadresse, Telefon, E-Mail, Website, Status) [Blueprint 19.2] | Sabina, Malik, Sabrina, Sascha (Leads) | Datensatz anlegen/bearbeiten | 🔶 (`Facility`-Entity + volles Repository/Service/Controller (`FacilitiesController`, `GET/POST/PUT /api/facilities`) in `omsorgCore` vorhanden, serverseitige Rechteprüfung über `[RequirePermission(ModuleType.Facilities, ...)]`; Felder Name/Art(`FacilityType`)/Adresse/Rechnungsadresse/**Website**/Status(`CrmStatus`) abgedeckt — Adresse und Rechnungsadresse sind je strukturierte Felder (Straße/PLZ/Ort/Land, analog Mitarbeiter-Adresse), kein Freitext; UI-Modul in `omsorgapp` vorhanden (`FacilitiesPage`, Sidebar-Tab "Kunden", analog `EmployeesPage`) — Anlegen/Bearbeiten funktionsfähig gegen `omsorgCore`; **Telefon/E-Mail sind entgegen dieser Zeile bisher nicht als eigene `Facility`-Felder modelliert** — nur `FacilityContact` (FR-EIN-2) trägt Telefon/E-Mail je Ansprechpartner, es gibt kein allgemeines Einrichtungs-Telefon/-E-Mail; das war schon vor dieser Änderung so dokumentiert, aber sachlich falsch — noch zu klären/nachzuziehen) |
|
||||
| FR-EIN-2 | Mehrere Ansprechpartner je Einrichtung mit Funktion, Abteilung, Kontaktwegen, Notizen | Sabina, Malik, Sabrina, Sascha | Liste von Ansprechpartnern editierbar, mind. 1:n-Beziehung | 🔶 (neue Entität `FacilityContact` (1:n zu `Facility`) in `omsorgCore`, `FacilityContactsController` unter `GET/POST /api/facilities/{facilityId}/contacts`, `PUT .../contacts/{id}`, gegated über dieselben `ModuleType.Facilities`-Rechte wie die Einrichtung selbst; Felder Name/Funktion(`Role`)/Abteilung(`Department`)/Telefon/E-Mail/Notizen abgedeckt; UI-Liste im `FacilityDetailPanel` (`omsorgapp`) anlegen/bearbeiten funktionsfähig; **kein Löschen** — konsistent mit dem noch fehlenden Soft-Delete für die übrigen Core-Objekte, siehe `omsorgCore/CLAUDE.md` "Offene Punkte") |
|
||||
| FR-EIN-3 | CRM-Status-Pipeline: Lead → kontaktiert → kein Bedarf → Wiedervorlage → Interesse → Angebot → Kunde → Bestandskunde [Blueprint, omsorg.md] | Sascha, Sabrina | Statuswechsel wird protokolliert; bei „kein Bedarf" wird automatisch Wiedervorlage in 14 Tagen erzeugt | ⬜ |
|
||||
| FR-EIN-4 | Konditionen (Verrechnungssatz, Zuschläge, Fahrtkosten, Mindeststunden, Zahlungsziel etc.) je Einrichtung [Blueprint 19.2] | Sabina, Malik, Sabrina | Konditionssatz ist Grundlage für Rechnungserstellung (siehe FR-RE-1) | ⬜ |
|
||||
| FR-EIN-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 | ⬜ |
|
||||
@@ -95,8 +95,8 @@ Umfasst alle drei Plattform-Ebenen: OMSORG Desktop, OMSORG Connect, OMSORG Backe
|
||||
|
||||
| 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 | ⬜ |
|
||||
| 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 | ⬜ |
|
||||
| FR-EM-1 | Auftrag erfassen (Einrichtung, Ansprechpartner, Qualifikation, Zeitraum, Schichtart, Anzahl Mitarbeiter, Konditionen, Priorität) [Blueprint 19.4] | Sabrina | Auftragsdatensatz mit Pflichtfeldern anlegbar | 🔶 (`Order`-Entity in `omsorgCore` um alle geforderten Felder erweitert, volles Repository/Service/Controller (`OrdersController`, `GET/POST/PUT /api/orders`) nach dem Facility/Contract-Muster, gegated über `[RequirePermission(ModuleType.Orders, ...)]`; Ansprechpartner (`FacilityContactId`) wird gegen die angegebene Einrichtung cross-validiert — noch kein `omsorgapp`-UI-Modul dafür) |
|
||||
| FR-EM-2 | Auftragsstatus-Pipeline: Anfrage → Prüfung → offen → teilweise besetzt → vollständig besetzt → aktiv → abgeschlossen/storniert [Blueprint 19.4] | Sabrina | Statuswechsel nur in zulässiger Reihenfolge, sichtbar im Dashboard | 🔶 (Pipeline serverseitig erzwungen: `OrderStatusDefinition`/`OrderStatusTransition` in `omsorgCore` bilden die Status und erlaubten Übergänge **DB-konfigurierbar** statt hartcodiert ab, Standard-Pipeline per `DbSeeder.SeedOrderStatusesAsync` geseedet, `OrderService.UpdateAsync` lehnt unzulässige Übergänge mit `400` ab; Dashboard-Sichtbarkeit fehlt noch — kein `omsorgapp`-UI) |
|
||||
| FR-EM-3 | Mitarbeiterzuweisung prüft Qualifikation, Verfügbarkeit, Arbeitszeit, Abwesenheiten, Überschneidungen, Vertragsbedingungen [Blueprint 19.4] | Sabrina | System verhindert/warnt bei Konflikten vor Zuweisung | ⬜ |
|
||||
| FR-EM-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 | ⬜ |
|
||||
|
||||
+74
-10
@@ -27,10 +27,10 @@ omsorgCore/
|
||||
.config/dotnet-tools.json # lokales dotnet-ef Tool (dotnet tool restore)
|
||||
src/
|
||||
OmsorgCore.Domain/ # Entitäten, Enums. Keine Abhängigkeit auf andere Projekte.
|
||||
Common/ # Entity, AuditableEntity (Basisklassen)
|
||||
Enums/ # ModuleType, PermissionAction, PermissionEffect
|
||||
Common/ # Entity, AuditableEntity, AuditRedactedAttribute (Basisklassen)
|
||||
Enums/ # ModuleType, PermissionAction, PermissionEffect, AuditEventCategory
|
||||
Entities/ # Employee, Facility, Contract, Order, TimeEntry, Invoice,
|
||||
# User, Role, RolePermission, UserPermissionOverride
|
||||
# User, Role, RolePermission, UserPermissionOverride, AuditLogEntry
|
||||
OmsorgCore.Application/ # Business-Logik. Abhängig von Domain.
|
||||
Abstractions/ # Interfaces: IEmployeeRepository, IUserRepository,
|
||||
# IPasswordHasher, IJwtTokenGenerator, ICurrentUserService,
|
||||
@@ -42,16 +42,16 @@ omsorgCore/
|
||||
OmsorgCoreDbContext.cs
|
||||
Configurations/ # ein IEntityTypeConfiguration<T> pro Entität
|
||||
Migrations/ # EF-Core-Migrationen (InitialCreate bereits erzeugt)
|
||||
Repositories/ # EmployeeRepository, UserRepository, RefreshTokenRepository (implementieren Application-Interfaces)
|
||||
Repositories/ # EmployeeRepository, FacilityRepository, FacilityContactRepository, UserRepository, RefreshTokenRepository, AuditLogRepository (implementieren Application-Interfaces)
|
||||
Security/ # PasswordHasher, JwtOptions, JwtTokenGenerator, RefreshTokenOptions, RefreshTokenGenerator
|
||||
DependencyInjection.cs # AddInfrastructure(configuration)
|
||||
OmsorgCore.Engine/ # Event-Schicht. Abhängig von Domain + Application.
|
||||
Events/ # IDomainEvent, IDomainEventHandler<T>, IDomainEventDispatcher,
|
||||
# DomainEventDispatcher (In-Process, kein Message-Bus), Beispiel-Event
|
||||
Handlers/ # Beispiel-Handler (EmployeeCreatedHandler)
|
||||
Handlers/ # Beispiel-Handler (EmployeeCreatedHandler), AuditEventHandler
|
||||
DependencyInjection.cs # AddEngine()
|
||||
OmsorgCore.Api/ # ASP.NET Core Web API. Abhängig von Application+Infrastructure+Engine.
|
||||
Controllers/ # AuthController, EmployeesController, HealthController, AdminSessionsController
|
||||
Controllers/ # AuthController, EmployeesController, FacilitiesController, FacilityContactsController, HealthController, AdminSessionsController, AuditLogController
|
||||
Contracts/ # Request-/Response-DTOs (LoginRequest, EmployeeResponse, ...)
|
||||
Security/ # CurrentUserService, RequirePermissionAttribute
|
||||
Program.cs # einziger Ort, an dem alle Schichten verdrahtet werden
|
||||
@@ -75,6 +75,21 @@ Rechteprüfung auf Controller-Actions:
|
||||
```
|
||||
Das Attribut (`src/OmsorgCore.Api/Security/RequirePermissionAttribute.cs`) prüft serverseitig über `IPermissionService` — nicht nur im Client (REQUIREMENTS.md NFR-9). Jeder Controller außer `AuthController` trägt zusätzlich `[Authorize]`.
|
||||
|
||||
**Rollen-Rechte-Matrix und User-Overrides verwalten (Admin-Flow):** Eine neu angelegte Rolle (`RoleService.CreateAsync`) hat zunächst keine `RolePermission`-Einträge — die Rechte-Matrix wird separat gesetzt über `GET /api/roles/{id}` (Rolle inkl. ihrer aktuellen `RolePermission`-Liste, `RoleService.GetByIdWithPermissionsAsync`) und `PUT /api/roles/{id}/permissions` (`RoleService.UpdatePermissionsAsync` — ersetzt die komplette `RolePermission`-Menge der Rolle durch die übergebene Menge, kein inkrementelles Patchen). Individuelle `UserPermissionOverride`-Ausnahmen eines Users werden über `GET/POST/DELETE /api/users/{id}/permission-overrides[...]` verwaltet (`UserService.GetPermissionOverridesAsync`/`AddPermissionOverrideAsync`/`RemovePermissionOverrideAsync` — `AddPermissionOverrideAsync` ist ein Upsert: existiert bereits ein Override für dasselbe Modul+Aktion bei diesem User, wird dessen `Effect` aktualisiert statt dupliziert). Alle diese Endpoints liegen auf `RolesController`/`UsersController`, gegated über `[RequirePermission(ModuleType.UserManagement, View|Edit)]` wie der Rest der Nutzerverwaltung. Admin-UI dazu: `omsorgapp/src/modules/settings/` (`SettingsPage`, `RolesPanel`, `RolePermissionMatrix`, `UserOverridesPanel`).
|
||||
|
||||
**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.
|
||||
|
||||
## Audit-Log ("wer hat wann was verändert")
|
||||
|
||||
Zwei sich ergänzende Erfassungswege, beide münden in dieselbe Tabelle `AuditLogEntry` (`src/OmsorgCore.Domain/Entities/AuditLogEntry.cs`, `Category` unterscheidet die Herkunft):
|
||||
|
||||
1. **Automatisch, Entity-Änderungen:** `AuditSaveChangesInterceptor` (`src/OmsorgCore.Infrastructure/Persistence/AuditSaveChangesInterceptor.cs`, ein EF-Core-`SaveChangesInterceptor`) erfasst bei **jedem** `SaveChangesAsync` auf `OmsorgCoreDbContext` automatisch jede Create/Update/(Soft-)Delete-Änderung an einer beliebigen `AuditableEntity`-Subklasse — inkl. aller künftigen (Facility/Contract/Order/TimeEntry/Invoice), **ohne dass dafür Code in deren Services/Controllern nötig ist**. Bei Update wird nur der tatsächliche Feld-Diff (`{old, new}` je geändertem Feld) als JSON in `Details` gespeichert; ein Soft-Delete (`IsDeleted: false→true`) wird als Action `"Deleted"` erkannt, nicht als `"Updated"`. Sensible Felder (aktuell `User.PasswordHash`/`User.SecurityStamp`) sind mit `[AuditRedacted]` (`src/OmsorgCore.Domain/Common/AuditRedactedAttribute.cs`) markiert — der Interceptor ersetzt ihren Wert im Log durch `"***redacted***"`. **Neue sensible Felder in künftigen Entitäten müssen dieses Attribut bekommen**, sonst landen sie im Klartext im Audit-Log.
|
||||
2. **Explizit, Verhaltens-Ereignisse ohne Entity-Änderung:** `AuditEvent` (`src/OmsorgCore.Engine/Events/AuditEvent.cs`) über den bestehenden `IDomainEventDispatcher` dispatcht, analog zu `EmployeeCreatedEvent` (siehe "Event-Schicht" unten) — für Aktionen wie Login/Logout/Session-Kill, die keine `AuditableEntity` verändern. `AuditEventHandler` (`Engine/Handlers/`) persistiert das Event als `AuditLogEntry` mit `Category = BehavioralEvent`. Aktuelle Dispatch-Stellen: `AuthController.Login` (`"Login"`/`"LoginFailed"`), `AuthController.Logout` (`"Logout"`), `AdminSessionsController.Revoke`/`RevokeAll` (`"SessionRevoked"`/`"AllSessionsRevoked"`). **Neue Aktionen folgen demselben Muster:** eine Zeile `await _dispatcher.DispatchAsync(new AuditEvent(actorUserId, actorUsername, ipAddress, "MeineAktion", details))` an der auslösenden Stelle im Api-Layer. Bewusst **kein** Event bei `AuthController.Refresh` (zu häufig/geräuschig für eine sliding Session, kein eigenständiges "wer hat was getan"-Faktum).
|
||||
|
||||
Aktor-Informationen (`Username`/`RoleName`/`IpAddress`) kommen über `ICurrentUserService` (erweitert um diese drei Properties, Implementierung `CurrentUserService` liest sie aus JWT-Claims bzw. `HttpContext.Connection.RemoteIpAddress`).
|
||||
|
||||
**Einsicht:** `GET /api/audit-log` (`AuditLogController`, Query-Filter `entityType`/`entityId`/`actorUserId`/`fromUtc`/`toUtc` + Pagination), gegated über `[RequirePermission(ModuleType.AuditLog, PermissionAction.View)]`. Per Default nur die Rolle `Geschäftsführung` (`DbSeeder.SeedBaseRolesAsync` iteriert für sie generisch alle `ModuleType`-Werte, siehe dort) — alle anderen Basis-Rollen sehen das Audit-Log nicht.
|
||||
|
||||
## 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).
|
||||
@@ -85,8 +100,14 @@ Das Attribut (`src/OmsorgCore.Api/Security/RequirePermissionAttribute.cs`) prüf
|
||||
|
||||
**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`.
|
||||
|
||||
**Passwort-Mindestlänge:** zentral über `IPasswordPolicy`/`PasswordPolicy` (`src/OmsorgCore.Infrastructure/Security/PasswordPolicy.cs`), gebunden an `PasswordPolicy:MinLength` in `appsettings.json` (Default 8, überschreibbar per `PasswordPolicy__MinLength`, siehe `CONFIGURATION.md`). Wird von `UserService` (Account-Anlage im Direct-Modus, Admin-Reset, `ChangeOwnPasswordAsync`) und `PasswordResetService.ResetPasswordAsync` injiziert geprüft — **nicht** mehr in den Controllern dupliziert. Bei Verstoß liefern die jeweiligen `*Result`-Typen einen `PasswordTooShort`-Wert, den `UsersController`/`AuthController` auf `400 BadRequest` mit der aktuell konfigurierten Zahl im Fehlertext abbilden. `GET /api/auth/password-policy` (kein `[Authorize]`) liefert `{ minLength }` für Clients, die denselben Wert für Hinweistexte/Vorab-Validierung brauchen (`omsorgapp`: `AuthContext.jsx`; `omsorgWeb/mitarbeiter-app`: `omsorgcore_password_policy()` in `lib/omsorgCoreClient.php`).
|
||||
|
||||
**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.
|
||||
|
||||
**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).
|
||||
|
||||
**Standard-Admin-Seed:** `DbSeeder.SeedDefaultAdminAsync` (`src/OmsorgCore.Infrastructure/Persistence/DbSeeder.cs`) legt beim Start **nur im Development-Modus** (`Program.cs`, `app.Environment.IsDevelopment()`) einen Benutzer `admin`/`abersicher` mit einer neuen Rolle `Administrator` (alle `ModuleType`×`PermissionAction`-Kombinationen als `RolePermission`) an — idempotent, läuft nur wenn noch **kein** `User` existiert. DB-Fehler dabei (z. B. keine Verbindung — Migrationen sind zu diesem Zeitpunkt bereits automatisch angewendet, siehe "Datenbank" unten) sind nicht fatal, werden nur geloggt (`try/catch` um den Seed-Aufruf). Bewusst **nicht** in Produktion aktiv, um kein bekanntes Standard-Passwort auszuliefern — für einen echten Produktivbetrieb muss ein richtiger User-Anlage-Flow her.
|
||||
|
||||
## Session-Killswitch (SecurityStamp)
|
||||
@@ -102,6 +123,8 @@ Ein Access-Token ist als JWT zustandslos gültig bis zum Ablauf (60 Min) — ein
|
||||
|
||||
Exponiert über `AdminSessionsController` (`GET /api/admin/sessions`, `POST /api/admin/sessions/{id}/revoke`, `POST /api/admin/sessions/revoke-all`), geschützt über `[RequirePermission(ModuleType.UserManagement, ...)]` — per Default nur die `Administrator`-Rolle aus dem `DbSeeder`. Im Frontend: `omsorgapp/src/modules/debug/DebugSessionsPage.jsx`, im Sidebar-Menü nur sichtbar, wenn `hasPermission("UserManagement", "View")` (siehe `GET /api/auth/me` unten) — dieselbe Rechteprüfung wie serverseitig, kein reiner Rollennamen-Vergleich mehr im Client.
|
||||
|
||||
`Revoke`/`RevokeAll` dispatchen jeweils zusätzlich ein `AuditEvent` (`"SessionRevoked"`/`"AllSessionsRevoked"`, siehe "Audit-Log" oben) — ein Session-Kill hinterlässt damit nachvollziehbar, welcher Aktor ihn wann ausgelöst hat.
|
||||
|
||||
**Kosten:** ein zusätzlicher DB-Read pro authentifiziertem Request (`GetByIdAsync`). Für die aktuelle Nutzerzahl vernachlässigbar — bei relevantem Traffic-Wachstum wäre ein Cache (z. B. In-Memory mit kurzer TTL) der nächste Schritt, aber kein Caching ohne echten Bedarf, um die Sofortigkeit des Killswitches nicht zu unterlaufen.
|
||||
|
||||
## Passwort-Reset / E-Mail-Versand
|
||||
@@ -121,7 +144,7 @@ Vollständig implementiert: `PasswordResetCode`-Entity + `PasswordResetService`
|
||||
- Connection-String-Key: `ConnectionStrings:OmsorgCore` (Format `Host=...;Port=5432;Database=omsorg_core;Username=...;Password=...`).
|
||||
- `dotnet-ef` ist als lokales Tool eingerichtet (`.config/dotnet-tools.json`) — vor erster Nutzung `dotnet tool restore`. Wird nur noch zum **Erzeugen** neuer Migrationen gebraucht (`dotnet ef migrations add ...`), nicht mehr zum Anwenden.
|
||||
- **`Program.cs` ruft bei jedem Start `db.Database.MigrateAsync()` auf, in jeder Umgebung** (nicht nur Development) — ausstehende Migrationen werden automatisch angewendet, bevor der Server Requests annimmt. Ein manuelles `dotnet ef database update` ist dadurch nur noch zum gezielten Vorab-Prüfen/Debuggen einer Migration nötig, nicht mehr für den normalen Start/Deploy. Schlägt die Migration fehl, crasht der Start bewusst fatal (fail-fast) statt mit einem veralteten Schema weiterzulaufen.
|
||||
- Migrationen `InitialCreate`, `AddRefreshTokens`, `AddUserSecurityStamp`, `AddEmployeeContactFieldConstraints` und `AddAuditableSoftDelete` existieren (`src/OmsorgCore.Infrastructure/Persistence/Migrations/`) und wurden erfolgreich gegen eine echte PostgreSQL-Instanz angewendet.
|
||||
- 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).
|
||||
|
||||
## Build- und Run-Befehle
|
||||
|
||||
@@ -151,14 +174,55 @@ ASPNETCORE_ENVIRONMENT=Development dotnet run --project src/OmsorgCore.Api
|
||||
- `omsorgapp`s `authClient.cjs` erfolgreich gegen den laufenden Server getestet (Login/Refresh/Logout-Fehlerfälle).
|
||||
- **Kompletter Login-Flow end-to-end mit echtem Postgres verifiziert:** Login mit `admin`/`abersicher` (Seed) → gültiges Token-Paar; `refresh` rotiert korrekt (neues Paar, alter Refresh-Token danach 401 bei Wiederverwendung); `GET /api/employees` mit frischem Access-Token → 200 (Administrator-Rolle hat volle Rechte über den Seed).
|
||||
- **Session-Killswitch end-to-end verifiziert:** `GET /api/admin/sessions` liefert aktive Sessions; `POST /api/admin/sessions/revoke-all` → 204, danach liefert **derselbe, zuvor gültige Access-Token sofort 401** (nicht erst nach Ablauf) und der zugehörige Refresh-Token liefert bei `POST /api/auth/refresh` ebenfalls 401. Erneuter Login mit `admin`/`abersicher` funktioniert danach wieder normal.
|
||||
- `DbSeeder.SeedBaseRolesAsync` gegen echte PostgreSQL-Instanz verifiziert: legt `Geschäftsführung`/`Disposition/Buchhaltung`/`Recruiting`/`Außendienst` mit der erwarteten Rechteanzahl an (54/33/10/0 Permissions), zweiter Lauf verändert nichts (idempotent pro Rollenname).
|
||||
|
||||
## Konfigurierbare Auswahllisten
|
||||
|
||||
Dropdown-Werte, die früher als hartcodierte Arrays im `omsorgapp`-Frontend lebten (Mitarbeiterstatus, Beschäftigungsart, CRM-Status, Einrichtungstyp) plus die entsprechenden, bisher nur als freier String modellierten Felder auf `Contract` (Vertragstyp/-status) und der Auftragsstatus (FR-EM-2) sind jetzt eine gemeinsame, admin-editierbare Stammdaten-Struktur statt Enum/hartcodiertes Array — Ziel: Löschen/Umbenennen/Hinzufügen ohne Code-Deploy, über die "Status-Verwaltung" unter "Einstellungen" in `omsorgapp`.
|
||||
|
||||
**Datenmodell** (`src/OmsorgCore.Domain/Entities/`): `ValueList` (Stammdaten einer Liste — `Key`, eindeutig, z. B. `"EmployeeStatus"`, `"EmploymentType"`, `"CrmStatus"`, `"FacilityType"`, `"ContractType"`, `"ContractStatus"`, `"OrderStatus"`; `DisplayName` für die Admin-UI) + `ValueListItem` (`Value`, `SortOrder`, `IsDefault`, `IsInitial`/`IsTerminal` — die letzten beiden nur für `"OrderStatus"` relevant) + `ValueListItemTransition` (erlaubte Übergänge zwischen zwei Items derselben Liste, wird nur für `"OrderStatus"` befüllt). Ersetzt das frühere `OrderStatusDefinition`/`OrderStatusTransition`-Sondermodell — Migration `ReplaceOrderStatusWithValueLists` übernimmt bestehende Auftragsstatus-Zeilen 1:1 mit identischen Ids in die neuen Tabellen, damit `Order.StatusId` unverändert gültig bleibt.
|
||||
|
||||
**Wo welches Feld referenziert wird:**
|
||||
- `Order.StatusId` (FK, echte Fremdschlüsselbeziehung auf `ValueListItem.Id`) — einzige Liste mit Übergangsregeln. `OrderService.CreateAsync`/`UpdateAsync` nutzen `IValueListRepository.GetInitialItemAsync("OrderStatus", ...)`/`CanTransitionAsync(...)` genau wie zuvor `IOrderStatusRepository`.
|
||||
- `Employee.Status`/`EmploymentType`, `Facility.CrmStatus`/`FacilityType`, `Contract.ContractType`/`Status` bleiben bewusst einfache `string`-Spalten (kein FK, keine Schema-Migration auf diesen Tabellen nötig) — stattdessen prüfen `EmployeesController`/`FacilitiesController`/`ContractsController` beim Schreiben serverseitig über `IValueListRepository.GetActiveValuesAsync(key, ...)`, dass der übergebene Wert unter den aktuell konfigurierten Werten der zugehörigen Liste ist (`400` sonst) — analog zur bereits bestehenden Passwort-Policy-Validierung.
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
**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", ...)`.
|
||||
|
||||
## Offene Punkte
|
||||
|
||||
- Facility/Contract/Order/TimeEntry/Invoice haben noch keine Controller/Repositories/Services — nur Domain-Entitäten + DB-Konfiguration. Nächste Schritte folgen demselben Muster wie `Employee` (Repository-Interface in Application, Implementierung in Infrastructure, Service in Application, Controller in Api).
|
||||
- `Facility` hat jetzt volles Repository/Service/Controller (`FacilitiesController`, `GET/POST/PUT /api/facilities`) nach dem Employee-Muster, inkl. `FacilityCreatedEvent`. Zusätzlich `FacilityContact` (FR-EIN-2, Ansprechpartner) als 1:n-Unterressource unter `GET/POST /api/facilities/{facilityId}/contacts`, `PUT .../contacts/{id}` (`FacilityContactsController`) — bewusst kein eigener `ModuleType`, sondern über dieselben `Facilities`-Rechte gegated, da Ansprechpartner kein eigenständiges Core-Objekt sind. Kein Delete-Endpoint für Ansprechpartner (konsistent mit dem noch fehlenden Soft-Delete für die übrigen Core-Objekte).
|
||||
- `Contract` hat jetzt ebenfalls volles Repository/Service/Controller (`ContractsController`, `GET/POST/PUT /api/contracts`, gegated über `[RequirePermission(ModuleType.Contracts, ...)]`) nach demselben Facility-Muster, inkl. `ContractCreatedEvent`. Deckt FR-MA-2 auf Backend-Seite ab: `WeeklyHours` (Arbeitszeit), `HourlyWage` (Stundenlohn), `AllowancesDescription` (Zuschläge, Freitext), `OvertimeRules` (Überstundenregelung, Freitext), `VacationDaysPerYear` (Urlaubsanspruch), `ProbationPeriodMonths` (Probezeit) — alle nullable, da ein Vertrag entweder einem Mitarbeiter oder einer Einrichtung zugeordnet ist (`EmployeeId`/`FacilityId`, mindestens eins muss gesetzt sein, per Controller-Validierung erzwungen) und nicht jeder Vertragstyp alle Felder braucht. `ContractConfiguration` hat jetzt (wie `Facility`) einen `HasQueryFilter(!IsDeleted)`. Kein `omsorgapp`-UI-Modul dafür in diesem Schritt — nur das Backend-CRUD.
|
||||
- `Order` hat jetzt ebenfalls volles Repository/Service/Controller (`OrdersController`, `GET/POST/PUT /api/orders`, gegated über `[RequirePermission(ModuleType.Orders, ...)]`) nach demselben Facility/Contract-Muster, inkl. `OrderCreatedEvent`. Deckt FR-EM-1 auf Backend-Seite ab: `FacilityContactId` (optionaler Ansprechpartner, gegen `FacilityId` cross-validiert — der Kontakt muss zur angegebenen Einrichtung gehören, sonst `400`), `ShiftType` (Schichtart, Freitext), `RequiredHeadcount` (Anzahl Mitarbeiter, mindestens 1), `Conditions` (Konditionen, Freitext), `Priority` (Priorität, Freitext). Der Auftragsstatus (FR-EM-2, `Order.StatusId`) ist Teil der generischen Auswahllisten — siehe "Konfigurierbare Auswahllisten" unten. `OrderConfiguration` hat jetzt (wie `Facility`/`Contract`) einen `HasQueryFilter(!IsDeleted)`. Kein `omsorgapp`-UI-Modul für Aufträge selbst in diesem Schritt (nur die Statuspflege über "Status-Verwaltung"). `TimeEntry`/`Invoice` haben weiterhin nur Domain-Entitäten + DB-Konfiguration — nächste Schritte folgen demselben Muster (Repository-Interface in Application, Implementierung in Infrastructure, Service in Application, Controller in Api).
|
||||
- Keine E-Mail-Verifizierung bei User-Anlage (Passwort-Reset per E-Mail ist fertig, siehe "Passwort-Reset / E-Mail-Versand" oben).
|
||||
- `omsorgapp` spricht seit Kurzem gegen dieses Backend (Login-Screen + Refresh-Token-Session, siehe `omsorgapp/CLAUDE.md`) — `omsorgWeb` ist noch nicht angebunden.
|
||||
- Kein Docker-/CI-Setup.
|
||||
|
||||
## Generierte API-Clients
|
||||
|
||||
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.
|
||||
|
||||
**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).
|
||||
|
||||
```bash
|
||||
# omsorgCore muss dafür lokal im Development-Modus laufen (Swagger nur dort aktiv):
|
||||
cd omsorgCore && ASPNETCORE_ENVIRONMENT=Development dotnet run --project src/OmsorgCore.Api
|
||||
|
||||
# in einem zweiten Terminal:
|
||||
cd omsorgapp/api-client-ts
|
||||
npm run generate # entspricht ./generate.sh — überschreibt src/, README.md, package.json, tsconfig*.json
|
||||
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.
|
||||
|
||||
## Die sechs Core-Objekte (Domain-Entitäten)
|
||||
|
||||
Aus `REQUIREMENTS.md` Abschnitt 6 / Blueprint Kap. 19: **Mitarbeiter, Einrichtung, Vertrag, Auftrag, Zeiterfassung, Rechnung** — als `Employee`, `Facility`, `Contract`, `Order`, `TimeEntry`, `Invoice` in `src/OmsorgCore.Domain/Entities/` angelegt, mit Kernfeldern (nicht vollständig ausmodelliert).
|
||||
@@ -167,13 +231,13 @@ Verbindliche Regeln für das Datenmodell:
|
||||
1. Jede Entität hat eine eindeutige `Guid Id` (siehe `Entity`-Basisklasse).
|
||||
2. Beziehungen ausschließlich über Foreign Keys/IDs, keine redundante Texteingabe verwandter Daten.
|
||||
3. Stammdaten nur an einer Stelle — kein Feld, das auch in `omsorgWeb` oder `omsorgapp` unabhängig gepflegt wird, sobald die Migration dorthin begonnen hat.
|
||||
4. Änderungen an geschäftsrelevanten Daten müssen nachvollziehbar sein — `AuditableEntity` liefert `CreatedAt`/`UpdatedAt`; ein vollständiger Audit-Trail (wer hat was geändert) ist noch nicht gebaut.
|
||||
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).
|
||||
6. Berechtigungsprüfung auf Daten- und Funktionsebene (siehe Rechtesystem oben und Rechtematrix in `REQUIREMENTS.md` Abschnitt 7).
|
||||
7. Rechnungen entstehen ausschließlich aus freigegebener Zeiterfassung (FR-ZE-3/FR-RE-1) — muss bei Ausbau von `TimeEntry`/`Invoice` auf Domain-/Application-Ebene erzwungen werden, nicht nur als UI-Regel im Client.
|
||||
|
||||
## Event-Schicht (Engine) — Funktionsweise
|
||||
|
||||
`IDomainEventDispatcher` (Singleton, In-Process) löst über den DI-Container alle registrierten `IDomainEventHandler<TEvent>` für ein Event auf und ruft sie auf. Beispiel: `EmployeesController.Create` ruft nach dem Speichern `_dispatcher.DispatchAsync(new EmployeeCreatedEvent(created.Id))` auf, `EmployeeCreatedHandler` reagiert darauf (aktuell nur Logging). Neue Trigger aus `REQUIREMENTS.md` Abschnitt 4.10 (Krankmeldung, fehlender Tätigkeitsnachweis, Vertragsende, ...) folgen demselben Muster: Event-Klasse in `Engine/Events/`, Handler in `Engine/Handlers/`, Registrierung in `Engine/DependencyInjection.cs`, Dispatch-Aufruf an der Stelle im Api-Layer, wo das auslösende Ereignis passiert.
|
||||
`IDomainEventDispatcher` (Singleton, In-Process) löst über den DI-Container alle registrierten `IDomainEventHandler<TEvent>` für ein Event auf und ruft sie auf. Beispiel: `EmployeesController.Create` ruft nach dem Speichern `_dispatcher.DispatchAsync(new EmployeeCreatedEvent(created.Id))` auf, `EmployeeCreatedHandler` reagiert darauf (aktuell nur Logging). Zweites Beispiel, generisch statt fachspezifisch: `AuditEvent` (siehe "Audit-Log" oben) wird von mehreren Stellen im Api-Layer für beliebige Verhaltens-Ereignisse gefeuert, `AuditEventHandler` persistiert sie einheitlich. Neue Trigger aus `REQUIREMENTS.md` Abschnitt 4.10 (Krankmeldung, fehlender Tätigkeitsnachweis, Vertragsende, ...) folgen demselben Muster: Event-Klasse in `Engine/Events/`, Handler in `Engine/Handlers/`, Registrierung in `Engine/DependencyInjection.cs`, Dispatch-Aufruf an der Stelle im Api-Layer, wo das auslösende Ereignis passiert.
|
||||
|
||||
**Wichtig:** `Application`-Services dispatchen bewusst *nicht* selbst (sie kennen `Engine` nicht, das würde die Abhängigkeitsrichtung verletzen) — das Dispatchen passiert im Api-Layer, der als einziger alle Schichten kennt.
|
||||
|
||||
@@ -24,6 +24,7 @@ ASP.NET Core liest Konfiguration in dieser Reihenfolge (später gewinnt):
|
||||
| `RefreshToken:ExpiryDays` | `60` | `RefreshToken__ExpiryDays` | Gültigkeitsdauer eines Refresh-Tokens (sliding — jede Nutzung/Rotation verlängert effektiv die Session). |
|
||||
| `Auth:MaxLoginFailures` | `5` | `Auth__MaxLoginFailures` | Fehlversuche pro IP innerhalb von `Auth:LoginLockoutMinutes`, ab denen `POST /api/auth/login` mit `429` sperrt (zentral für alle Clients, siehe `AuthService.LoginAsync`/`LoginAttempt`). |
|
||||
| `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. |
|
||||
|
||||
## Passwort-Reset / E-Mail
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record AddUserPermissionOverrideRequest(ModuleType Module, PermissionAction Action, PermissionEffect Effect);
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record AuditLogEntryResponse(
|
||||
Guid Id,
|
||||
DateTime OccurredAtUtc,
|
||||
Guid? ActorUserId,
|
||||
string? ActorUsername,
|
||||
string? IpAddress,
|
||||
string Category,
|
||||
string Action,
|
||||
string? EntityType,
|
||||
Guid? EntityId,
|
||||
string? Details);
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record ContractResponse(
|
||||
Guid Id,
|
||||
string ContractType,
|
||||
Guid? EmployeeId,
|
||||
Guid? FacilityId,
|
||||
DateOnly StartDate,
|
||||
DateOnly? EndDate,
|
||||
string Status,
|
||||
decimal? WeeklyHours,
|
||||
decimal? HourlyWage,
|
||||
string? AllowancesDescription,
|
||||
string? OvertimeRules,
|
||||
int? VacationDaysPerYear,
|
||||
int? ProbationPeriodMonths);
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record CreateContractRequest(
|
||||
string ContractType,
|
||||
Guid? EmployeeId,
|
||||
Guid? FacilityId,
|
||||
DateOnly StartDate,
|
||||
DateOnly? EndDate,
|
||||
decimal? WeeklyHours,
|
||||
decimal? HourlyWage,
|
||||
string? AllowancesDescription,
|
||||
string? OvertimeRules,
|
||||
int? VacationDaysPerYear,
|
||||
int? ProbationPeriodMonths);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record CreateFacilityContactRequest(
|
||||
string Name,
|
||||
string? Role,
|
||||
string? Department,
|
||||
string? PhoneNumber,
|
||||
string? Email,
|
||||
string? Notes);
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record CreateFacilityRequest(
|
||||
string Name,
|
||||
string? FacilityType,
|
||||
string? Website,
|
||||
string? Street,
|
||||
string? PostalCode,
|
||||
string? City,
|
||||
string? Country,
|
||||
string? BillingStreet,
|
||||
string? BillingPostalCode,
|
||||
string? BillingCity,
|
||||
string? BillingCountry);
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record CreateOrderRequest(
|
||||
Guid FacilityId,
|
||||
Guid? FacilityContactId,
|
||||
DateOnly StartDate,
|
||||
DateOnly? EndDate,
|
||||
string? RequiredQualification,
|
||||
string? ShiftType,
|
||||
int RequiredHeadcount,
|
||||
string? Conditions,
|
||||
string Priority);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record CreateValueListItemRequest(
|
||||
string Value,
|
||||
int SortOrder,
|
||||
bool IsDefault = false,
|
||||
bool IsInitial = false,
|
||||
bool IsTerminal = false);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record FacilityContactResponse(
|
||||
Guid Id,
|
||||
Guid FacilityId,
|
||||
string Name,
|
||||
string? Role,
|
||||
string? Department,
|
||||
string? PhoneNumber,
|
||||
string? Email,
|
||||
string? Notes);
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record FacilityResponse(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string CrmStatus,
|
||||
string? FacilityType,
|
||||
string? Website,
|
||||
string? Street,
|
||||
string? PostalCode,
|
||||
string? City,
|
||||
string? Country,
|
||||
string? BillingStreet,
|
||||
string? BillingPostalCode,
|
||||
string? BillingCity,
|
||||
string? BillingCountry);
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record OrderResponse(
|
||||
Guid Id,
|
||||
Guid FacilityId,
|
||||
Guid? FacilityContactId,
|
||||
DateOnly StartDate,
|
||||
DateOnly? EndDate,
|
||||
string? RequiredQualification,
|
||||
string? ShiftType,
|
||||
int RequiredHeadcount,
|
||||
string? Conditions,
|
||||
string Priority,
|
||||
Guid StatusId,
|
||||
string StatusName);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record PasswordPolicyResponse(int MinLength);
|
||||
@@ -1,3 +1,5 @@
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record PermissionDto(string Module, string Action);
|
||||
public record PermissionDto(ModuleType Module, PermissionAction Action);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record RolePermissionsResponse(Guid Id, string Name, IReadOnlyList<PermissionDto> Permissions);
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record UpdateContractRequest(
|
||||
string ContractType,
|
||||
Guid? EmployeeId,
|
||||
Guid? FacilityId,
|
||||
DateOnly StartDate,
|
||||
DateOnly? EndDate,
|
||||
string Status,
|
||||
decimal? WeeklyHours,
|
||||
decimal? HourlyWage,
|
||||
string? AllowancesDescription,
|
||||
string? OvertimeRules,
|
||||
int? VacationDaysPerYear,
|
||||
int? ProbationPeriodMonths);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record UpdateFacilityContactRequest(
|
||||
string Name,
|
||||
string? Role,
|
||||
string? Department,
|
||||
string? PhoneNumber,
|
||||
string? Email,
|
||||
string? Notes);
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record UpdateFacilityRequest(
|
||||
string Name,
|
||||
string CrmStatus,
|
||||
string? FacilityType,
|
||||
string? Website,
|
||||
string? Street,
|
||||
string? PostalCode,
|
||||
string? City,
|
||||
string? Country,
|
||||
string? BillingStreet,
|
||||
string? BillingPostalCode,
|
||||
string? BillingCity,
|
||||
string? BillingCountry);
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record UpdateOrderRequest(
|
||||
Guid FacilityId,
|
||||
Guid? FacilityContactId,
|
||||
DateOnly StartDate,
|
||||
DateOnly? EndDate,
|
||||
string? RequiredQualification,
|
||||
string? ShiftType,
|
||||
int RequiredHeadcount,
|
||||
string? Conditions,
|
||||
string Priority,
|
||||
Guid StatusId);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record UpdateRolePermissionsRequest(IReadOnlyList<PermissionDto> Permissions);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record UpdateValueListItemRequest(
|
||||
string Value,
|
||||
int SortOrder,
|
||||
bool IsDefault = false,
|
||||
bool IsInitial = false,
|
||||
bool IsTerminal = false);
|
||||
@@ -0,0 +1,5 @@
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record UserPermissionOverrideResponse(Guid Id, ModuleType Module, PermissionAction Action, PermissionEffect Effect);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record ValueListItemResponse(
|
||||
Guid Id,
|
||||
string Value,
|
||||
int SortOrder,
|
||||
bool IsDefault,
|
||||
bool IsInitial,
|
||||
bool IsTerminal);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record ValueListResponse(Guid Id, string Key, string DisplayName);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record ValueListTransitionRequest(Guid FromItemId, Guid ToItemId);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record ValueListTransitionResponse(Guid Id, Guid FromItemId, Guid ToItemId);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record ValueListUsageResponse(string EntityType, Guid EntityId, string DisplayLabel);
|
||||
@@ -1,9 +1,12 @@
|
||||
using System.Text.Json;
|
||||
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.Enums;
|
||||
using OmsorgCore.Engine.Events;
|
||||
|
||||
namespace OmsorgCore.Api.Controllers;
|
||||
|
||||
@@ -17,10 +20,17 @@ namespace OmsorgCore.Api.Controllers;
|
||||
public class AdminSessionsController : ControllerBase
|
||||
{
|
||||
private readonly ISessionAdminService _sessionAdminService;
|
||||
private readonly ICurrentUserService _currentUserService;
|
||||
private readonly IDomainEventDispatcher _dispatcher;
|
||||
|
||||
public AdminSessionsController(ISessionAdminService sessionAdminService)
|
||||
public AdminSessionsController(
|
||||
ISessionAdminService sessionAdminService,
|
||||
ICurrentUserService currentUserService,
|
||||
IDomainEventDispatcher dispatcher)
|
||||
{
|
||||
_sessionAdminService = sessionAdminService;
|
||||
_currentUserService = currentUserService;
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -36,6 +46,14 @@ public class AdminSessionsController : ControllerBase
|
||||
public async Task<IActionResult> Revoke(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
await _sessionAdminService.RevokeSessionAsync(id, cancellationToken);
|
||||
await _dispatcher.DispatchAsync(
|
||||
new AuditEvent(
|
||||
_currentUserService.UserId,
|
||||
_currentUserService.Username,
|
||||
_currentUserService.IpAddress,
|
||||
"SessionRevoked",
|
||||
JsonSerializer.Serialize(new { sessionId = id })),
|
||||
cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -44,6 +62,13 @@ public class AdminSessionsController : ControllerBase
|
||||
public async Task<IActionResult> RevokeAll(CancellationToken cancellationToken)
|
||||
{
|
||||
await _sessionAdminService.RevokeAllSessionsAsync(cancellationToken);
|
||||
await _dispatcher.DispatchAsync(
|
||||
new AuditEvent(
|
||||
_currentUserService.UserId,
|
||||
_currentUserService.Username,
|
||||
_currentUserService.IpAddress,
|
||||
"AllSessionsRevoked"),
|
||||
cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OmsorgCore.Api.Contracts;
|
||||
using OmsorgCore.Api.Security;
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Nachvollziehbarkeit "wer hat wann was am System verändert" - kombiniert automatisch erfasste
|
||||
/// Entity-Änderungen (AuditSaveChangesInterceptor) und explizit gemeldete Verhaltens-Ereignisse
|
||||
/// (AuditEvent, z. B. Login/Logout/Session-Kill). Per Default nur für die Rolle Geschäftsführung
|
||||
/// sichtbar (siehe DbSeeder.SeedBaseRolesAsync).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/audit-log")]
|
||||
public class AuditLogController : ControllerBase
|
||||
{
|
||||
private readonly IAuditLogService _auditLogService;
|
||||
|
||||
public AuditLogController(IAuditLogService auditLogService)
|
||||
{
|
||||
_auditLogService = auditLogService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[RequirePermission(ModuleType.AuditLog, PermissionAction.View)]
|
||||
public async Task<ActionResult<PagedResponse<AuditLogEntryResponse>>> GetAll(
|
||||
[FromQuery] string? entityType,
|
||||
[FromQuery] Guid? entityId,
|
||||
[FromQuery] Guid? actorUserId,
|
||||
[FromQuery] AuditEventCategory? category,
|
||||
[FromQuery] DateTime? fromUtc,
|
||||
[FromQuery] DateTime? toUtc,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 50,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
page = Math.Max(page, 1);
|
||||
pageSize = Math.Clamp(pageSize, 1, 200);
|
||||
|
||||
var (items, totalCount) = await _auditLogService.GetPagedAsync(
|
||||
entityType, entityId, actorUserId, category, fromUtc, toUtc, page, pageSize, cancellationToken);
|
||||
|
||||
return Ok(new PagedResponse<AuditLogEntryResponse>(items.Select(ToResponse).ToList(), totalCount, page, pageSize));
|
||||
}
|
||||
|
||||
private static AuditLogEntryResponse ToResponse(AuditLogEntry entry) => new(
|
||||
entry.Id,
|
||||
entry.OccurredAtUtc,
|
||||
entry.ActorUserId,
|
||||
entry.ActorUsername,
|
||||
entry.IpAddress,
|
||||
entry.Category.ToString(),
|
||||
entry.Action,
|
||||
entry.EntityType,
|
||||
entry.EntityId,
|
||||
entry.Details);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OmsorgCore.Api.Contracts;
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Application.Services;
|
||||
using OmsorgCore.Engine.Events;
|
||||
using OmsorgCore.Infrastructure.Security;
|
||||
|
||||
namespace OmsorgCore.Api.Controllers;
|
||||
|
||||
@@ -16,19 +18,22 @@ public class AuthController : ControllerBase
|
||||
private readonly IPasswordResetService _passwordResetService;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IDomainEventDispatcher _dispatcher;
|
||||
private readonly PasswordPolicyOptions _passwordPolicyOptions;
|
||||
|
||||
public AuthController(
|
||||
IAuthService authService,
|
||||
ICurrentUserService currentUserService,
|
||||
IPasswordResetService passwordResetService,
|
||||
IUserService userService,
|
||||
IDomainEventDispatcher dispatcher)
|
||||
IDomainEventDispatcher dispatcher,
|
||||
IOptions<PasswordPolicyOptions> passwordPolicyOptions)
|
||||
{
|
||||
_authService = authService;
|
||||
_currentUserService = currentUserService;
|
||||
_passwordResetService = passwordResetService;
|
||||
_userService = userService;
|
||||
_dispatcher = dispatcher;
|
||||
_passwordPolicyOptions = passwordPolicyOptions.Value;
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
@@ -44,9 +49,11 @@ public class AuthController : ControllerBase
|
||||
|
||||
if (!result.Success || result.Token is null || result.RefreshToken is null || result.ExpiresAt is null)
|
||||
{
|
||||
await _dispatcher.DispatchAsync(new AuditEvent(null, request.Username, ipAddress, "LoginFailed"), cancellationToken);
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -66,6 +73,9 @@ public class AuthController : ControllerBase
|
||||
public async Task<IActionResult> Logout(LogoutRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
await _authService.RevokeAsync(request.RefreshToken, cancellationToken);
|
||||
await _dispatcher.DispatchAsync(
|
||||
new AuditEvent(_currentUserService.UserId, _currentUserService.Username, _currentUserService.IpAddress, "Logout"),
|
||||
cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -85,7 +95,7 @@ public class AuthController : ControllerBase
|
||||
}
|
||||
|
||||
var permissions = profile.Permissions
|
||||
.Select(p => new PermissionDto(p.Module.ToString(), p.Action.ToString()))
|
||||
.Select(p => new PermissionDto(p.Module, p.Action))
|
||||
.ToList();
|
||||
|
||||
return Ok(new MeResponse(
|
||||
@@ -110,13 +120,19 @@ public class AuthController : ControllerBase
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.NewPassword) || request.NewPassword.Length < 8)
|
||||
var result = await _userService.ChangeOwnPasswordAsync(userId, request.CurrentPassword, request.NewPassword, cancellationToken);
|
||||
return result switch
|
||||
{
|
||||
return BadRequest("NewPassword muss mindestens 8 Zeichen lang sein.");
|
||||
}
|
||||
ChangeOwnPasswordResult.Success => NoContent(),
|
||||
ChangeOwnPasswordResult.PasswordTooShort => BadRequest($"NewPassword muss mindestens {_passwordPolicyOptions.MinLength} Zeichen lang sein."),
|
||||
_ => Unauthorized()
|
||||
};
|
||||
}
|
||||
|
||||
var success = await _userService.ChangeOwnPasswordAsync(userId, request.CurrentPassword, request.NewPassword, cancellationToken);
|
||||
return success ? NoContent() : Unauthorized();
|
||||
[HttpGet("password-policy")]
|
||||
public ActionResult<PasswordPolicyResponse> PasswordPolicy()
|
||||
{
|
||||
return Ok(new PasswordPolicyResponse(_passwordPolicyOptions.MinLength));
|
||||
}
|
||||
|
||||
[HttpPost("forgot-password/request")]
|
||||
@@ -156,8 +172,13 @@ public class AuthController : ControllerBase
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var success = await _passwordResetService.ResetPasswordAsync(
|
||||
var result = await _passwordResetService.ResetPasswordAsync(
|
||||
request.ResetToken, request.NewPassword, cancellationToken);
|
||||
return success ? NoContent() : Unauthorized();
|
||||
return result switch
|
||||
{
|
||||
ResetPasswordResult.Success => NoContent(),
|
||||
ResetPasswordResult.PasswordTooShort => BadRequest($"NewPassword muss mindestens {_passwordPolicyOptions.MinLength} Zeichen lang sein."),
|
||||
_ => Unauthorized()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
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/contracts")]
|
||||
public class ContractsController : ControllerBase
|
||||
{
|
||||
private const string ContractTypeListKey = "ContractType";
|
||||
private const string ContractStatusListKey = "ContractStatus";
|
||||
|
||||
private readonly IContractService _contractService;
|
||||
private readonly IValueListRepository _valueListRepository;
|
||||
private readonly IDomainEventDispatcher _dispatcher;
|
||||
|
||||
public ContractsController(IContractService contractService, IValueListRepository valueListRepository, IDomainEventDispatcher dispatcher)
|
||||
{
|
||||
_contractService = contractService;
|
||||
_valueListRepository = valueListRepository;
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[RequirePermission(ModuleType.Contracts, PermissionAction.View)]
|
||||
public async Task<ActionResult<PagedResponse<ContractResponse>>> GetAll(
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] Guid? employeeId,
|
||||
[FromQuery] Guid? facilityId,
|
||||
[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 _contractService.GetPagedAsync(search, status, employeeId, facilityId, page, pageSize, cancellationToken);
|
||||
return Ok(new PagedResponse<ContractResponse>(items.Select(ToResponse).ToList(), totalCount, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Contracts, PermissionAction.View)]
|
||||
public async Task<ActionResult<ContractResponse>> GetById(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var contract = await _contractService.GetByIdAsync(id, cancellationToken);
|
||||
return contract is null ? NotFound() : Ok(ToResponse(contract));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[RequirePermission(ModuleType.Contracts, PermissionAction.Create)]
|
||||
public async Task<ActionResult<ContractResponse>> Create(CreateContractRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var fieldError = await ValidateFieldsAsync(
|
||||
request.ContractType,
|
||||
request.EmployeeId,
|
||||
request.FacilityId,
|
||||
request.StartDate,
|
||||
request.EndDate,
|
||||
request.WeeklyHours,
|
||||
request.HourlyWage,
|
||||
request.AllowancesDescription,
|
||||
request.OvertimeRules,
|
||||
request.VacationDaysPerYear,
|
||||
request.ProbationPeriodMonths,
|
||||
cancellationToken);
|
||||
if (fieldError is not null)
|
||||
{
|
||||
return BadRequest(fieldError);
|
||||
}
|
||||
|
||||
var contract = new Contract
|
||||
{
|
||||
ContractType = request.ContractType,
|
||||
EmployeeId = request.EmployeeId,
|
||||
FacilityId = request.FacilityId,
|
||||
StartDate = request.StartDate,
|
||||
EndDate = request.EndDate,
|
||||
WeeklyHours = request.WeeklyHours,
|
||||
HourlyWage = request.HourlyWage,
|
||||
AllowancesDescription = request.AllowancesDescription,
|
||||
OvertimeRules = request.OvertimeRules,
|
||||
VacationDaysPerYear = request.VacationDaysPerYear,
|
||||
ProbationPeriodMonths = request.ProbationPeriodMonths
|
||||
};
|
||||
|
||||
var created = await _contractService.CreateAsync(contract, cancellationToken);
|
||||
await _dispatcher.DispatchAsync(new ContractCreatedEvent(created.Id), cancellationToken);
|
||||
|
||||
return CreatedAtAction(nameof(GetById), new { id = created.Id }, ToResponse(created));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Contracts, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<ContractResponse>> Update(Guid id, UpdateContractRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var fieldError = await ValidateFieldsAsync(
|
||||
request.ContractType,
|
||||
request.EmployeeId,
|
||||
request.FacilityId,
|
||||
request.StartDate,
|
||||
request.EndDate,
|
||||
request.WeeklyHours,
|
||||
request.HourlyWage,
|
||||
request.AllowancesDescription,
|
||||
request.OvertimeRules,
|
||||
request.VacationDaysPerYear,
|
||||
request.ProbationPeriodMonths,
|
||||
cancellationToken);
|
||||
if (fieldError is not null)
|
||||
{
|
||||
return BadRequest(fieldError);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Status) || request.Status.Length > 50)
|
||||
{
|
||||
return BadRequest("Status ist erforderlich und darf maximal 50 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
var allowedStatuses = await _valueListRepository.GetActiveValuesAsync(ContractStatusListKey, cancellationToken);
|
||||
if (!allowedStatuses.Contains(request.Status))
|
||||
{
|
||||
return BadRequest($"Status muss einer der folgenden Werte sein: {string.Join(", ", allowedStatuses)}.");
|
||||
}
|
||||
|
||||
var updates = new Contract
|
||||
{
|
||||
ContractType = request.ContractType,
|
||||
EmployeeId = request.EmployeeId,
|
||||
FacilityId = request.FacilityId,
|
||||
StartDate = request.StartDate,
|
||||
EndDate = request.EndDate,
|
||||
Status = request.Status,
|
||||
WeeklyHours = request.WeeklyHours,
|
||||
HourlyWage = request.HourlyWage,
|
||||
AllowancesDescription = request.AllowancesDescription,
|
||||
OvertimeRules = request.OvertimeRules,
|
||||
VacationDaysPerYear = request.VacationDaysPerYear,
|
||||
ProbationPeriodMonths = request.ProbationPeriodMonths
|
||||
};
|
||||
|
||||
var updated = await _contractService.UpdateAsync(id, updates, cancellationToken);
|
||||
return updated is null ? NotFound() : Ok(ToResponse(updated));
|
||||
}
|
||||
|
||||
private async Task<string?> ValidateFieldsAsync(
|
||||
string contractType,
|
||||
Guid? employeeId,
|
||||
Guid? facilityId,
|
||||
DateOnly startDate,
|
||||
DateOnly? endDate,
|
||||
decimal? weeklyHours,
|
||||
decimal? hourlyWage,
|
||||
string? allowancesDescription,
|
||||
string? overtimeRules,
|
||||
int? vacationDaysPerYear,
|
||||
int? probationPeriodMonths,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(contractType) || contractType.Length > 100)
|
||||
{
|
||||
return "ContractType ist erforderlich und darf maximal 100 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
var allowedContractTypes = await _valueListRepository.GetActiveValuesAsync(ContractTypeListKey, cancellationToken);
|
||||
if (!allowedContractTypes.Contains(contractType))
|
||||
{
|
||||
return $"ContractType muss einer der folgenden Werte sein: {string.Join(", ", allowedContractTypes)}.";
|
||||
}
|
||||
|
||||
if (employeeId is null && facilityId is null)
|
||||
{
|
||||
return "Ein Vertrag muss entweder einem Mitarbeiter oder einer Einrichtung zugeordnet sein.";
|
||||
}
|
||||
|
||||
if (startDate == default)
|
||||
{
|
||||
return "StartDate ist erforderlich.";
|
||||
}
|
||||
|
||||
if (endDate.HasValue && endDate.Value < startDate)
|
||||
{
|
||||
return "EndDate darf nicht vor StartDate liegen.";
|
||||
}
|
||||
|
||||
if (weeklyHours.HasValue && weeklyHours.Value < 0)
|
||||
{
|
||||
return "WeeklyHours darf nicht negativ sein.";
|
||||
}
|
||||
|
||||
if (hourlyWage.HasValue && hourlyWage.Value < 0)
|
||||
{
|
||||
return "HourlyWage darf nicht negativ sein.";
|
||||
}
|
||||
|
||||
if (vacationDaysPerYear.HasValue && vacationDaysPerYear.Value < 0)
|
||||
{
|
||||
return "VacationDaysPerYear darf nicht negativ sein.";
|
||||
}
|
||||
|
||||
if (probationPeriodMonths.HasValue && probationPeriodMonths.Value < 0)
|
||||
{
|
||||
return "ProbationPeriodMonths darf nicht negativ sein.";
|
||||
}
|
||||
|
||||
if (allowancesDescription is { Length: > 500 })
|
||||
{
|
||||
return "AllowancesDescription darf maximal 500 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (overtimeRules is { Length: > 500 })
|
||||
{
|
||||
return "OvertimeRules darf maximal 500 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ContractResponse ToResponse(Contract contract)
|
||||
=> new(
|
||||
contract.Id,
|
||||
contract.ContractType,
|
||||
contract.EmployeeId,
|
||||
contract.FacilityId,
|
||||
contract.StartDate,
|
||||
contract.EndDate,
|
||||
contract.Status,
|
||||
contract.WeeklyHours,
|
||||
contract.HourlyWage,
|
||||
contract.AllowancesDescription,
|
||||
contract.OvertimeRules,
|
||||
contract.VacationDaysPerYear,
|
||||
contract.ProbationPeriodMonths);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ 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;
|
||||
@@ -14,15 +15,17 @@ namespace OmsorgCore.Api.Controllers;
|
||||
[Route("api/employees")]
|
||||
public class EmployeesController : ControllerBase
|
||||
{
|
||||
private static readonly string[] AllowedEmploymentTypes =
|
||||
{ "Vollzeit", "Teilzeit", "Minijob", "Aushilfe", "Praktikant", "Freiberuflich" };
|
||||
private const string StatusListKey = "EmployeeStatus";
|
||||
private const string EmploymentTypeListKey = "EmploymentType";
|
||||
|
||||
private readonly IEmployeeService _employeeService;
|
||||
private readonly IValueListRepository _valueListRepository;
|
||||
private readonly IDomainEventDispatcher _dispatcher;
|
||||
|
||||
public EmployeesController(IEmployeeService employeeService, IDomainEventDispatcher dispatcher)
|
||||
public EmployeesController(IEmployeeService employeeService, IValueListRepository valueListRepository, IDomainEventDispatcher dispatcher)
|
||||
{
|
||||
_employeeService = employeeService;
|
||||
_valueListRepository = valueListRepository;
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
@@ -36,9 +39,13 @@ public class EmployeesController : ControllerBase
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (employmentType is not null && !AllowedEmploymentTypes.Contains(employmentType))
|
||||
if (employmentType is not null)
|
||||
{
|
||||
return BadRequest($"employmentType muss einer der folgenden Werte sein: {string.Join(", ", AllowedEmploymentTypes)}.");
|
||||
var allowedTypes = await _valueListRepository.GetActiveValuesAsync(EmploymentTypeListKey, cancellationToken);
|
||||
if (!allowedTypes.Contains(employmentType))
|
||||
{
|
||||
return BadRequest($"employmentType muss einer der folgenden Werte sein: {string.Join(", ", allowedTypes)}.");
|
||||
}
|
||||
}
|
||||
|
||||
page = Math.Max(page, 1);
|
||||
@@ -91,12 +98,13 @@ public class EmployeesController : ControllerBase
|
||||
return BadRequest(addressError);
|
||||
}
|
||||
|
||||
var stammdatenError = ValidateStammdatenFields(
|
||||
var stammdatenError = await ValidateStammdatenFieldsAsync(
|
||||
request.EmergencyContactName,
|
||||
request.EmergencyContactPhone,
|
||||
request.EmergencyContactRelation,
|
||||
request.EmploymentType,
|
||||
request.Qualification);
|
||||
request.Qualification,
|
||||
cancellationToken);
|
||||
if (stammdatenError is not null)
|
||||
{
|
||||
return BadRequest(stammdatenError);
|
||||
@@ -147,6 +155,12 @@ public class EmployeesController : ControllerBase
|
||||
return BadRequest("Status ist erforderlich und darf maximal 50 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
var allowedStatuses = await _valueListRepository.GetActiveValuesAsync(StatusListKey, cancellationToken);
|
||||
if (!allowedStatuses.Contains(request.Status))
|
||||
{
|
||||
return BadRequest($"Status muss einer der folgenden Werte sein: {string.Join(", ", allowedStatuses)}.");
|
||||
}
|
||||
|
||||
if (request.PhoneNumber is { Length: > 50 })
|
||||
{
|
||||
return BadRequest("PhoneNumber darf maximal 50 Zeichen lang sein.");
|
||||
@@ -168,12 +182,13 @@ public class EmployeesController : ControllerBase
|
||||
return BadRequest(addressError);
|
||||
}
|
||||
|
||||
var stammdatenError = ValidateStammdatenFields(
|
||||
var stammdatenError = await ValidateStammdatenFieldsAsync(
|
||||
request.EmergencyContactName,
|
||||
request.EmergencyContactPhone,
|
||||
request.EmergencyContactRelation,
|
||||
request.EmploymentType,
|
||||
request.Qualification);
|
||||
request.Qualification,
|
||||
cancellationToken);
|
||||
if (stammdatenError is not null)
|
||||
{
|
||||
return BadRequest(stammdatenError);
|
||||
@@ -230,12 +245,13 @@ public class EmployeesController : ControllerBase
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ValidateStammdatenFields(
|
||||
private async Task<string?> ValidateStammdatenFieldsAsync(
|
||||
string? emergencyContactName,
|
||||
string? emergencyContactPhone,
|
||||
string? emergencyContactRelation,
|
||||
string? employmentType,
|
||||
string? qualification)
|
||||
string? qualification,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (emergencyContactName is { Length: > 200 })
|
||||
{
|
||||
@@ -252,9 +268,13 @@ public class EmployeesController : ControllerBase
|
||||
return "EmergencyContactRelation darf maximal 100 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (employmentType is not null && !AllowedEmploymentTypes.Contains(employmentType))
|
||||
if (employmentType is not null)
|
||||
{
|
||||
return $"EmploymentType muss einer der folgenden Werte sein: {string.Join(", ", AllowedEmploymentTypes)}.";
|
||||
var allowedTypes = await _valueListRepository.GetActiveValuesAsync(EmploymentTypeListKey, cancellationToken);
|
||||
if (!allowedTypes.Contains(employmentType))
|
||||
{
|
||||
return $"EmploymentType muss einer der folgenden Werte sein: {string.Join(", ", allowedTypes)}.";
|
||||
}
|
||||
}
|
||||
|
||||
if (qualification is { Length: > 500 })
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
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/facilities")]
|
||||
public class FacilitiesController : ControllerBase
|
||||
{
|
||||
private const string CrmStatusListKey = "CrmStatus";
|
||||
private const string FacilityTypeListKey = "FacilityType";
|
||||
|
||||
private readonly IFacilityService _facilityService;
|
||||
private readonly IValueListRepository _valueListRepository;
|
||||
private readonly IDomainEventDispatcher _dispatcher;
|
||||
|
||||
public FacilitiesController(IFacilityService facilityService, IValueListRepository valueListRepository, IDomainEventDispatcher dispatcher)
|
||||
{
|
||||
_facilityService = facilityService;
|
||||
_valueListRepository = valueListRepository;
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.View)]
|
||||
public async Task<ActionResult<PagedResponse<FacilityResponse>>> GetAll(
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] string? crmStatus,
|
||||
[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 _facilityService.GetPagedAsync(search, crmStatus, page, pageSize, cancellationToken);
|
||||
return Ok(new PagedResponse<FacilityResponse>(items.Select(ToResponse).ToList(), totalCount, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.View)]
|
||||
public async Task<ActionResult<FacilityResponse>> GetById(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var facility = await _facilityService.GetByIdAsync(id, cancellationToken);
|
||||
return facility is null ? NotFound() : Ok(ToResponse(facility));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Create)]
|
||||
public async Task<ActionResult<FacilityResponse>> Create(CreateFacilityRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 300)
|
||||
{
|
||||
return BadRequest("Name ist erforderlich und darf maximal 300 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
if (request.FacilityType is { Length: > 100 })
|
||||
{
|
||||
return BadRequest("FacilityType darf maximal 100 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
if (request.FacilityType is not null)
|
||||
{
|
||||
var allowedTypes = await _valueListRepository.GetActiveValuesAsync(FacilityTypeListKey, cancellationToken);
|
||||
if (!allowedTypes.Contains(request.FacilityType))
|
||||
{
|
||||
return BadRequest($"FacilityType muss einer der folgenden Werte sein: {string.Join(", ", allowedTypes)}.");
|
||||
}
|
||||
}
|
||||
|
||||
if (request.Website is { Length: > 300 })
|
||||
{
|
||||
return BadRequest("Website darf maximal 300 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
var addressError = ValidateAddressFields(request.Street, request.PostalCode, request.City, request.Country, prefix: "");
|
||||
if (addressError is not null)
|
||||
{
|
||||
return BadRequest(addressError);
|
||||
}
|
||||
|
||||
var billingAddressError = ValidateAddressFields(request.BillingStreet, request.BillingPostalCode, request.BillingCity, request.BillingCountry, prefix: "Billing");
|
||||
if (billingAddressError is not null)
|
||||
{
|
||||
return BadRequest(billingAddressError);
|
||||
}
|
||||
|
||||
var facility = new Facility
|
||||
{
|
||||
Name = request.Name,
|
||||
FacilityType = request.FacilityType,
|
||||
Website = request.Website,
|
||||
Street = request.Street,
|
||||
PostalCode = request.PostalCode,
|
||||
City = request.City,
|
||||
Country = request.Country,
|
||||
BillingStreet = request.BillingStreet,
|
||||
BillingPostalCode = request.BillingPostalCode,
|
||||
BillingCity = request.BillingCity,
|
||||
BillingCountry = request.BillingCountry
|
||||
};
|
||||
|
||||
var created = await _facilityService.CreateAsync(facility, cancellationToken);
|
||||
await _dispatcher.DispatchAsync(new FacilityCreatedEvent(created.Id), cancellationToken);
|
||||
|
||||
return CreatedAtAction(nameof(GetById), new { id = created.Id }, ToResponse(created));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<FacilityResponse>> Update(Guid id, UpdateFacilityRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 300)
|
||||
{
|
||||
return BadRequest("Name ist erforderlich und darf maximal 300 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.CrmStatus) || request.CrmStatus.Length > 50)
|
||||
{
|
||||
return BadRequest("CrmStatus ist erforderlich und darf maximal 50 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
var allowedCrmStatuses = await _valueListRepository.GetActiveValuesAsync(CrmStatusListKey, cancellationToken);
|
||||
if (!allowedCrmStatuses.Contains(request.CrmStatus))
|
||||
{
|
||||
return BadRequest($"CrmStatus muss einer der folgenden Werte sein: {string.Join(", ", allowedCrmStatuses)}.");
|
||||
}
|
||||
|
||||
if (request.FacilityType is { Length: > 100 })
|
||||
{
|
||||
return BadRequest("FacilityType darf maximal 100 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
if (request.FacilityType is not null)
|
||||
{
|
||||
var allowedTypes = await _valueListRepository.GetActiveValuesAsync(FacilityTypeListKey, cancellationToken);
|
||||
if (!allowedTypes.Contains(request.FacilityType))
|
||||
{
|
||||
return BadRequest($"FacilityType muss einer der folgenden Werte sein: {string.Join(", ", allowedTypes)}.");
|
||||
}
|
||||
}
|
||||
|
||||
if (request.Website is { Length: > 300 })
|
||||
{
|
||||
return BadRequest("Website darf maximal 300 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
var addressError = ValidateAddressFields(request.Street, request.PostalCode, request.City, request.Country, prefix: "");
|
||||
if (addressError is not null)
|
||||
{
|
||||
return BadRequest(addressError);
|
||||
}
|
||||
|
||||
var billingAddressError = ValidateAddressFields(request.BillingStreet, request.BillingPostalCode, request.BillingCity, request.BillingCountry, prefix: "Billing");
|
||||
if (billingAddressError is not null)
|
||||
{
|
||||
return BadRequest(billingAddressError);
|
||||
}
|
||||
|
||||
var updates = new Facility
|
||||
{
|
||||
Name = request.Name,
|
||||
CrmStatus = request.CrmStatus,
|
||||
FacilityType = request.FacilityType,
|
||||
Website = request.Website,
|
||||
Street = request.Street,
|
||||
PostalCode = request.PostalCode,
|
||||
City = request.City,
|
||||
Country = request.Country,
|
||||
BillingStreet = request.BillingStreet,
|
||||
BillingPostalCode = request.BillingPostalCode,
|
||||
BillingCity = request.BillingCity,
|
||||
BillingCountry = request.BillingCountry
|
||||
};
|
||||
|
||||
var updated = await _facilityService.UpdateAsync(id, updates, cancellationToken);
|
||||
return updated is null ? NotFound() : Ok(ToResponse(updated));
|
||||
}
|
||||
|
||||
private static string? ValidateAddressFields(string? street, string? postalCode, string? city, string? country, string prefix)
|
||||
{
|
||||
if (street is { Length: > 200 })
|
||||
{
|
||||
return $"{prefix}Street darf maximal 200 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (postalCode is { Length: > 10 })
|
||||
{
|
||||
return $"{prefix}PostalCode darf maximal 10 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (city is { Length: > 100 })
|
||||
{
|
||||
return $"{prefix}City darf maximal 100 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (country is { Length: > 100 })
|
||||
{
|
||||
return $"{prefix}Country darf maximal 100 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static FacilityResponse ToResponse(Facility facility)
|
||||
=> new(
|
||||
facility.Id,
|
||||
facility.Name,
|
||||
facility.CrmStatus,
|
||||
facility.FacilityType,
|
||||
facility.Website,
|
||||
facility.Street,
|
||||
facility.PostalCode,
|
||||
facility.City,
|
||||
facility.Country,
|
||||
facility.BillingStreet,
|
||||
facility.BillingPostalCode,
|
||||
facility.BillingCity,
|
||||
facility.BillingCountry);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
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>
|
||||
/// Ansprechpartner sind eine 1:n-Unterressource von Facility (REQUIREMENTS.md FR-EIN-2) — kein
|
||||
/// eigenständiges Core-Objekt, daher unter /api/facilities/{facilityId}/contacts und mit den
|
||||
/// gleichen Facilities-Rechten gegated statt einem eigenen ModuleType.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/facilities/{facilityId:guid}/contacts")]
|
||||
public class FacilityContactsController : ControllerBase
|
||||
{
|
||||
private readonly IFacilityService _facilityService;
|
||||
private readonly IFacilityContactService _facilityContactService;
|
||||
|
||||
public FacilityContactsController(IFacilityService facilityService, IFacilityContactService facilityContactService)
|
||||
{
|
||||
_facilityService = facilityService;
|
||||
_facilityContactService = facilityContactService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.View)]
|
||||
public async Task<ActionResult<IReadOnlyList<FacilityContactResponse>>> GetAll(Guid facilityId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (await _facilityService.GetByIdAsync(facilityId, cancellationToken) is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var contacts = await _facilityContactService.GetByFacilityIdAsync(facilityId, cancellationToken);
|
||||
return Ok(contacts.Select(ToResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Create)]
|
||||
public async Task<ActionResult<FacilityContactResponse>> Create(Guid facilityId, CreateFacilityContactRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (await _facilityService.GetByIdAsync(facilityId, cancellationToken) is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var fieldError = ValidateRequest(request.Name, request.Role, request.Department, request.PhoneNumber, request.Email, request.Notes);
|
||||
if (fieldError is not null)
|
||||
{
|
||||
return BadRequest(fieldError);
|
||||
}
|
||||
|
||||
var contact = new FacilityContact
|
||||
{
|
||||
FacilityId = facilityId,
|
||||
Name = request.Name,
|
||||
Role = request.Role,
|
||||
Department = request.Department,
|
||||
PhoneNumber = request.PhoneNumber,
|
||||
Email = request.Email,
|
||||
Notes = request.Notes
|
||||
};
|
||||
|
||||
var created = await _facilityContactService.CreateAsync(contact, cancellationToken);
|
||||
return CreatedAtAction(nameof(GetAll), new { facilityId }, ToResponse(created));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<FacilityContactResponse>> Update(Guid facilityId, Guid id, UpdateFacilityContactRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await _facilityContactService.GetByIdAsync(id, cancellationToken);
|
||||
if (existing is null || existing.FacilityId != facilityId)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var fieldError = ValidateRequest(request.Name, request.Role, request.Department, request.PhoneNumber, request.Email, request.Notes);
|
||||
if (fieldError is not null)
|
||||
{
|
||||
return BadRequest(fieldError);
|
||||
}
|
||||
|
||||
var updates = new FacilityContact
|
||||
{
|
||||
Name = request.Name,
|
||||
Role = request.Role,
|
||||
Department = request.Department,
|
||||
PhoneNumber = request.PhoneNumber,
|
||||
Email = request.Email,
|
||||
Notes = request.Notes
|
||||
};
|
||||
|
||||
var updated = await _facilityContactService.UpdateAsync(id, updates, cancellationToken);
|
||||
return updated is null ? NotFound() : Ok(ToResponse(updated));
|
||||
}
|
||||
|
||||
private static string? ValidateRequest(string name, string? role, string? department, string? phoneNumber, string? email, string? notes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name) || name.Length > 200)
|
||||
{
|
||||
return "Name ist erforderlich und darf maximal 200 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (role is { Length: > 100 })
|
||||
{
|
||||
return "Role darf maximal 100 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (department is { Length: > 100 })
|
||||
{
|
||||
return "Department darf maximal 100 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (phoneNumber is { Length: > 50 })
|
||||
{
|
||||
return "PhoneNumber darf maximal 50 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (email is { Length: > 200 })
|
||||
{
|
||||
return "Email darf maximal 200 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (notes is { Length: > 1000 })
|
||||
{
|
||||
return "Notes darf maximal 1000 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static FacilityContactResponse ToResponse(FacilityContact contact)
|
||||
=> new(
|
||||
contact.Id,
|
||||
contact.FacilityId,
|
||||
contact.Name,
|
||||
contact.Role,
|
||||
contact.Department,
|
||||
contact.PhoneNumber,
|
||||
contact.Email,
|
||||
contact.Notes);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OmsorgCore.Infrastructure.Persistence;
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
|
||||
namespace OmsorgCore.Api.Controllers;
|
||||
|
||||
@@ -8,26 +7,17 @@ namespace OmsorgCore.Api.Controllers;
|
||||
[Route("api/health")]
|
||||
public class HealthController : ControllerBase
|
||||
{
|
||||
private readonly OmsorgCoreDbContext _db;
|
||||
private readonly IDatabaseConnectivityChecker _databaseConnectivityChecker;
|
||||
|
||||
public HealthController(OmsorgCoreDbContext db)
|
||||
public HealthController(IDatabaseConnectivityChecker databaseConnectivityChecker)
|
||||
{
|
||||
_db = db;
|
||||
_databaseConnectivityChecker = databaseConnectivityChecker;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Get(CancellationToken cancellationToken)
|
||||
{
|
||||
bool databaseReachable;
|
||||
try
|
||||
{
|
||||
databaseReachable = await _db.Database.CanConnectAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
databaseReachable = false;
|
||||
}
|
||||
|
||||
var databaseReachable = await _databaseConnectivityChecker.CanConnectAsync(cancellationToken);
|
||||
return Ok(new { status = "ok", databaseReachable });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
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;
|
||||
using OmsorgCore.Engine.Events;
|
||||
|
||||
namespace OmsorgCore.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/orders")]
|
||||
public class OrdersController : ControllerBase
|
||||
{
|
||||
private const string StatusListKey = "OrderStatus";
|
||||
|
||||
private readonly IOrderService _orderService;
|
||||
private readonly IFacilityContactService _facilityContactService;
|
||||
private readonly IValueListService _valueListService;
|
||||
private readonly IDomainEventDispatcher _dispatcher;
|
||||
|
||||
public OrdersController(
|
||||
IOrderService orderService,
|
||||
IFacilityContactService facilityContactService,
|
||||
IValueListService valueListService,
|
||||
IDomainEventDispatcher dispatcher)
|
||||
{
|
||||
_orderService = orderService;
|
||||
_facilityContactService = facilityContactService;
|
||||
_valueListService = valueListService;
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[RequirePermission(ModuleType.Orders, PermissionAction.View)]
|
||||
public async Task<ActionResult<PagedResponse<OrderResponse>>> GetAll(
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] Guid? statusId,
|
||||
[FromQuery] Guid? facilityId,
|
||||
[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 _orderService.GetPagedAsync(search, statusId, facilityId, page, pageSize, cancellationToken);
|
||||
return Ok(new PagedResponse<OrderResponse>(items.Select(ToResponse).ToList(), totalCount, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Orders, PermissionAction.View)]
|
||||
public async Task<ActionResult<OrderResponse>> GetById(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var order = await _orderService.GetByIdAsync(id, cancellationToken);
|
||||
return order is null ? NotFound() : Ok(ToResponse(order));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[RequirePermission(ModuleType.Orders, PermissionAction.Create)]
|
||||
public async Task<ActionResult<OrderResponse>> Create(CreateOrderRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var fieldError = ValidateFields(
|
||||
request.FacilityId,
|
||||
request.StartDate,
|
||||
request.EndDate,
|
||||
request.RequiredQualification,
|
||||
request.ShiftType,
|
||||
request.RequiredHeadcount,
|
||||
request.Conditions,
|
||||
request.Priority);
|
||||
if (fieldError is not null)
|
||||
{
|
||||
return BadRequest(fieldError);
|
||||
}
|
||||
|
||||
var contactError = await ValidateFacilityContactAsync(request.FacilityContactId, request.FacilityId, cancellationToken);
|
||||
if (contactError is not null)
|
||||
{
|
||||
return BadRequest(contactError);
|
||||
}
|
||||
|
||||
var order = new Order
|
||||
{
|
||||
FacilityId = request.FacilityId,
|
||||
FacilityContactId = request.FacilityContactId,
|
||||
StartDate = request.StartDate,
|
||||
EndDate = request.EndDate,
|
||||
RequiredQualification = request.RequiredQualification,
|
||||
ShiftType = request.ShiftType,
|
||||
RequiredHeadcount = request.RequiredHeadcount,
|
||||
Conditions = request.Conditions,
|
||||
Priority = request.Priority
|
||||
};
|
||||
|
||||
var created = await _orderService.CreateAsync(order, cancellationToken);
|
||||
await _dispatcher.DispatchAsync(new OrderCreatedEvent(created.Id), cancellationToken);
|
||||
|
||||
var reloaded = await _orderService.GetByIdAsync(created.Id, cancellationToken) ?? created;
|
||||
return CreatedAtAction(nameof(GetById), new { id = created.Id }, ToResponse(reloaded));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Orders, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<OrderResponse>> Update(Guid id, UpdateOrderRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var fieldError = ValidateFields(
|
||||
request.FacilityId,
|
||||
request.StartDate,
|
||||
request.EndDate,
|
||||
request.RequiredQualification,
|
||||
request.ShiftType,
|
||||
request.RequiredHeadcount,
|
||||
request.Conditions,
|
||||
request.Priority);
|
||||
if (fieldError is not null)
|
||||
{
|
||||
return BadRequest(fieldError);
|
||||
}
|
||||
|
||||
var contactError = await ValidateFacilityContactAsync(request.FacilityContactId, request.FacilityId, cancellationToken);
|
||||
if (contactError is not null)
|
||||
{
|
||||
return BadRequest(contactError);
|
||||
}
|
||||
|
||||
var statusItems = await _valueListService.GetItemsAsync(StatusListKey, cancellationToken);
|
||||
if (!statusItems.Any(s => s.Id == request.StatusId))
|
||||
{
|
||||
return BadRequest("StatusId verweist auf keinen bekannten Auftragsstatus.");
|
||||
}
|
||||
|
||||
var updates = new Order
|
||||
{
|
||||
FacilityId = request.FacilityId,
|
||||
FacilityContactId = request.FacilityContactId,
|
||||
StartDate = request.StartDate,
|
||||
EndDate = request.EndDate,
|
||||
RequiredQualification = request.RequiredQualification,
|
||||
ShiftType = request.ShiftType,
|
||||
RequiredHeadcount = request.RequiredHeadcount,
|
||||
Conditions = request.Conditions,
|
||||
Priority = request.Priority,
|
||||
StatusId = request.StatusId
|
||||
};
|
||||
|
||||
var result = await _orderService.UpdateAsync(id, updates, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
return result.FailureReason switch
|
||||
{
|
||||
UpdateOrderFailureReason.OrderNotFound => NotFound(),
|
||||
UpdateOrderFailureReason.InvalidStatusTransition => BadRequest("Der Statuswechsel ist nicht zulässig."),
|
||||
_ => BadRequest()
|
||||
};
|
||||
}
|
||||
|
||||
return Ok(ToResponse(result.Order!));
|
||||
}
|
||||
|
||||
private async Task<string?> ValidateFacilityContactAsync(Guid? facilityContactId, Guid facilityId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (facilityContactId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var contact = await _facilityContactService.GetByIdAsync(facilityContactId.Value, cancellationToken);
|
||||
if (contact is null || contact.FacilityId != facilityId)
|
||||
{
|
||||
return "Der Ansprechpartner gehört nicht zur angegebenen Einrichtung.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ValidateFields(
|
||||
Guid facilityId,
|
||||
DateOnly startDate,
|
||||
DateOnly? endDate,
|
||||
string? requiredQualification,
|
||||
string? shiftType,
|
||||
int requiredHeadcount,
|
||||
string? conditions,
|
||||
string priority)
|
||||
{
|
||||
if (facilityId == Guid.Empty)
|
||||
{
|
||||
return "FacilityId ist erforderlich.";
|
||||
}
|
||||
|
||||
if (startDate == default)
|
||||
{
|
||||
return "StartDate ist erforderlich.";
|
||||
}
|
||||
|
||||
if (endDate.HasValue && endDate.Value < startDate)
|
||||
{
|
||||
return "EndDate darf nicht vor StartDate liegen.";
|
||||
}
|
||||
|
||||
if (requiredHeadcount < 1)
|
||||
{
|
||||
return "RequiredHeadcount muss mindestens 1 sein.";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(priority) || priority.Length > 50)
|
||||
{
|
||||
return "Priority ist erforderlich und darf maximal 50 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (requiredQualification is { Length: > 200 })
|
||||
{
|
||||
return "RequiredQualification darf maximal 200 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (shiftType is { Length: > 100 })
|
||||
{
|
||||
return "ShiftType darf maximal 100 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (conditions is { Length: > 500 })
|
||||
{
|
||||
return "Conditions darf maximal 500 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static OrderResponse ToResponse(Order order)
|
||||
=> new(
|
||||
order.Id,
|
||||
order.FacilityId,
|
||||
order.FacilityContactId,
|
||||
order.StartDate,
|
||||
order.EndDate,
|
||||
order.RequiredQualification,
|
||||
order.ShiftType,
|
||||
order.RequiredHeadcount,
|
||||
order.Conditions,
|
||||
order.Priority,
|
||||
order.StatusId,
|
||||
order.Status?.Value ?? string.Empty);
|
||||
}
|
||||
@@ -48,4 +48,40 @@ public class RolesController : ControllerBase
|
||||
|
||||
return CreatedAtAction(nameof(GetAll), new RoleResponse(result.Role!.Id, result.Role.Name));
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.View)]
|
||||
public async Task<ActionResult<RolePermissionsResponse>> GetById(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var role = await _roleService.GetByIdWithPermissionsAsync(id, cancellationToken);
|
||||
if (role is null)
|
||||
{
|
||||
return NotFound("Rolle nicht gefunden.");
|
||||
}
|
||||
|
||||
var permissions = role.RolePermissions
|
||||
.Select(rp => new PermissionDto(rp.Module, rp.Action))
|
||||
.ToList();
|
||||
|
||||
return Ok(new RolePermissionsResponse(role.Id, role.Name, permissions));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/permissions")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
public async Task<IActionResult> UpdatePermissions(Guid id, UpdateRolePermissionsRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsed = request.Permissions.Select(dto => (dto.Module, dto.Action)).ToList();
|
||||
|
||||
var result = await _roleService.UpdatePermissionsAsync(id, parsed, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
return result.FailureReason switch
|
||||
{
|
||||
UpdateRolePermissionsFailureReason.RoleNotFound => NotFound("Rolle nicht gefunden."),
|
||||
_ => BadRequest()
|
||||
};
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OmsorgCore.Api.Contracts;
|
||||
using OmsorgCore.Api.Security;
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Application.Services;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
using OmsorgCore.Engine.Events;
|
||||
using OmsorgCore.Infrastructure.Security;
|
||||
|
||||
namespace OmsorgCore.Api.Controllers;
|
||||
|
||||
@@ -14,18 +16,19 @@ namespace OmsorgCore.Api.Controllers;
|
||||
[Route("api/users")]
|
||||
public class UsersController : ControllerBase
|
||||
{
|
||||
private const int MinInitialPasswordLength = 8;
|
||||
private const int DefaultPinValidityDays = 7;
|
||||
private const int MinPinValidityDays = 1;
|
||||
private const int MaxPinValidityDays = 30;
|
||||
|
||||
private readonly IUserService _userService;
|
||||
private readonly IDomainEventDispatcher _dispatcher;
|
||||
private readonly PasswordPolicyOptions _passwordPolicyOptions;
|
||||
|
||||
public UsersController(IUserService userService, IDomainEventDispatcher dispatcher)
|
||||
public UsersController(IUserService userService, IDomainEventDispatcher dispatcher, IOptions<PasswordPolicyOptions> passwordPolicyOptions)
|
||||
{
|
||||
_userService = userService;
|
||||
_dispatcher = dispatcher;
|
||||
_passwordPolicyOptions = passwordPolicyOptions.Value;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -65,6 +68,7 @@ public class UsersController : ControllerBase
|
||||
CreateUserFailureReason.UsernameTaken => Conflict("Dieser Username ist bereits vergeben."),
|
||||
CreateUserFailureReason.EmployeeEmailMissing => BadRequest("Für den Einladungs-Modus braucht der Mitarbeiter eine hinterlegte E-Mail-Adresse."),
|
||||
CreateUserFailureReason.InitialPasswordRequired => BadRequest("InitialPassword ist im Direct-Modus erforderlich."),
|
||||
CreateUserFailureReason.PasswordTooShort => BadRequest($"InitialPassword muss mindestens {_passwordPolicyOptions.MinLength} Zeichen lang sein."),
|
||||
_ => BadRequest()
|
||||
};
|
||||
}
|
||||
@@ -100,6 +104,7 @@ public class UsersController : ControllerBase
|
||||
AdminResetPasswordFailureReason.UserNotFound => NotFound("User nicht gefunden."),
|
||||
AdminResetPasswordFailureReason.EmployeeEmailMissing => BadRequest("Für den Einladungs-Modus braucht der Mitarbeiter eine hinterlegte E-Mail-Adresse."),
|
||||
AdminResetPasswordFailureReason.InitialPasswordRequired => BadRequest("InitialPassword ist im Direct-Modus erforderlich."),
|
||||
AdminResetPasswordFailureReason.PasswordTooShort => BadRequest($"InitialPassword muss mindestens {_passwordPolicyOptions.MinLength} Zeichen lang sein."),
|
||||
_ => BadRequest()
|
||||
};
|
||||
}
|
||||
@@ -153,15 +158,65 @@ public class UsersController : ControllerBase
|
||||
pinValidity = TimeSpan.FromDays(days);
|
||||
}
|
||||
|
||||
if (mode == UserCreationMode.Direct
|
||||
&& (initialPassword is null || initialPassword.Length < MinInitialPasswordLength))
|
||||
if (mode == UserCreationMode.Direct && initialPassword is null)
|
||||
{
|
||||
return ($"InitialPassword ist im Direct-Modus erforderlich und muss mindestens {MinInitialPasswordLength} Zeichen lang sein.", mode, null);
|
||||
return ("InitialPassword ist im Direct-Modus erforderlich.", mode, null);
|
||||
}
|
||||
|
||||
return (null, mode, pinValidity);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/permission-overrides")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.View)]
|
||||
public async Task<ActionResult<IReadOnlyList<UserPermissionOverrideResponse>>> GetPermissionOverrides(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var overrides = await _userService.GetPermissionOverridesAsync(id, cancellationToken);
|
||||
if (overrides is null)
|
||||
{
|
||||
return NotFound("User nicht gefunden.");
|
||||
}
|
||||
|
||||
return Ok(overrides.Select(o => new UserPermissionOverrideResponse(
|
||||
o.Id, o.Module, o.Action, o.Effect)).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/permission-overrides")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<UserPermissionOverrideResponse>> AddPermissionOverride(
|
||||
Guid id, AddUserPermissionOverrideRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _userService.AddPermissionOverrideAsync(id, request.Module, request.Action, request.Effect, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
return result.FailureReason switch
|
||||
{
|
||||
AddPermissionOverrideFailureReason.UserNotFound => NotFound("User nicht gefunden."),
|
||||
_ => BadRequest()
|
||||
};
|
||||
}
|
||||
|
||||
var o = result.Override!;
|
||||
return Ok(new UserPermissionOverrideResponse(o.Id, o.Module, o.Action, o.Effect));
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}/permission-overrides/{overrideId:guid}")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
public async Task<IActionResult> RemovePermissionOverride(Guid id, Guid overrideId, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _userService.RemovePermissionOverrideAsync(id, overrideId, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
return result.FailureReason switch
|
||||
{
|
||||
RemovePermissionOverrideFailureReason.UserNotFound => NotFound("User nicht gefunden."),
|
||||
RemovePermissionOverrideFailureReason.OverrideNotFound => NotFound("Override nicht gefunden."),
|
||||
_ => BadRequest()
|
||||
};
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private static UserResponse ToResponse(UserSummary summary)
|
||||
=> new(summary.Id, summary.Username, summary.EmployeeId, summary.RoleName, summary.IsActive, summary.MustChangePassword);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
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>
|
||||
/// Verwaltet die konfigurierbaren Auswahllisten (Mitarbeiterstatus, Beschäftigungsart, CRM-Status,
|
||||
/// Einrichtungstyp, Vertragstyp/-status, Auftragsstatus) — siehe omsorgCore/CLAUDE.md, Abschnitt
|
||||
/// "Konfigurierbare Auswahllisten". Lesen ist für jeden eingeloggten Nutzer erlaubt (die aufrufenden
|
||||
/// Formulare gehören zu unterschiedlichen Modulen), Schreiben ist eine Admin-Funktion und läuft über
|
||||
/// dasselbe Recht wie die übrige "Einstellungen"-Seite (<see cref="ModuleType.UserManagement"/>).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/value-lists")]
|
||||
public class ValueListsController : ControllerBase
|
||||
{
|
||||
private readonly IValueListService _valueListService;
|
||||
|
||||
public ValueListsController(IValueListService valueListService)
|
||||
{
|
||||
_valueListService = valueListService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IReadOnlyList<ValueListResponse>>> GetLists(CancellationToken cancellationToken)
|
||||
{
|
||||
var lists = await _valueListService.GetListsAsync(cancellationToken);
|
||||
return Ok(lists.Select(l => new ValueListResponse(l.Id, l.Key, l.DisplayName)).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("{key}/items")]
|
||||
public async Task<ActionResult<IReadOnlyList<ValueListItemResponse>>> GetItems(string key, CancellationToken cancellationToken)
|
||||
{
|
||||
var items = await _valueListService.GetItemsAsync(key, cancellationToken);
|
||||
return Ok(items.Select(ToResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("{key}/items")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<ValueListItemResponse>> CreateItem(string key, CreateValueListItemRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Value) || request.Value.Length > 100)
|
||||
{
|
||||
return BadRequest("Value ist erforderlich und darf maximal 100 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var item = await _valueListService.CreateItemAsync(
|
||||
key, request.Value, request.SortOrder, request.IsDefault, request.IsInitial, request.IsTerminal, cancellationToken);
|
||||
return Ok(ToResponse(item));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{key}/items/{id:guid}")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<ValueListItemResponse>> UpdateItem(string key, Guid id, UpdateValueListItemRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Value) || request.Value.Length > 100)
|
||||
{
|
||||
return BadRequest("Value ist erforderlich und darf maximal 100 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
var item = await _valueListService.UpdateItemAsync(
|
||||
id, request.Value, request.SortOrder, request.IsDefault, request.IsInitial, request.IsTerminal, cancellationToken);
|
||||
return item is null ? NotFound() : Ok(ToResponse(item));
|
||||
}
|
||||
|
||||
[HttpDelete("{key}/items/{id:guid}")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
public async Task<IActionResult> DeleteItem(string key, Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _valueListService.DeleteItemAsync(id, cancellationToken);
|
||||
if (result.Success)
|
||||
{
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
return result.FailureReason switch
|
||||
{
|
||||
DeleteValueListItemFailureReason.ItemNotFound => NotFound(),
|
||||
DeleteValueListItemFailureReason.InUse => Conflict(result.Usages
|
||||
.Select(u => new ValueListUsageResponse(u.EntityType, u.EntityId, u.DisplayLabel))
|
||||
.ToList()),
|
||||
_ => Conflict()
|
||||
};
|
||||
}
|
||||
|
||||
[HttpGet("{key}/items/{id:guid}/usages")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<IReadOnlyList<ValueListUsageResponse>>> GetUsages(string key, Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var usages = await _valueListService.GetUsagesAsync(id, cancellationToken);
|
||||
return Ok(usages.Select(u => new ValueListUsageResponse(u.EntityType, u.EntityId, u.DisplayLabel)).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("{key}/transitions")]
|
||||
public async Task<ActionResult<IReadOnlyList<ValueListTransitionResponse>>> GetTransitions(string key, CancellationToken cancellationToken)
|
||||
{
|
||||
var transitions = await _valueListService.GetTransitionsAsync(key, cancellationToken);
|
||||
return Ok(transitions.Select(t => new ValueListTransitionResponse(t.Id, t.FromItemId, t.ToItemId)).ToList());
|
||||
}
|
||||
|
||||
[HttpPut("{key}/transitions")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
public async Task<IActionResult> ReplaceTransitions(string key, List<ValueListTransitionRequest> request, CancellationToken cancellationToken)
|
||||
{
|
||||
await _valueListService.ReplaceTransitionsAsync(key, request.Select(t => (t.FromItemId, t.ToItemId)), cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private static ValueListItemResponse ToResponse(ValueListItem item)
|
||||
=> new(item.Id, item.Value, item.SortOrder, item.IsDefault, item.IsInitial, item.IsTerminal);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -17,7 +18,8 @@ using OmsorgCore.Infrastructure.Security;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddControllers()
|
||||
.AddJsonOptions(options => options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(options =>
|
||||
{
|
||||
@@ -132,6 +134,34 @@ using (var migrationScope = app.Services.CreateScope())
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
// Basis-Rollen (Geschäftsführung/Disposition/Recruiting/Außendienst, siehe REQUIREMENTS.md Abschnitt 3/7)
|
||||
// in jeder Umgebung anlegen - reine Rollen-Stammdaten ohne Zugangsdaten, anders als der
|
||||
// Development-only-Admin-Seed unten. Nicht fatal bei Fehlern, analog zum Admin-Seed.
|
||||
try
|
||||
{
|
||||
using var baseRolesScope = app.Services.CreateScope();
|
||||
var db = baseRolesScope.ServiceProvider.GetRequiredService<OmsorgCoreDbContext>();
|
||||
await DbSeeder.SeedBaseRolesAsync(db);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
app.Logger.LogWarning(ex, "Seed der Basis-Rollen fehlgeschlagen (z.B. Datenbank nicht erreichbar).");
|
||||
}
|
||||
|
||||
// Konfigurierbare Auswahllisten (Mitarbeiterstatus, Beschäftigungsart, CRM-Status, Einrichtungstyp,
|
||||
// Vertragstyp/-status, Auftragsstatus inkl. Übergangsregeln, FR-EM-2) - reine Stammdaten, jede
|
||||
// Umgebung, nicht fatal.
|
||||
try
|
||||
{
|
||||
using var valueListScope = app.Services.CreateScope();
|
||||
var db = valueListScope.ServiceProvider.GetRequiredService<OmsorgCoreDbContext>();
|
||||
await DbSeeder.SeedValueListsAsync(db);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
app.Logger.LogWarning(ex, "Seed der Auswahllisten fehlgeschlagen (z.B. Datenbank nicht erreichbar).");
|
||||
}
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
|
||||
@@ -24,4 +24,10 @@ public class CurrentUserService : ICurrentUserService
|
||||
return Guid.TryParse(value, out var id) ? id : null;
|
||||
}
|
||||
}
|
||||
|
||||
public string? Username => _httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.Name);
|
||||
|
||||
public string? RoleName => _httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.Role);
|
||||
|
||||
public string? IpAddress => _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"RefreshToken": {
|
||||
"ExpiryDays": 60
|
||||
},
|
||||
"PasswordPolicy": {
|
||||
"MinLength": 8
|
||||
},
|
||||
"Auth": {
|
||||
"MaxLoginFailures": 5,
|
||||
"LoginLockoutMinutes": 10
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface IAuditLogRepository
|
||||
{
|
||||
Task AddAsync(AuditLogEntry entry, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<(IReadOnlyList<AuditLogEntry> Items, int TotalCount)> GetPagedAsync(
|
||||
string? entityType,
|
||||
Guid? entityId,
|
||||
Guid? actorUserId,
|
||||
AuditEventCategory? category,
|
||||
DateTime? fromUtc,
|
||||
DateTime? toUtc,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface IAuditLogService
|
||||
{
|
||||
Task<(IReadOnlyList<AuditLogEntry> Items, int TotalCount)> GetPagedAsync(
|
||||
string? entityType,
|
||||
Guid? entityId,
|
||||
Guid? actorUserId,
|
||||
AuditEventCategory? category,
|
||||
DateTime? fromUtc,
|
||||
DateTime? toUtc,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface IContractRepository
|
||||
{
|
||||
Task<Contract?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Contract>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<(IReadOnlyList<Contract> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
string? status,
|
||||
Guid? employeeId,
|
||||
Guid? facilityId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task AddAsync(Contract contract, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(Contract contract, CancellationToken cancellationToken = default);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -9,4 +9,7 @@ public interface ICurrentUserService
|
||||
{
|
||||
Guid? UserId { get; }
|
||||
bool IsAuthenticated { get; }
|
||||
string? Username { get; }
|
||||
string? RoleName { get; }
|
||||
string? IpAddress { get; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Prüft, ob die Datenbank erreichbar ist, ohne dass Api-Controller dafür direkt an
|
||||
/// EF Core/DbContext koppeln müssen (siehe Health-Check).
|
||||
/// </summary>
|
||||
public interface IDatabaseConnectivityChecker
|
||||
{
|
||||
Task<bool> CanConnectAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface IFacilityContactRepository
|
||||
{
|
||||
Task<FacilityContact?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<FacilityContact>> GetByFacilityIdAsync(Guid facilityId, CancellationToken cancellationToken = default);
|
||||
Task AddAsync(FacilityContact contact, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(FacilityContact contact, CancellationToken cancellationToken = default);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface IFacilityRepository
|
||||
{
|
||||
Task<Facility?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Facility>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<(IReadOnlyList<Facility> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
string? crmStatus,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task AddAsync(Facility facility, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(Facility facility, CancellationToken cancellationToken = default);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface IOrderRepository
|
||||
{
|
||||
Task<Order?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Order>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<(IReadOnlyList<Order> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
Guid? statusId,
|
||||
Guid? facilityId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task AddAsync(Order order, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(Order order, CancellationToken cancellationToken = default);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Einzige Stelle, die entscheidet, ob ein Passwort die konfigurierte Mindestlänge erfüllt -
|
||||
/// wird von jedem Application-Service injiziert, der ein Passwort entgegennimmt (UserService,
|
||||
/// PasswordResetService), damit die Regel nicht mehrfach/inkonsistent dupliziert wird.
|
||||
/// </summary>
|
||||
public interface IPasswordPolicy
|
||||
{
|
||||
int MinLength { get; }
|
||||
|
||||
bool IsValid(string? password);
|
||||
}
|
||||
@@ -6,6 +6,16 @@ public interface IRoleRepository
|
||||
{
|
||||
Task<IReadOnlyList<Role>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<Role?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Inklusive RolePermissions - für das Lesen/Bearbeiten der Rechte-Matrix einer Rolle.</summary>
|
||||
Task<Role?> GetByIdWithPermissionsAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Explizit über das DbSet statt über role.RolePermissions.Add(...) - sonst behandelt EF Core neue
|
||||
/// Kind-Entitäten mit bereits client-seitig gesetzter Guid-Id fälschlich als "existiert schon" und
|
||||
/// generiert ein UPDATE statt INSERT (0 betroffene Zeilen, DbUpdateConcurrencyException).
|
||||
/// </summary>
|
||||
Task AddPermissionRangeAsync(IEnumerable<RolePermission> permissions, CancellationToken cancellationToken = default);
|
||||
Task<bool> ExistsByNameAsync(string name, CancellationToken cancellationToken = default);
|
||||
Task AddAsync(Role role, CancellationToken cancellationToken = default);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -23,5 +23,12 @@ public interface IUserRepository
|
||||
|
||||
Task AddAsync(User user, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Explizit über das DbSet statt über user.PermissionOverrides.Add(...) - sonst behandelt EF Core
|
||||
/// die neue Kind-Entität mit bereits client-seitig gesetzter Guid-Id fälschlich als "existiert schon"
|
||||
/// und generiert ein UPDATE statt INSERT (0 betroffene Zeilen, DbUpdateConcurrencyException).
|
||||
/// </summary>
|
||||
Task AddPermissionOverrideAsync(UserPermissionOverride @override, CancellationToken cancellationToken = default);
|
||||
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface IValueListRepository
|
||||
{
|
||||
Task<IReadOnlyList<ValueList>> GetAllListsAsync(CancellationToken cancellationToken = default);
|
||||
Task<ValueList?> GetListByKeyAsync(string key, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<ValueListItem>> GetItemsAsync(string key, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<string>> GetActiveValuesAsync(string key, CancellationToken cancellationToken = default);
|
||||
Task<ValueListItem?> GetItemByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<ValueListItem?> GetInitialItemAsync(string key, CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddItemAsync(ValueListItem item, CancellationToken cancellationToken = default);
|
||||
Task UpdateItemAsync(ValueListItem item, CancellationToken cancellationToken = default);
|
||||
Task RemoveItemAsync(ValueListItem item, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> CanTransitionAsync(Guid fromItemId, Guid toItemId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<ValueListItemTransition>> GetTransitionsAsync(string key, CancellationToken cancellationToken = default);
|
||||
Task ReplaceTransitionsAsync(string key, IEnumerable<(Guid FromItemId, Guid ToItemId)> transitions, CancellationToken cancellationToken = default);
|
||||
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public record ValueListUsageEntry(string EntityType, Guid EntityId, string DisplayLabel);
|
||||
|
||||
/// <summary>
|
||||
/// Prüft, ob ein bestimmter <c>ValueListItem</c>-Wert (identifiziert über den <c>Key</c> seiner
|
||||
/// Liste) noch irgendwo verwendet wird — Voraussetzung dafür, dass <c>ValueListService.DeleteItemAsync</c>
|
||||
/// das Löschen verweigert, solange Treffer existieren (Nutzeranforderung: "erst überall entfernen").
|
||||
/// Eine Implementierung je Liste, registriert in Infrastructure/DependencyInjection.cs.
|
||||
/// </summary>
|
||||
public interface IValueListUsageChecker
|
||||
{
|
||||
string Key { get; }
|
||||
|
||||
Task<IReadOnlyList<ValueListUsageEntry>> FindUsagesAsync(Guid itemId, string itemValue, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -11,10 +11,16 @@ public static class DependencyInjection
|
||||
services.AddScoped<IPermissionService, PermissionService>();
|
||||
services.AddScoped<IAuthService, AuthService>();
|
||||
services.AddScoped<IEmployeeService, EmployeeService>();
|
||||
services.AddScoped<IFacilityService, FacilityService>();
|
||||
services.AddScoped<IFacilityContactService, FacilityContactService>();
|
||||
services.AddScoped<IContractService, ContractService>();
|
||||
services.AddScoped<IOrderService, OrderService>();
|
||||
services.AddScoped<IValueListService, ValueListService>();
|
||||
services.AddScoped<ISessionAdminService, SessionAdminService>();
|
||||
services.AddScoped<IPasswordResetService, PasswordResetService>();
|
||||
services.AddScoped<IUserService, UserService>();
|
||||
services.AddScoped<IRoleService, RoleService>();
|
||||
services.AddScoped<IAuditLogService, AuditLogService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Application.Models;
|
||||
|
||||
/// <summary>Ein einzelner UserPermissionOverride-Eintrag für die Admin-Ansicht/-Bearbeitung.</summary>
|
||||
public record PermissionOverrideSummary(Guid Id, ModuleType Module, PermissionAction Action, PermissionEffect Effect);
|
||||
@@ -0,0 +1,27 @@
|
||||
using OmsorgCore.Application.Models;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public enum AddPermissionOverrideFailureReason
|
||||
{
|
||||
UserNotFound
|
||||
}
|
||||
|
||||
public class AddPermissionOverrideResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public AddPermissionOverrideFailureReason? FailureReason { get; init; }
|
||||
public PermissionOverrideSummary? Override { get; init; }
|
||||
|
||||
public static AddPermissionOverrideResult Fail(AddPermissionOverrideFailureReason reason) => new()
|
||||
{
|
||||
Success = false,
|
||||
FailureReason = reason
|
||||
};
|
||||
|
||||
public static AddPermissionOverrideResult Ok(PermissionOverrideSummary @override) => new()
|
||||
{
|
||||
Success = true,
|
||||
Override = @override
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,8 @@ public enum AdminResetPasswordFailureReason
|
||||
{
|
||||
UserNotFound,
|
||||
EmployeeEmailMissing,
|
||||
InitialPasswordRequired
|
||||
InitialPasswordRequired,
|
||||
PasswordTooShort
|
||||
}
|
||||
|
||||
public class AdminResetPasswordResult
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class AuditLogService : IAuditLogService
|
||||
{
|
||||
private readonly IAuditLogRepository _auditLogRepository;
|
||||
|
||||
public AuditLogService(IAuditLogRepository auditLogRepository)
|
||||
{
|
||||
_auditLogRepository = auditLogRepository;
|
||||
}
|
||||
|
||||
public Task<(IReadOnlyList<AuditLogEntry> Items, int TotalCount)> GetPagedAsync(
|
||||
string? entityType,
|
||||
Guid? entityId,
|
||||
Guid? actorUserId,
|
||||
AuditEventCategory? category,
|
||||
DateTime? fromUtc,
|
||||
DateTime? toUtc,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> _auditLogRepository.GetPagedAsync(entityType, entityId, actorUserId, category, fromUtc, toUtc, page, pageSize, cancellationToken);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ namespace OmsorgCore.Application.Services;
|
||||
public class AuthResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public Guid? UserId { get; init; }
|
||||
public string? Username { get; init; }
|
||||
public string? Token { get; init; }
|
||||
public string? RefreshToken { get; init; }
|
||||
public DateTime? ExpiresAt { get; init; }
|
||||
@@ -13,9 +15,11 @@ public class AuthResult
|
||||
|
||||
public static AuthResult LockedOut() => new() { Success = false, IsLockedOut = true };
|
||||
|
||||
public static AuthResult Ok(string token, string refreshToken, DateTime expiresAt, bool mustChangePassword) => new()
|
||||
public static AuthResult Ok(Guid userId, string username, string token, string refreshToken, DateTime expiresAt, bool mustChangePassword) => new()
|
||||
{
|
||||
Success = true,
|
||||
UserId = userId,
|
||||
Username = username,
|
||||
Token = token,
|
||||
RefreshToken = refreshToken,
|
||||
ExpiresAt = expiresAt,
|
||||
|
||||
@@ -150,6 +150,6 @@ public class AuthService : IAuthService
|
||||
|
||||
await _refreshTokenRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return AuthResult.Ok(accessToken, rawRefreshToken, accessTokenExpiresAt, user.MustChangePassword);
|
||||
return AuthResult.Ok(user.Id, user.Username, accessToken, rawRefreshToken, accessTokenExpiresAt, user.MustChangePassword);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public enum ChangeOwnPasswordResult
|
||||
{
|
||||
Success,
|
||||
InvalidCurrentPassword,
|
||||
PasswordTooShort
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class ContractService : IContractService
|
||||
{
|
||||
private readonly IContractRepository _contractRepository;
|
||||
|
||||
public ContractService(IContractRepository contractRepository)
|
||||
{
|
||||
_contractRepository = contractRepository;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Contract>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
=> _contractRepository.GetAllAsync(cancellationToken);
|
||||
|
||||
public Task<(IReadOnlyList<Contract> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
string? status,
|
||||
Guid? employeeId,
|
||||
Guid? facilityId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> _contractRepository.GetPagedAsync(search, status, employeeId, facilityId, page, pageSize, cancellationToken);
|
||||
|
||||
public Task<Contract?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _contractRepository.GetByIdAsync(id, cancellationToken);
|
||||
|
||||
public async Task<Contract> CreateAsync(Contract contract, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _contractRepository.AddAsync(contract, cancellationToken);
|
||||
await _contractRepository.SaveChangesAsync(cancellationToken);
|
||||
return contract;
|
||||
}
|
||||
|
||||
public async Task<Contract?> UpdateAsync(Guid id, Contract updates, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var contract = await _contractRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (contract is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
contract.ContractType = updates.ContractType;
|
||||
contract.EmployeeId = updates.EmployeeId;
|
||||
contract.FacilityId = updates.FacilityId;
|
||||
contract.StartDate = updates.StartDate;
|
||||
contract.EndDate = updates.EndDate;
|
||||
contract.Status = updates.Status;
|
||||
contract.WeeklyHours = updates.WeeklyHours;
|
||||
contract.HourlyWage = updates.HourlyWage;
|
||||
contract.AllowancesDescription = updates.AllowancesDescription;
|
||||
contract.OvertimeRules = updates.OvertimeRules;
|
||||
contract.VacationDaysPerYear = updates.VacationDaysPerYear;
|
||||
contract.ProbationPeriodMonths = updates.ProbationPeriodMonths;
|
||||
contract.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _contractRepository.UpdateAsync(contract, cancellationToken);
|
||||
await _contractRepository.SaveChangesAsync(cancellationToken);
|
||||
return contract;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,8 @@ public enum CreateUserFailureReason
|
||||
EmployeeEmailMissing,
|
||||
UsernameTaken,
|
||||
RoleNotFound,
|
||||
InitialPasswordRequired
|
||||
InitialPasswordRequired,
|
||||
PasswordTooShort
|
||||
}
|
||||
|
||||
public class CreateUserResult
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public enum DeleteValueListItemFailureReason
|
||||
{
|
||||
ItemNotFound,
|
||||
InUse
|
||||
}
|
||||
|
||||
public class DeleteValueListItemResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public DeleteValueListItemFailureReason? FailureReason { get; init; }
|
||||
public IReadOnlyList<ValueListUsageEntry> Usages { get; init; } = Array.Empty<ValueListUsageEntry>();
|
||||
|
||||
public static DeleteValueListItemResult Ok() => new() { Success = true };
|
||||
|
||||
public static DeleteValueListItemResult Fail(DeleteValueListItemFailureReason reason, IReadOnlyList<ValueListUsageEntry>? usages = null) => new()
|
||||
{
|
||||
Success = false,
|
||||
FailureReason = reason,
|
||||
Usages = usages ?? Array.Empty<ValueListUsageEntry>()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class FacilityContactService : IFacilityContactService
|
||||
{
|
||||
private readonly IFacilityContactRepository _facilityContactRepository;
|
||||
|
||||
public FacilityContactService(IFacilityContactRepository facilityContactRepository)
|
||||
{
|
||||
_facilityContactRepository = facilityContactRepository;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<FacilityContact>> GetByFacilityIdAsync(Guid facilityId, CancellationToken cancellationToken = default)
|
||||
=> _facilityContactRepository.GetByFacilityIdAsync(facilityId, cancellationToken);
|
||||
|
||||
public Task<FacilityContact?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _facilityContactRepository.GetByIdAsync(id, cancellationToken);
|
||||
|
||||
public async Task<FacilityContact> CreateAsync(FacilityContact contact, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _facilityContactRepository.AddAsync(contact, cancellationToken);
|
||||
await _facilityContactRepository.SaveChangesAsync(cancellationToken);
|
||||
return contact;
|
||||
}
|
||||
|
||||
public async Task<FacilityContact?> UpdateAsync(Guid id, FacilityContact updates, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var contact = await _facilityContactRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (contact is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
contact.Name = updates.Name;
|
||||
contact.Role = updates.Role;
|
||||
contact.Department = updates.Department;
|
||||
contact.PhoneNumber = updates.PhoneNumber;
|
||||
contact.Email = updates.Email;
|
||||
contact.Notes = updates.Notes;
|
||||
contact.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _facilityContactRepository.UpdateAsync(contact, cancellationToken);
|
||||
await _facilityContactRepository.SaveChangesAsync(cancellationToken);
|
||||
return contact;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class FacilityService : IFacilityService
|
||||
{
|
||||
private readonly IFacilityRepository _facilityRepository;
|
||||
|
||||
public FacilityService(IFacilityRepository facilityRepository)
|
||||
{
|
||||
_facilityRepository = facilityRepository;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Facility>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
=> _facilityRepository.GetAllAsync(cancellationToken);
|
||||
|
||||
public Task<(IReadOnlyList<Facility> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
string? crmStatus,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> _facilityRepository.GetPagedAsync(search, crmStatus, page, pageSize, cancellationToken);
|
||||
|
||||
public Task<Facility?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _facilityRepository.GetByIdAsync(id, cancellationToken);
|
||||
|
||||
public async Task<Facility> CreateAsync(Facility facility, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _facilityRepository.AddAsync(facility, cancellationToken);
|
||||
await _facilityRepository.SaveChangesAsync(cancellationToken);
|
||||
return facility;
|
||||
}
|
||||
|
||||
public async Task<Facility?> UpdateAsync(Guid id, Facility updates, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var facility = await _facilityRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (facility is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
facility.Name = updates.Name;
|
||||
facility.FacilityType = updates.FacilityType;
|
||||
facility.Website = updates.Website;
|
||||
facility.Street = updates.Street;
|
||||
facility.PostalCode = updates.PostalCode;
|
||||
facility.City = updates.City;
|
||||
facility.Country = updates.Country;
|
||||
facility.BillingStreet = updates.BillingStreet;
|
||||
facility.BillingPostalCode = updates.BillingPostalCode;
|
||||
facility.BillingCity = updates.BillingCity;
|
||||
facility.BillingCountry = updates.BillingCountry;
|
||||
facility.CrmStatus = updates.CrmStatus;
|
||||
facility.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _facilityRepository.UpdateAsync(facility, cancellationToken);
|
||||
await _facilityRepository.SaveChangesAsync(cancellationToken);
|
||||
return facility;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public interface IContractService
|
||||
{
|
||||
Task<IReadOnlyList<Contract>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<(IReadOnlyList<Contract> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
string? status,
|
||||
Guid? employeeId,
|
||||
Guid? facilityId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<Contract?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<Contract> CreateAsync(Contract contract, CancellationToken cancellationToken = default);
|
||||
Task<Contract?> UpdateAsync(Guid id, Contract updates, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public interface IFacilityContactService
|
||||
{
|
||||
Task<IReadOnlyList<FacilityContact>> GetByFacilityIdAsync(Guid facilityId, CancellationToken cancellationToken = default);
|
||||
Task<FacilityContact?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<FacilityContact> CreateAsync(FacilityContact contact, CancellationToken cancellationToken = default);
|
||||
Task<FacilityContact?> UpdateAsync(Guid id, FacilityContact updates, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public interface IFacilityService
|
||||
{
|
||||
Task<IReadOnlyList<Facility>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<(IReadOnlyList<Facility> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
string? crmStatus,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<Facility?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<Facility> CreateAsync(Facility facility, CancellationToken cancellationToken = default);
|
||||
Task<Facility?> UpdateAsync(Guid id, Facility updates, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public interface IOrderService
|
||||
{
|
||||
Task<IReadOnlyList<Order>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<(IReadOnlyList<Order> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
Guid? statusId,
|
||||
Guid? facilityId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<Order?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<Order> CreateAsync(Order order, CancellationToken cancellationToken = default);
|
||||
Task<UpdateOrderResult> UpdateAsync(Guid id, Order updates, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ public interface IPasswordResetService
|
||||
|
||||
Task<PasswordResetVerifyResult> VerifyCodeAsync(string username, string pin, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> ResetPasswordAsync(string resetToken, string newPassword, CancellationToken cancellationToken = default);
|
||||
Task<ResetPasswordResult> ResetPasswordAsync(string resetToken, string newPassword, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Stellt einen PIN für eine Account-Einladung aus (admin-ausgelöst, kein Self-Service-Request des Users
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
@@ -6,4 +7,13 @@ public interface IRoleService
|
||||
{
|
||||
Task<IReadOnlyList<Role>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<CreateRoleResult> CreateAsync(string name, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Inklusive RolePermissions - für die Rechte-Matrix-Ansicht/-Bearbeitung einer Rolle.</summary>
|
||||
Task<Role?> GetByIdWithPermissionsAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Ersetzt die komplette RolePermission-Menge der Rolle durch die übergebene Menge (kein inkrementelles Patchen).</summary>
|
||||
Task<UpdateRolePermissionsResult> UpdatePermissionsAsync(
|
||||
Guid roleId,
|
||||
IReadOnlyList<(ModuleType Module, PermissionAction Action)> permissions,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
using OmsorgCore.Application.Models;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public interface IUserService
|
||||
@@ -13,7 +16,7 @@ public interface IUserService
|
||||
TimeSpan? invitePinValidity,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> ChangeOwnPasswordAsync(
|
||||
Task<ChangeOwnPasswordResult> ChangeOwnPasswordAsync(
|
||||
Guid userId,
|
||||
string currentPassword,
|
||||
string newPassword,
|
||||
@@ -33,4 +36,22 @@ public interface IUserService
|
||||
Guid roleId,
|
||||
bool isActive,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Individuelle Rechte-Ausnahmen eines Users (überschreiben den Rollen-Default, siehe PermissionService).</summary>
|
||||
Task<IReadOnlyList<PermissionOverrideSummary>?> GetPermissionOverridesAsync(
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Upsert: existiert bereits ein Override für (module, action) bei diesem User, wird dessen Effect aktualisiert statt dupliziert.</summary>
|
||||
Task<AddPermissionOverrideResult> AddPermissionOverrideAsync(
|
||||
Guid userId,
|
||||
ModuleType module,
|
||||
PermissionAction action,
|
||||
PermissionEffect effect,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RemovePermissionOverrideResult> RemovePermissionOverrideAsync(
|
||||
Guid userId,
|
||||
Guid overrideId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public interface IValueListService
|
||||
{
|
||||
Task<IReadOnlyList<ValueList>> GetListsAsync(CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<ValueListItem>> GetItemsAsync(string key, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ValueListItem> CreateItemAsync(string key, string value, int sortOrder, bool isDefault, bool isInitial, bool isTerminal, CancellationToken cancellationToken = default);
|
||||
Task<ValueListItem?> UpdateItemAsync(Guid id, string value, int sortOrder, bool isDefault, bool isInitial, bool isTerminal, CancellationToken cancellationToken = default);
|
||||
Task<DeleteValueListItemResult> DeleteItemAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<ValueListUsageEntry>> GetUsagesAsync(Guid id, 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);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class OrderService : IOrderService
|
||||
{
|
||||
private const string StatusListKey = "OrderStatus";
|
||||
|
||||
private readonly IOrderRepository _orderRepository;
|
||||
private readonly IValueListRepository _valueListRepository;
|
||||
|
||||
public OrderService(IOrderRepository orderRepository, IValueListRepository valueListRepository)
|
||||
{
|
||||
_orderRepository = orderRepository;
|
||||
_valueListRepository = valueListRepository;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Order>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
=> _orderRepository.GetAllAsync(cancellationToken);
|
||||
|
||||
public Task<(IReadOnlyList<Order> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
Guid? statusId,
|
||||
Guid? facilityId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> _orderRepository.GetPagedAsync(search, statusId, facilityId, page, pageSize, cancellationToken);
|
||||
|
||||
public Task<Order?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _orderRepository.GetByIdAsync(id, cancellationToken);
|
||||
|
||||
public async Task<Order> CreateAsync(Order order, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var initialStatus = await _valueListRepository.GetInitialItemAsync(StatusListKey, cancellationToken)
|
||||
?? throw new InvalidOperationException("Kein initialer Auftragsstatus konfiguriert (DbSeeder.SeedValueListsAsync fehlt).");
|
||||
order.StatusId = initialStatus.Id;
|
||||
|
||||
await _orderRepository.AddAsync(order, cancellationToken);
|
||||
await _orderRepository.SaveChangesAsync(cancellationToken);
|
||||
return order;
|
||||
}
|
||||
|
||||
public async Task<UpdateOrderResult> UpdateAsync(Guid id, Order updates, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var order = await _orderRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (order is null)
|
||||
{
|
||||
return UpdateOrderResult.Fail(UpdateOrderFailureReason.OrderNotFound);
|
||||
}
|
||||
|
||||
if (!await _valueListRepository.CanTransitionAsync(order.StatusId, updates.StatusId, cancellationToken))
|
||||
{
|
||||
return UpdateOrderResult.Fail(UpdateOrderFailureReason.InvalidStatusTransition);
|
||||
}
|
||||
|
||||
order.FacilityId = updates.FacilityId;
|
||||
order.FacilityContactId = updates.FacilityContactId;
|
||||
order.StartDate = updates.StartDate;
|
||||
order.EndDate = updates.EndDate;
|
||||
order.RequiredQualification = updates.RequiredQualification;
|
||||
order.ShiftType = updates.ShiftType;
|
||||
order.RequiredHeadcount = updates.RequiredHeadcount;
|
||||
order.Conditions = updates.Conditions;
|
||||
order.Priority = updates.Priority;
|
||||
order.StatusId = updates.StatusId;
|
||||
order.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _orderRepository.UpdateAsync(order, cancellationToken);
|
||||
await _orderRepository.SaveChangesAsync(cancellationToken);
|
||||
return UpdateOrderResult.Ok(order);
|
||||
}
|
||||
}
|
||||
@@ -10,19 +10,22 @@ public class PasswordResetService : IPasswordResetService
|
||||
private readonly IPasswordResetCodeGenerator _codeGenerator;
|
||||
private readonly IPasswordHasher _passwordHasher;
|
||||
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
||||
private readonly IPasswordPolicy _passwordPolicy;
|
||||
|
||||
public PasswordResetService(
|
||||
IUserRepository userRepository,
|
||||
IPasswordResetCodeRepository codeRepository,
|
||||
IPasswordResetCodeGenerator codeGenerator,
|
||||
IPasswordHasher passwordHasher,
|
||||
IRefreshTokenRepository refreshTokenRepository)
|
||||
IRefreshTokenRepository refreshTokenRepository,
|
||||
IPasswordPolicy passwordPolicy)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_codeRepository = codeRepository;
|
||||
_codeGenerator = codeGenerator;
|
||||
_passwordHasher = passwordHasher;
|
||||
_refreshTokenRepository = refreshTokenRepository;
|
||||
_passwordPolicy = passwordPolicy;
|
||||
}
|
||||
|
||||
public async Task<PasswordResetRequestResult> RequestResetAsync(string username, CancellationToken cancellationToken = default)
|
||||
@@ -111,13 +114,18 @@ public class PasswordResetService : IPasswordResetService
|
||||
return rawPin;
|
||||
}
|
||||
|
||||
public async Task<bool> ResetPasswordAsync(string resetToken, string newPassword, CancellationToken cancellationToken = default)
|
||||
public async Task<ResetPasswordResult> ResetPasswordAsync(string resetToken, string newPassword, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var resetTokenHash = _codeGenerator.HashResetToken(resetToken);
|
||||
var code = await _codeRepository.GetByResetTokenHashAsync(resetTokenHash, cancellationToken);
|
||||
if (code is null || !code.IsResetTokenActive)
|
||||
{
|
||||
return false;
|
||||
return ResetPasswordResult.InvalidOrExpiredToken;
|
||||
}
|
||||
|
||||
if (!_passwordPolicy.IsValid(newPassword))
|
||||
{
|
||||
return ResetPasswordResult.PasswordTooShort;
|
||||
}
|
||||
|
||||
code.ResetTokenUsedAt = DateTime.UtcNow;
|
||||
@@ -134,6 +142,6 @@ public class PasswordResetService : IPasswordResetService
|
||||
|
||||
await _codeRepository.SaveChangesAsync(cancellationToken);
|
||||
await _refreshTokenRepository.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
return ResetPasswordResult.Success;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public enum RemovePermissionOverrideFailureReason
|
||||
{
|
||||
UserNotFound,
|
||||
OverrideNotFound
|
||||
}
|
||||
|
||||
public class RemovePermissionOverrideResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public RemovePermissionOverrideFailureReason? FailureReason { get; init; }
|
||||
|
||||
public static RemovePermissionOverrideResult Fail(RemovePermissionOverrideFailureReason reason) => new()
|
||||
{
|
||||
Success = false,
|
||||
FailureReason = reason
|
||||
};
|
||||
|
||||
public static RemovePermissionOverrideResult Ok() => new() { Success = true };
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public enum ResetPasswordResult
|
||||
{
|
||||
Success,
|
||||
InvalidOrExpiredToken,
|
||||
PasswordTooShort
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
@@ -33,4 +34,29 @@ public class RoleService : IRoleService
|
||||
|
||||
return CreateRoleResult.Ok(role);
|
||||
}
|
||||
|
||||
public Task<Role?> GetByIdWithPermissionsAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _roleRepository.GetByIdWithPermissionsAsync(id, cancellationToken);
|
||||
|
||||
public async Task<UpdateRolePermissionsResult> UpdatePermissionsAsync(
|
||||
Guid roleId,
|
||||
IReadOnlyList<(ModuleType Module, PermissionAction Action)> permissions,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var role = await _roleRepository.GetByIdWithPermissionsAsync(roleId, cancellationToken);
|
||||
if (role is null)
|
||||
{
|
||||
return UpdateRolePermissionsResult.Fail(UpdateRolePermissionsFailureReason.RoleNotFound);
|
||||
}
|
||||
|
||||
role.RolePermissions.Clear();
|
||||
await _roleRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var newPermissions = permissions.Distinct()
|
||||
.Select(p => new RolePermission { RoleId = role.Id, Module = p.Module, Action = p.Action })
|
||||
.ToList();
|
||||
await _roleRepository.AddPermissionRangeAsync(newPermissions, cancellationToken);
|
||||
await _roleRepository.SaveChangesAsync(cancellationToken);
|
||||
return UpdateRolePermissionsResult.Ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public enum UpdateOrderFailureReason
|
||||
{
|
||||
OrderNotFound,
|
||||
InvalidStatusTransition
|
||||
}
|
||||
|
||||
public class UpdateOrderResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public UpdateOrderFailureReason? FailureReason { get; init; }
|
||||
public Order? Order { get; init; }
|
||||
|
||||
public static UpdateOrderResult Fail(UpdateOrderFailureReason reason) => new()
|
||||
{
|
||||
Success = false,
|
||||
FailureReason = reason
|
||||
};
|
||||
|
||||
public static UpdateOrderResult Ok(Order order) => new()
|
||||
{
|
||||
Success = true,
|
||||
Order = order
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public enum UpdateRolePermissionsFailureReason
|
||||
{
|
||||
RoleNotFound
|
||||
}
|
||||
|
||||
public class UpdateRolePermissionsResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public UpdateRolePermissionsFailureReason? FailureReason { get; init; }
|
||||
|
||||
public static UpdateRolePermissionsResult Fail(UpdateRolePermissionsFailureReason reason) => new()
|
||||
{
|
||||
Success = false,
|
||||
FailureReason = reason
|
||||
};
|
||||
|
||||
public static UpdateRolePermissionsResult Ok() => new() { Success = true };
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Application.Models;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
@@ -11,6 +13,7 @@ public class UserService : IUserService
|
||||
private readonly IPasswordHasher _passwordHasher;
|
||||
private readonly IPasswordResetService _passwordResetService;
|
||||
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
||||
private readonly IPasswordPolicy _passwordPolicy;
|
||||
|
||||
public UserService(
|
||||
IUserRepository userRepository,
|
||||
@@ -18,7 +21,8 @@ public class UserService : IUserService
|
||||
IRoleRepository roleRepository,
|
||||
IPasswordHasher passwordHasher,
|
||||
IPasswordResetService passwordResetService,
|
||||
IRefreshTokenRepository refreshTokenRepository)
|
||||
IRefreshTokenRepository refreshTokenRepository,
|
||||
IPasswordPolicy passwordPolicy)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_employeeRepository = employeeRepository;
|
||||
@@ -26,6 +30,7 @@ public class UserService : IUserService
|
||||
_passwordHasher = passwordHasher;
|
||||
_passwordResetService = passwordResetService;
|
||||
_refreshTokenRepository = refreshTokenRepository;
|
||||
_passwordPolicy = passwordPolicy;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UserSummary>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
@@ -73,6 +78,11 @@ public class UserService : IUserService
|
||||
return CreateUserResult.Fail(CreateUserFailureReason.InitialPasswordRequired);
|
||||
}
|
||||
|
||||
if (mode == UserCreationMode.Direct && !_passwordPolicy.IsValid(initialPassword))
|
||||
{
|
||||
return CreateUserResult.Fail(CreateUserFailureReason.PasswordTooShort);
|
||||
}
|
||||
|
||||
if (mode == UserCreationMode.Invite && string.IsNullOrWhiteSpace(employee.Email))
|
||||
{
|
||||
return CreateUserResult.Fail(CreateUserFailureReason.EmployeeEmailMissing);
|
||||
@@ -104,7 +114,7 @@ public class UserService : IUserService
|
||||
return CreateUserResult.Ok(user.Id, role.Name);
|
||||
}
|
||||
|
||||
public async Task<bool> ChangeOwnPasswordAsync(
|
||||
public async Task<ChangeOwnPasswordResult> ChangeOwnPasswordAsync(
|
||||
Guid userId,
|
||||
string currentPassword,
|
||||
string newPassword,
|
||||
@@ -113,7 +123,12 @@ public class UserService : IUserService
|
||||
var user = await _userRepository.GetByIdAsync(userId, cancellationToken);
|
||||
if (user is null || !_passwordHasher.Verify(currentPassword, user.PasswordHash))
|
||||
{
|
||||
return false;
|
||||
return ChangeOwnPasswordResult.InvalidCurrentPassword;
|
||||
}
|
||||
|
||||
if (!_passwordPolicy.IsValid(newPassword))
|
||||
{
|
||||
return ChangeOwnPasswordResult.PasswordTooShort;
|
||||
}
|
||||
|
||||
user.PasswordHash = _passwordHasher.Hash(newPassword);
|
||||
@@ -131,7 +146,7 @@ public class UserService : IUserService
|
||||
|
||||
await _userRepository.SaveChangesAsync(cancellationToken);
|
||||
await _refreshTokenRepository.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
return ChangeOwnPasswordResult.Success;
|
||||
}
|
||||
|
||||
public async Task<AdminResetPasswordResult> AdminResetPasswordAsync(
|
||||
@@ -152,6 +167,11 @@ public class UserService : IUserService
|
||||
return AdminResetPasswordResult.Fail(AdminResetPasswordFailureReason.InitialPasswordRequired);
|
||||
}
|
||||
|
||||
if (mode == UserCreationMode.Direct && !_passwordPolicy.IsValid(initialPassword))
|
||||
{
|
||||
return AdminResetPasswordResult.Fail(AdminResetPasswordFailureReason.PasswordTooShort);
|
||||
}
|
||||
|
||||
if (mode == UserCreationMode.Invite && string.IsNullOrWhiteSpace(user.Employee?.Email))
|
||||
{
|
||||
return AdminResetPasswordResult.Fail(AdminResetPasswordFailureReason.EmployeeEmailMissing);
|
||||
@@ -219,4 +239,68 @@ public class UserService : IUserService
|
||||
await _userRepository.SaveChangesAsync(cancellationToken);
|
||||
return UpdateUserResult.Ok();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PermissionOverrideSummary>?> GetPermissionOverridesAsync(
|
||||
Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await _userRepository.GetByIdWithPermissionsAsync(userId, cancellationToken);
|
||||
if (user is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return user.PermissionOverrides
|
||||
.Select(o => new PermissionOverrideSummary(o.Id, o.Module, o.Action, o.Effect))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<AddPermissionOverrideResult> AddPermissionOverrideAsync(
|
||||
Guid userId,
|
||||
ModuleType module,
|
||||
PermissionAction action,
|
||||
PermissionEffect effect,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await _userRepository.GetByIdWithPermissionsAsync(userId, cancellationToken);
|
||||
if (user is null)
|
||||
{
|
||||
return AddPermissionOverrideResult.Fail(AddPermissionOverrideFailureReason.UserNotFound);
|
||||
}
|
||||
|
||||
var existing = user.PermissionOverrides
|
||||
.FirstOrDefault(o => o.Module == module && o.Action == action);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.Effect = effect;
|
||||
}
|
||||
else
|
||||
{
|
||||
existing = new UserPermissionOverride { UserId = user.Id, Module = module, Action = action, Effect = effect };
|
||||
await _userRepository.AddPermissionOverrideAsync(existing, cancellationToken);
|
||||
}
|
||||
|
||||
await _userRepository.SaveChangesAsync(cancellationToken);
|
||||
return AddPermissionOverrideResult.Ok(new PermissionOverrideSummary(existing.Id, module, action, effect));
|
||||
}
|
||||
|
||||
public async Task<RemovePermissionOverrideResult> RemovePermissionOverrideAsync(
|
||||
Guid userId, Guid overrideId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await _userRepository.GetByIdWithPermissionsAsync(userId, cancellationToken);
|
||||
if (user is null)
|
||||
{
|
||||
return RemovePermissionOverrideResult.Fail(RemovePermissionOverrideFailureReason.UserNotFound);
|
||||
}
|
||||
|
||||
var existing = user.PermissionOverrides.FirstOrDefault(o => o.Id == overrideId);
|
||||
if (existing is null)
|
||||
{
|
||||
return RemovePermissionOverrideResult.Fail(RemovePermissionOverrideFailureReason.OverrideNotFound);
|
||||
}
|
||||
|
||||
user.PermissionOverrides.Remove(existing);
|
||||
await _userRepository.SaveChangesAsync(cancellationToken);
|
||||
return RemovePermissionOverrideResult.Ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class ValueListService : IValueListService
|
||||
{
|
||||
private readonly IValueListRepository _valueListRepository;
|
||||
private readonly IEnumerable<IValueListUsageChecker> _usageCheckers;
|
||||
|
||||
public ValueListService(IValueListRepository valueListRepository, IEnumerable<IValueListUsageChecker> usageCheckers)
|
||||
{
|
||||
_valueListRepository = valueListRepository;
|
||||
_usageCheckers = usageCheckers;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ValueList>> GetListsAsync(CancellationToken cancellationToken = default)
|
||||
=> _valueListRepository.GetAllListsAsync(cancellationToken);
|
||||
|
||||
public Task<IReadOnlyList<ValueListItem>> GetItemsAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> _valueListRepository.GetItemsAsync(key, cancellationToken);
|
||||
|
||||
public async Task<ValueListItem> CreateItemAsync(string key, string value, int sortOrder, bool isDefault, bool isInitial, bool isTerminal, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var list = await _valueListRepository.GetListByKeyAsync(key, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Unbekannte Auswahlliste '{key}'.");
|
||||
|
||||
var item = new ValueListItem
|
||||
{
|
||||
ValueListId = list.Id,
|
||||
Value = value,
|
||||
SortOrder = sortOrder,
|
||||
IsDefault = isDefault,
|
||||
IsInitial = isInitial,
|
||||
IsTerminal = isTerminal
|
||||
};
|
||||
|
||||
await _valueListRepository.AddItemAsync(item, cancellationToken);
|
||||
await _valueListRepository.SaveChangesAsync(cancellationToken);
|
||||
return item;
|
||||
}
|
||||
|
||||
public async Task<ValueListItem?> UpdateItemAsync(Guid id, string value, int sortOrder, bool isDefault, bool isInitial, bool isTerminal, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var item = await _valueListRepository.GetItemByIdAsync(id, cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
item.Value = value;
|
||||
item.SortOrder = sortOrder;
|
||||
item.IsDefault = isDefault;
|
||||
item.IsInitial = isInitial;
|
||||
item.IsTerminal = isTerminal;
|
||||
|
||||
await _valueListRepository.UpdateItemAsync(item, cancellationToken);
|
||||
await _valueListRepository.SaveChangesAsync(cancellationToken);
|
||||
return item;
|
||||
}
|
||||
|
||||
public async Task<DeleteValueListItemResult> DeleteItemAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var item = await _valueListRepository.GetItemByIdAsync(id, cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
return DeleteValueListItemResult.Fail(DeleteValueListItemFailureReason.ItemNotFound);
|
||||
}
|
||||
|
||||
var usages = await FindUsagesAsync(item, cancellationToken);
|
||||
if (usages.Count > 0)
|
||||
{
|
||||
return DeleteValueListItemResult.Fail(DeleteValueListItemFailureReason.InUse, usages);
|
||||
}
|
||||
|
||||
await _valueListRepository.RemoveItemAsync(item, cancellationToken);
|
||||
await _valueListRepository.SaveChangesAsync(cancellationToken);
|
||||
return DeleteValueListItemResult.Ok();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ValueListUsageEntry>> GetUsagesAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var item = await _valueListRepository.GetItemByIdAsync(id, cancellationToken);
|
||||
return item is null ? Array.Empty<ValueListUsageEntry>() : await FindUsagesAsync(item, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ValueListItemTransition>> GetTransitionsAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> _valueListRepository.GetTransitionsAsync(key, cancellationToken);
|
||||
|
||||
public async Task ReplaceTransitionsAsync(string key, IEnumerable<(Guid FromItemId, Guid ToItemId)> transitions, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _valueListRepository.ReplaceTransitionsAsync(key, transitions, cancellationToken);
|
||||
await _valueListRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ValueListUsageEntry>> FindUsagesAsync(ValueListItem item, CancellationToken cancellationToken)
|
||||
{
|
||||
var checker = _usageCheckers.FirstOrDefault(c => c.Key == item.ValueList.Key);
|
||||
return checker is null
|
||||
? Array.Empty<ValueListUsageEntry>()
|
||||
: await checker.FindUsagesAsync(item.Id, item.Value, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace OmsorgCore.Domain.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Markiert eine Property als sensibel - der SaveChanges-Interceptor nimmt ihren Feld-Diff zwar als
|
||||
/// "geändert" auf, ersetzt den tatsächlichen Wert im Audit-Log aber durch einen Platzhalter (kein
|
||||
/// Klartext-/Hash-Leck von Secrets in eine für Geschäftsführung lesbare Tabelle).
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Property)]
|
||||
public class AuditRedactedAttribute : Attribute
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using OmsorgCore.Domain.Common;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Ein Eintrag im generischen Audit-Log: "wer hat wann was verändert" (Entity-Changes, per
|
||||
/// SaveChanges-Interceptor automatisch erfasst) bzw. "wer hat wann was getan" (nicht-Entity-Aktionen
|
||||
/// wie Login/Logout/Session-Kill, per AuditEvent explizit gemeldet). Nicht zu verwechseln mit
|
||||
/// <see cref="LoginAttempt"/> (reine Brute-Force-Sperr-Historie).
|
||||
/// Erbt bewusst von <see cref="Entity"/>, nicht <see cref="AuditableEntity"/> - ein Audit-Eintrag ist
|
||||
/// selbst unveränderlich und braucht kein CreatedAt/UpdatedAt/Soft-Delete.
|
||||
/// </summary>
|
||||
public class AuditLogEntry : Entity
|
||||
{
|
||||
public DateTime OccurredAtUtc { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public Guid? ActorUserId { get; set; }
|
||||
|
||||
/// <summary>Snapshot, bleibt lesbar auch wenn der User später umbenannt oder gelöscht wird.</summary>
|
||||
public string? ActorUsername { get; set; }
|
||||
|
||||
public string? IpAddress { get; set; }
|
||||
|
||||
public AuditEventCategory Category { get; set; }
|
||||
|
||||
/// <summary>Z. B. "Created"/"Updated"/"Deleted"/"Login"/"LoginFailed"/"Logout"/"SessionRevoked"/"AllSessionsRevoked".</summary>
|
||||
public string Action { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Nur bei Category = EntityChange gesetzt, z. B. "Employee".</summary>
|
||||
public string? EntityType { get; set; }
|
||||
public Guid? EntityId { get; set; }
|
||||
|
||||
/// <summary>JSON: Feld-Diff bei EntityChange, freier Kontext bei BehavioralEvent.</summary>
|
||||
public string? Details { get; set; }
|
||||
}
|
||||
@@ -19,4 +19,11 @@ public class Contract : AuditableEntity
|
||||
public DateOnly StartDate { get; set; }
|
||||
public DateOnly? EndDate { get; set; }
|
||||
public string Status { get; set; } = "Entwurf";
|
||||
|
||||
public decimal? WeeklyHours { get; set; }
|
||||
public decimal? HourlyWage { get; set; }
|
||||
public string? AllowancesDescription { get; set; }
|
||||
public string? OvertimeRules { get; set; }
|
||||
public int? VacationDaysPerYear { get; set; }
|
||||
public int? ProbationPeriodMonths { get; set; }
|
||||
}
|
||||
|
||||
@@ -4,14 +4,24 @@ namespace OmsorgCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Core-Objekt "Einrichtung" — CRM-/Kundendatensatz (REQUIREMENTS.md Abschnitt 6, Blueprint 19.2).
|
||||
/// Adresse und Rechnungsadresse sind getrennte Adressen (z. B. Einsatzort vs. zentrale Buchhaltung
|
||||
/// eines Trägers), daher jeweils eigene strukturierte Felder statt eines gemeinsamen Freitextfelds.
|
||||
/// </summary>
|
||||
public class Facility : AuditableEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? FacilityType { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? BillingAddress { get; set; }
|
||||
public string? PhoneNumber { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Website { get; set; }
|
||||
|
||||
public string? Street { get; set; }
|
||||
public string? PostalCode { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? Country { get; set; }
|
||||
|
||||
public string? BillingStreet { get; set; }
|
||||
public string? BillingPostalCode { get; set; }
|
||||
public string? BillingCity { get; set; }
|
||||
public string? BillingCountry { get; set; }
|
||||
|
||||
public string CrmStatus { get; set; } = "Lead";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using OmsorgCore.Domain.Common;
|
||||
|
||||
namespace OmsorgCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Ansprechpartner einer Einrichtung (REQUIREMENTS.md FR-EIN-2, Blueprint 19.2) — 1:n-Beziehung
|
||||
/// zu <see cref="Facility"/>, da eine Einrichtung mehrere Ansprechpartner haben kann.
|
||||
/// </summary>
|
||||
public class FacilityContact : AuditableEntity
|
||||
{
|
||||
public Guid FacilityId { get; set; }
|
||||
public Facility Facility { get; set; } = null!;
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Role { get; set; }
|
||||
public string? Department { get; set; }
|
||||
public string? PhoneNumber { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
@@ -10,8 +10,17 @@ public class Order : AuditableEntity
|
||||
public Guid FacilityId { get; set; }
|
||||
public Facility Facility { get; set; } = null!;
|
||||
|
||||
public Guid? FacilityContactId { get; set; }
|
||||
public FacilityContact? FacilityContact { get; set; }
|
||||
|
||||
public DateOnly StartDate { get; set; }
|
||||
public DateOnly? EndDate { get; set; }
|
||||
public string? RequiredQualification { get; set; }
|
||||
public string Status { get; set; } = "Anfrage";
|
||||
public string? ShiftType { get; set; }
|
||||
public int RequiredHeadcount { get; set; } = 1;
|
||||
public string? Conditions { get; set; }
|
||||
public string Priority { get; set; } = "Normal";
|
||||
|
||||
public Guid StatusId { get; set; }
|
||||
public ValueListItem Status { get; set; } = null!;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ namespace OmsorgCore.Domain.Entities;
|
||||
public class User : AuditableEntity
|
||||
{
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
[AuditRedacted]
|
||||
public string PasswordHash { get; set; } = string.Empty;
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
@@ -17,6 +19,7 @@ public class User : AuditableEntity
|
||||
/// Wird als Claim in jeden Access-Token eingebettet und bei jedem Request dagegen geprüft -
|
||||
/// ein Wechsel invalidiert sofort alle bereits ausgestellten Access-Tokens dieses Users.
|
||||
/// </summary>
|
||||
[AuditRedacted]
|
||||
public Guid SecurityStamp { get; set; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using OmsorgCore.Domain.Common;
|
||||
|
||||
namespace OmsorgCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Eine admin-editierbare Auswahlliste (z. B. Mitarbeiterstatus, Beschäftigungsart, CRM-Status,
|
||||
/// Einrichtungstyp, Vertragstyp/-status, Auftragsstatus) — Stammdaten statt Enum/hartcodiertes
|
||||
/// Frontend-Array, damit sie über die "Status-Verwaltung" (Einstellungen) ohne Code-Deploy gepflegt
|
||||
/// werden kann. <see cref="Key"/> ist der stabile Bezeichner, über den Backend-Code (Validierung,
|
||||
/// Verwendungsprüfung) und Frontend (Dropdown-Ladeaufruf) auf eine bestimmte Liste verweisen.
|
||||
/// </summary>
|
||||
public class ValueList : Entity
|
||||
{
|
||||
public string Key { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
public ICollection<ValueListItem> Items { get; set; } = new List<ValueListItem>();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using OmsorgCore.Domain.Common;
|
||||
|
||||
namespace OmsorgCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Ein einzelner Wert innerhalb einer <see cref="ValueList"/> (z. B. "Aktiv" in der Liste
|
||||
/// "EmployeeStatus"). <see cref="IsInitial"/>/<see cref="IsTerminal"/> sind nur für Listen mit
|
||||
/// Übergangsregeln relevant (aktuell nur "OrderStatus", siehe <see cref="ValueListItemTransition"/>)
|
||||
/// und bei allen anderen Listen einfach <c>false</c>.
|
||||
/// </summary>
|
||||
public class ValueListItem : Entity
|
||||
{
|
||||
public Guid ValueListId { get; set; }
|
||||
public ValueList ValueList { get; set; } = null!;
|
||||
|
||||
public string Value { get; set; } = string.Empty;
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsDefault { get; set; }
|
||||
public bool IsInitial { get; set; }
|
||||
public bool IsTerminal { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using OmsorgCore.Domain.Common;
|
||||
|
||||
namespace OmsorgCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Ein erlaubter Übergang zwischen zwei <see cref="ValueListItem"/>n derselben Liste. Wird aktuell
|
||||
/// nur für die Liste "OrderStatus" befüllt (FR-EM-2) — die meisten Listen brauchen keine
|
||||
/// Übergangsregeln und lassen diese Tabelle für ihren <see cref="ValueList.Key"/> leer.
|
||||
/// </summary>
|
||||
public class ValueListItemTransition : Entity
|
||||
{
|
||||
public Guid FromItemId { get; set; }
|
||||
public ValueListItem FromItem { get; set; } = null!;
|
||||
|
||||
public Guid ToItemId { get; set; }
|
||||
public ValueListItem ToItem { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace OmsorgCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Unterscheidet automatisch vom SaveChanges-Interceptor erfasste Entity-Änderungen von explizit
|
||||
/// gemeldeten Verhaltens-Ereignissen ohne Entity-Änderung (Login, Logout, Session-Kill, ...).
|
||||
/// </summary>
|
||||
public enum AuditEventCategory
|
||||
{
|
||||
EntityChange,
|
||||
BehavioralEvent
|
||||
}
|
||||
@@ -13,5 +13,6 @@ public enum ModuleType
|
||||
Invoices,
|
||||
Recruiting,
|
||||
Controlling,
|
||||
UserManagement
|
||||
UserManagement,
|
||||
AuditLog
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user