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:
Felix Kemmler
2026-08-08 23:29:16 +02:00
co-authored by Claude Sonnet 5
parent ee74ed65f5
commit e9e96a57dc
528 changed files with 96814 additions and 456 deletions
+149
View File
@@ -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. 24 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
View File
@@ -41,7 +41,7 @@ Umfasst alle drei Plattform-Ebenen: OMSORG Desktop, OMSORG Connect, OMSORG Backe
|---|---|---|
| **OMSORG Desktop** | Vollständige Unternehmensmodule für Büromitarbeiter | 🔶 `omsorgapp/` — Electron/React, Release 0.1.1. Vorhanden: `HomePage`, `HomeStats`, `ContractWidget`, `EmployeesPage` mit Tabs/Detailpanel. Lokale JSON-DB (`~/Documents/Omsorg Business Controls Pro/database/omsorg-local-db.json`), noch keine SQLite/Server-Anbindung. |
| **OMSORG Connect** | Mobile/Web-App für Außendienst | ✅ `omsorgWeb/mitarbeiter-app/` — PHP/MySQL, produktiv als PWA (Manifest + Service Worker). Deckt bereits Zeiterfassung/Stundennachweis, Urlaub, Abwesenheit, Fortbildung, Dokumente, News, Benefits, Werben, Einsatzanweisung, Dienstplan, Bewertungen ab. |
| **OMSORG Backend (Core + Engine)** | Eine Backend-Komponente, zwei interne Schichten: **Datenschicht (Core)** — gemeinsame Datenbasis, 6 Objekte; **Event-Schicht (Engine)** — Ereignis→Aktion-Automatisierung ohne eigene UI, arbeitet auf denselben Objekten der Datenschicht | 🔶 `omsorgCore/` — Grundgerüst steht (C#/.NET 8, ASP.NET Core Controller, EF Core/PostgreSQL, JWT-Auth, Rollen+Permission-Override-Rechtesystem, In-Process-Event-Dispatcher). 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
View File
@@ -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.
+1
View File
@@ -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);
}
+31 -1
View File
@@ -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