Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f729e5ae48 | ||
|
|
9e2c7f92ff |
150
CLAUDE.md
150
CLAUDE.md
@@ -478,6 +478,118 @@ das Register ist eine Genehmigungsliste, kein vollständiges Antragslog
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Freigabe-Workflow (konfigurierbare Mehrfach-Genehmigung, 2026-08-31)
|
||||||
|
|
||||||
|
**Bewusste Abkehr von einer dokumentierten v1-Ausschlussentscheidung.**
|
||||||
|
Diese CLAUDE.md schloss "konfigurierbare Rollen mit eigener Oberfläche"
|
||||||
|
und "Workflow-Designer" ursprünglich explizit für v1 aus (siehe "Nicht
|
||||||
|
bauen (v1)" unten). Der Nutzer hat das am 2026-08-31 bewusst und nach
|
||||||
|
Rückfrage aufgehoben ("Wir sollten das System schon perfekt bauen! Daher
|
||||||
|
auch gerne komplex") — Auslöser war die Frage, ob ein Datenschutz-
|
||||||
|
beauftragter und/oder die Geschäftsführung zusätzlich in die Entscheidung
|
||||||
|
eingebunden werden müssen. Statt DSB/GF fest zu verdrahten, können
|
||||||
|
Mandanten jetzt selbst definieren, wer bei welcher abgeleiteten
|
||||||
|
Bedingung zusätzlich zustimmen muss.
|
||||||
|
|
||||||
|
**Datenmodell (Migration 0017):** vier neue Tabellen, orthogonal zu den
|
||||||
|
fünf bestehenden Zugriffsrollen (`mitarbeiter`/`verantwortlicher`/
|
||||||
|
`pruefer`/`admin`/`betreiber` bleiben unverändert):
|
||||||
|
|
||||||
|
- `genehmiger_rolle` — eine mandantendefinierte Freigabe-Funktion (z. B.
|
||||||
|
"Datenschutzbeauftragter", "Geschäftsführung"). Kein Zugriffsrecht,
|
||||||
|
nur eine Freigabe-Zuständigkeit.
|
||||||
|
- `nutzer_genehmiger_rolle` — Zuordnung Person↔Genehmiger-Rolle,
|
||||||
|
many-to-many. Eine Person kann null, eine oder mehrere davon haben,
|
||||||
|
unabhängig von ihrer `app_user.role`.
|
||||||
|
- `freigabe_regel` — "wenn Bedingung X zutrifft, ist zusätzlich eine
|
||||||
|
Freigabe durch Genehmiger-Rolle Y nötig". Die Bedingung ist bewusst
|
||||||
|
**kein freier Regeleditor**, sondern auf die drei Vokabulare
|
||||||
|
beschränkt, die die Bewertungslogik ohnehin schon berechnet:
|
||||||
|
Anforderungs-ID (z. B. `dsfa_erforderlich`), Einstufungs-ID (z. B.
|
||||||
|
`hochrisiko`) oder Datenklasse-ID (z. B. `besondere_kategorie`).
|
||||||
|
Kompromiss zwischen "konfigurierbar" (Nutzeranforderung) und
|
||||||
|
"buildbar/auditierbar" (jede mögliche Bedingung ist bereits eine
|
||||||
|
geprüfte, bekannte Ableitung, keine beliebige Freitext-Regel).
|
||||||
|
- `freigabeschritt` — pro Antrag ein Datensatz je ausgelöster
|
||||||
|
Freigabe-Regel, Status `ausstehend`/`genehmigt`/`abgelehnt`.
|
||||||
|
|
||||||
|
`antrag.status` hat einen neuen Zwischenwert `wartet_auf_freigabe`
|
||||||
|
zwischen `eingereicht` und `entschieden` (CHECK-Constraint erweitert).
|
||||||
|
|
||||||
|
**Ablauf:** `handleFallEntscheiden` (`internal/web/fachebene_handlers.go`)
|
||||||
|
prüft nach einer Genehmigung/Genehmigung-mit-Auflagen, ob die Bewertung
|
||||||
|
mindestens eine `freigabe_regel` des Mandanten auslöst
|
||||||
|
(`triggeredGenehmigerRollen`). Falls ja: `antrag.status` wird
|
||||||
|
`wartet_auf_freigabe` statt `entschieden`, pro ausgelöster Regel entsteht
|
||||||
|
ein `freigabeschritt`, **kein** `registereintrag` entsteht noch. Falls
|
||||||
|
keine Regel greift, läuft exakt der alte Pfad (`registriereGenehmigung`)
|
||||||
|
— **verifiziert per dediziertem Test**
|
||||||
|
(`TestOhneFreigabeRegelnVerhaeltSichWieVorher`), da alle heutigen
|
||||||
|
Bestandskunden null Freigabe-Regeln haben und sich für sie nichts ändern
|
||||||
|
darf.
|
||||||
|
|
||||||
|
**Bestätigte Semantik (2026-08-31, Nutzer: "Beides stimmt!"):**
|
||||||
|
1. **Alle** ausgelösten Genehmiger-Rollen müssen zustimmen, nicht nur
|
||||||
|
eine von mehreren — geprüft durch erneutes `ListFreigabeschritteForAntrag`
|
||||||
|
nach jeder Einzelentscheidung.
|
||||||
|
2. **Eine Ablehnung kippt den gesamten Antrag**, geht nicht zurück an
|
||||||
|
die Fachebene zur Neuentscheidung. `KaskadiereAblehnung` setzt dabei
|
||||||
|
automatisch alle anderen noch `ausstehend`en Freigabeschritte
|
||||||
|
desselben Antrags ebenfalls auf `abgelehnt` — sonst würde ein
|
||||||
|
Freigabeschritt in der Liste einer anderen Person ewig unbearbeitet
|
||||||
|
hängen bleiben, obwohl der Antrag längst entschieden ist.
|
||||||
|
|
||||||
|
Eine Ablehnung (ob direkt oder per Kaskade) mutiert **nicht** die
|
||||||
|
ursprüngliche `genehmigt`-Zeile — `entscheidung` bleibt append-only wie
|
||||||
|
bisher. Stattdessen entsteht eine neue `entscheidung`-Zeile mit
|
||||||
|
`Entscheidung: "abgelehnt"`; `GetLatestEntscheidungForAntrag` (bereits
|
||||||
|
bestehender "letzte Zeile gewinnt"-Helfer) zeigt den Override überall
|
||||||
|
dort, wo er ohnehin schon verwendet wird — keine weitere Codeänderung
|
||||||
|
nötig, um die Überschreibung sichtbar zu machen.
|
||||||
|
|
||||||
|
**Frontend** (`internal/web/freigabe_handlers.go` + drei neue Templates):
|
||||||
|
`GET/POST /verwaltung/genehmiger-rollen` (Admin: Rolle anlegen, Mitglieder
|
||||||
|
zuordnen/entfernen — Formular nutzt einen einzelnen `<select>` mit drei
|
||||||
|
`<optgroup>`s für die drei Bedingungstypen, kein abhängiges Dropdown,
|
||||||
|
kein JavaScript), `GET/POST /verwaltung/freigabe-regeln` (Admin: Regel
|
||||||
|
anlegen/löschen), `GET /freigaben` + `POST /freigaben/{id}/entscheiden`
|
||||||
|
(jeder eingeloggte Nicht-Betreiber mit zugeordneter Genehmiger-Rolle:
|
||||||
|
eigene offene Freigaben sehen und entscheiden, mit Pflicht-Kommentarfeld
|
||||||
|
bei Ablehnung). Fall-Detail (`fall_detail.html`) zeigt die
|
||||||
|
Freigabeschritt-Historie mit Ampel-Badge **unabhängig vom aktuellen
|
||||||
|
Antragsstatus** — anfangs fälschlich auf `wartet_auf_freigabe`
|
||||||
|
beschränkt gebaut, beim Live-Verifizieren aufgefallen: sobald der letzte
|
||||||
|
Freigebende zustimmt und der Antrag auf `entschieden` springt, verschwand
|
||||||
|
die komplette Freigabehistorie aus der Ansicht, was der Grundregel
|
||||||
|
widerspricht, dass eine Entscheidung nie ohne vollständige Herleitung
|
||||||
|
gezeigt wird. Der Hinweistext "Genehmigung erst endgültig, wenn alle
|
||||||
|
erteilt sind" erscheint entsprechend nur noch, solange der Antrag
|
||||||
|
tatsächlich noch auf Freigabe wartet.
|
||||||
|
|
||||||
|
**CSS-Bugfix, beim Live-Verifizieren gefunden (sitesweit, nicht nur
|
||||||
|
diese Seite betreffend):** `.beitraege-liste > li:not(:has(> a)) form`
|
||||||
|
hatte `flex: 0 0 auto` (kein Schrumpfen). Bei den bisherigen Listen
|
||||||
|
(Nutzer, Abteilungen) enthielten diese eingebetteten Formulare nur
|
||||||
|
Buttons, die naturgemäß schmal bleiben — die Genehmiger-Rollen-Seite ist
|
||||||
|
die erste mit einem `<select>` darin, dessen Browser-Eigenbreite
|
||||||
|
(bestimmt durch die längste Options-Beschriftung, hier E-Mail-Adressen)
|
||||||
|
den ganzen Zeilen-Container über die Mobile-Viewport-Breite hinaustrieb
|
||||||
|
(horizontales Scrollen der ganzen Seite). Fix: `max-width: 100%` auf
|
||||||
|
derselben Regel ergänzt — generisch, wirkt auf jedes künftige
|
||||||
|
`<select>` in diesem Listen-Pattern, nicht nur hier.
|
||||||
|
|
||||||
|
**Live end-to-end verifiziert** (curl gegen echten lokalen Server +
|
||||||
|
echtes Postgres, nicht nur die Go-Testsuite): Happy Path (eine Regel,
|
||||||
|
eine Rolle, Genehmigung → `wartet_auf_freigabe` → Freigabe erteilt →
|
||||||
|
`entschieden` + 1 Registereintrag) und Kaskaden-Ablehnung (zwei Regeln,
|
||||||
|
zwei Rollen, eine lehnt ab → beide Freigabeschritte `abgelehnt`, Antrag
|
||||||
|
direkt `entschieden` als `abgelehnt`, 0 Registereinträge) — beide exakt
|
||||||
|
wie spezifiziert. Chromium-Headless-Screenshots (Desktop 1440×900 +
|
||||||
|
Mobile 390×844) aller vier neuen/geänderten Seiten bestätigen zusätzlich
|
||||||
|
das responsive Layout nach dem enconf-Card-Pattern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Onboarding
|
## Onboarding
|
||||||
|
|
||||||
**Firma (Ebene 1, öffentlich, `POST /register`):** Registrierungsformular
|
**Firma (Ebene 1, öffentlich, `POST /register`):** Registrierungsformular
|
||||||
@@ -660,10 +772,24 @@ Behoben durch explizites `flex-direction: row` in `.filter-bar` selbst
|
|||||||
— jetzt unabhängig davon robust, ob die Klasse auf einem `<form>` oder
|
— jetzt unabhängig davon robust, ob die Klasse auf einem `<form>` oder
|
||||||
einem `<div>` sitzt.
|
einem `<div>` sitzt.
|
||||||
|
|
||||||
Noch nicht übertragen: Betreiber-Accounts, Betreiber-Audit-Log,
|
**Ebenfalls ausgerollt (2026-08-31, zweite Runde):** Betreiber-Accounts
|
||||||
Wiedervorlage, "Meine Anträge" — nächste Kandidaten, falls das Muster
|
(`.page-header` + Filter über Firmenname + Paginierung), Betreiber-
|
||||||
fortgesetzt werden soll (Audit-Log wird langfristig am ehesten groß
|
Audit-Log (`.page-header` + Filter über Aktion/Zieltyp/Details +
|
||||||
genug für Paginierung).
|
Paginierung), "Meine Anträge" (`.page-header` + Filter über Titel/
|
||||||
|
Status/Häufigkeit + Paginierung), Wiedervorlage (nur `.page-header`,
|
||||||
|
bewusst ohne Paginierung — natürlich begrenzt auf "braucht gerade
|
||||||
|
Aufmerksamkeit").
|
||||||
|
|
||||||
|
**Bekannte Einschränkung beim Audit-Log:** `ListAuditLog` holt weiterhin
|
||||||
|
nur die neuesten `auditLogFetchLimit` (1000) Einträge aus der DB, dann
|
||||||
|
wird in Go gefiltert/paginiert (`auditLogFetchLimit`-Konstante in
|
||||||
|
`betreiber_handlers.go`) — keine echte DB-seitige Offset-Paginierung.
|
||||||
|
Für ein potenziell sehr groß werdendes, append-only Audit-Log ist das
|
||||||
|
mittelfristig nicht die saubere Lösung (Einträge jenseits der 1000
|
||||||
|
neuesten sind über die Such-/Paginierungs-UI nicht erreichbar), aber
|
||||||
|
proportional zum aktuellen Datenvolumen und konsistent mit dem
|
||||||
|
In-Memory-Paginierungsmuster der übrigen Listen. Bei echtem Bedarf:
|
||||||
|
`store.ListAuditLog` um `offset`/echtes SQL-`LIMIT`/`OFFSET` erweitern.
|
||||||
|
|
||||||
**Verifikationsmethode für CSS-Änderungen:** Live-Cookie-Auth per
|
**Verifikationsmethode für CSS-Änderungen:** Live-Cookie-Auth per
|
||||||
Chromium-Headless/CDP ist im Sandbox-Environment nicht möglich (kein
|
Chromium-Headless/CDP ist im Sandbox-Environment nicht möglich (kein
|
||||||
@@ -804,12 +930,22 @@ wiederverwendet aus dem alten Produkt), ebenso `bewertung`,
|
|||||||
(`GET /wiedervorlage`: abgelaufene/bald ablaufende Genehmigungen und
|
(`GET /wiedervorlage`: abgelaufene/bald ablaufende Genehmigungen und
|
||||||
Genehmigungen, deren Werkzeug sich seither im Katalog geändert hat
|
Genehmigungen, deren Werkzeug sich seither im Katalog geändert hat
|
||||||
oder entfernt wurde. In-App-Liste, kein E-Mail-Versand.)
|
oder entfernt wurde. In-App-Liste, kein E-Mail-Versand.)
|
||||||
|
8. ~~Konfigurierbarer Freigabe-Workflow (Mehrfach-Genehmigung)~~ —
|
||||||
|
**erledigt** (siehe "Freigabe-Workflow" weiter oben: Genehmiger-Rollen,
|
||||||
|
Freigabe-Regeln, Freigabeschritte, Migration 0017 — bewusste Abkehr
|
||||||
|
von der ursprünglichen v1-Ausschlussentscheidung, siehe "Nicht bauen
|
||||||
|
(v1)" unten).
|
||||||
|
|
||||||
Nicht bauen (v1): automatische Genehmigung ohne Mensch, Erkennung
|
Nicht bauen (v1): automatische Genehmigung ohne Mensch, Erkennung
|
||||||
tatsächlicher Werkzeug-Nutzung, Mitarbeiterüberwachung (nichts, was
|
tatsächlicher Werkzeug-Nutzung, Mitarbeiterüberwachung (nichts, was
|
||||||
Nutzung einzelner Personen auswertet), konfigurierbare Rollen mit
|
Nutzung einzelner Personen auswertet), Konzernstrukturen mit Vererbung,
|
||||||
eigener Oberfläche, Konzernstrukturen mit Vererbung, mandantenspezifische
|
mandantenspezifische Regelwerke, SAML, Schnittstellen zu Fremdsystemen.
|
||||||
Regelwerke, Workflow-Designer, SAML, Schnittstellen zu Fremdsystemen.
|
|
||||||
|
~~konfigurierbare Rollen mit eigener Oberfläche~~ / ~~Workflow-Designer~~
|
||||||
|
— **am 2026-08-31 bewusst aufgehoben**, siehe "Freigabe-Workflow"
|
||||||
|
weiter oben: Genehmiger-Rollen und Freigabe-Regeln sind jetzt genau das,
|
||||||
|
nur mit der Bedingung auf die drei bereits von der Bewertungslogik
|
||||||
|
berechneten Vokabulare beschränkt (kein beliebiger Regeleditor).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
385
internal/store/freigabe.go
Normal file
385
internal/store/freigabe.go
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
// Konfigurierbarer Mehrfach-Freigabe-Workflow — siehe Migration 0017
|
||||||
|
// und CLAUDE.md. GenehmigerRolle ist eine reine Freigabe-Funktion
|
||||||
|
// (z. B. "Datenschutzbeauftragter", "Geschäftsführung"), unabhängig von
|
||||||
|
// app_user.role (Zugriffskontrolle) — eine Person kann beides zugleich
|
||||||
|
// sein oder keines von beidem.
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GenehmigerRolle struct {
|
||||||
|
ID string
|
||||||
|
AccountID string
|
||||||
|
Name string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateGenehmigerRolle legt eine neue Freigabe-Funktion für einen
|
||||||
|
// Mandanten an.
|
||||||
|
func (s *Store) CreateGenehmigerRolle(ctx context.Context, accountID, name string) (GenehmigerRolle, error) {
|
||||||
|
var g GenehmigerRolle
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO genehmiger_rolle (account_id, name) VALUES ($1, $2)
|
||||||
|
RETURNING id, account_id, name, created_at
|
||||||
|
`, accountID, name).Scan(&g.ID, &g.AccountID, &g.Name, &g.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return GenehmigerRolle{}, fmt.Errorf("store: create genehmiger rolle: %w", err)
|
||||||
|
}
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGenehmigerRolle liest eine Genehmiger-Rolle anhand ihrer ID.
|
||||||
|
func (s *Store) GetGenehmigerRolle(ctx context.Context, id string) (GenehmigerRolle, error) {
|
||||||
|
var g GenehmigerRolle
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, account_id, name, created_at FROM genehmiger_rolle WHERE id = $1
|
||||||
|
`, id).Scan(&g.ID, &g.AccountID, &g.Name, &g.CreatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return GenehmigerRolle{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return GenehmigerRolle{}, fmt.Errorf("store: get genehmiger rolle: %w", err)
|
||||||
|
}
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGenehmigerRollenForAccount liefert alle Genehmiger-Rollen eines
|
||||||
|
// Mandanten.
|
||||||
|
func (s *Store) ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]GenehmigerRolle, error) {
|
||||||
|
rows, err := s.Pool.Query(ctx, `
|
||||||
|
SELECT id, account_id, name, created_at FROM genehmiger_rolle
|
||||||
|
WHERE account_id = $1 ORDER BY name
|
||||||
|
`, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list genehmiger rollen: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []GenehmigerRolle
|
||||||
|
for rows.Next() {
|
||||||
|
var g GenehmigerRolle
|
||||||
|
if err := rows.Scan(&g.ID, &g.AccountID, &g.Name, &g.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan genehmiger rolle: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, g)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteGenehmigerRolle entfernt eine Genehmiger-Rolle. Zuordnungen
|
||||||
|
// (nutzer_genehmiger_rolle) und darauf verweisende Freigabe-Regeln
|
||||||
|
// hängen per Fremdschlüssel daran — der Aufrufer muss sie vorher
|
||||||
|
// entfernen, sonst schlägt das DELETE fehl (bewusst kein CASCADE: ein
|
||||||
|
// stillschweigendes Mit-Löschen von Freigabe-Regeln wäre überraschend).
|
||||||
|
func (s *Store) DeleteGenehmigerRolle(ctx context.Context, id string) error {
|
||||||
|
tag, err := s.Pool.Exec(ctx, `DELETE FROM genehmiger_rolle WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: delete genehmiger rolle: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddNutzerGenehmigerRolle weist einer Person eine Genehmiger-Rolle zu.
|
||||||
|
func (s *Store) AddNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
|
||||||
|
_, err := s.Pool.Exec(ctx, `
|
||||||
|
INSERT INTO nutzer_genehmiger_rolle (app_user_id, genehmiger_rolle_id) VALUES ($1, $2)
|
||||||
|
ON CONFLICT (app_user_id, genehmiger_rolle_id) DO NOTHING
|
||||||
|
`, userID, genehmigerRolleID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: add nutzer genehmiger rolle: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveNutzerGenehmigerRolle entzieht einer Person eine Genehmiger-Rolle.
|
||||||
|
func (s *Store) RemoveNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
|
||||||
|
_, err := s.Pool.Exec(ctx, `
|
||||||
|
DELETE FROM nutzer_genehmiger_rolle WHERE app_user_id = $1 AND genehmiger_rolle_id = $2
|
||||||
|
`, userID, genehmigerRolleID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: remove nutzer genehmiger rolle: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGenehmigerRollenForUser liefert alle Genehmiger-Rollen einer
|
||||||
|
// Person — Grundlage dafür, ob und was sie unter "Meine Freigaben" sieht.
|
||||||
|
func (s *Store) ListGenehmigerRollenForUser(ctx context.Context, userID string) ([]GenehmigerRolle, error) {
|
||||||
|
rows, err := s.Pool.Query(ctx, `
|
||||||
|
SELECT gr.id, gr.account_id, gr.name, gr.created_at
|
||||||
|
FROM genehmiger_rolle gr
|
||||||
|
JOIN nutzer_genehmiger_rolle ngr ON ngr.genehmiger_rolle_id = gr.id
|
||||||
|
WHERE ngr.app_user_id = $1
|
||||||
|
ORDER BY gr.name
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list genehmiger rollen for user: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []GenehmigerRolle
|
||||||
|
for rows.Next() {
|
||||||
|
var g GenehmigerRolle
|
||||||
|
if err := rows.Scan(&g.ID, &g.AccountID, &g.Name, &g.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan genehmiger rolle: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, g)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenehmigerMitglied ist ein Nutzer, der eine bestimmte Genehmiger-Rolle
|
||||||
|
// innehat — für die Anzeige "wer hat diese Rolle" auf der Verwaltungsseite.
|
||||||
|
type GenehmigerMitglied struct {
|
||||||
|
UserID string
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListNutzerForGenehmigerRolle liefert alle Personen mit einer
|
||||||
|
// bestimmten Genehmiger-Rolle.
|
||||||
|
func (s *Store) ListNutzerForGenehmigerRolle(ctx context.Context, genehmigerRolleID string) ([]GenehmigerMitglied, error) {
|
||||||
|
rows, err := s.Pool.Query(ctx, `
|
||||||
|
SELECT u.id, u.email
|
||||||
|
FROM app_user u
|
||||||
|
JOIN nutzer_genehmiger_rolle ngr ON ngr.app_user_id = u.id
|
||||||
|
WHERE ngr.genehmiger_rolle_id = $1
|
||||||
|
ORDER BY u.email
|
||||||
|
`, genehmigerRolleID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list nutzer for genehmiger rolle: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []GenehmigerMitglied
|
||||||
|
for rows.Next() {
|
||||||
|
var m GenehmigerMitglied
|
||||||
|
if err := rows.Scan(&m.UserID, &m.Email); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan genehmiger mitglied: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FreigabeRegel: "wenn Bedingung X zutrifft, ist zusätzlich eine
|
||||||
|
// Freigabe durch GenehmigerRolleID nötig." BedingungTyp ist eine von
|
||||||
|
// "anforderung"/"einstufung"/"datenklasse", BedingungWert die jeweilige
|
||||||
|
// ID aus dem Regelwerk (z. B. "dsfa_erforderlich", "hochrisiko").
|
||||||
|
type FreigabeRegel struct {
|
||||||
|
ID string
|
||||||
|
AccountID string
|
||||||
|
BedingungTyp string
|
||||||
|
BedingungWert string
|
||||||
|
GenehmigerRolleID string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateFreigabeRegel(ctx context.Context, accountID, bedingungTyp, bedingungWert, genehmigerRolleID string) (FreigabeRegel, error) {
|
||||||
|
var f FreigabeRegel
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO freigabe_regel (account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
|
||||||
|
`, accountID, bedingungTyp, bedingungWert, genehmigerRolleID).Scan(
|
||||||
|
&f.ID, &f.AccountID, &f.BedingungTyp, &f.BedingungWert, &f.GenehmigerRolleID, &f.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return FreigabeRegel{}, fmt.Errorf("store: create freigabe regel: %w", err)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListFreigabeRegelnForAccount(ctx context.Context, accountID string) ([]FreigabeRegel, error) {
|
||||||
|
rows, err := s.Pool.Query(ctx, `
|
||||||
|
SELECT id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
|
||||||
|
FROM freigabe_regel WHERE account_id = $1 ORDER BY created_at
|
||||||
|
`, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list freigabe regeln: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []FreigabeRegel
|
||||||
|
for rows.Next() {
|
||||||
|
var f FreigabeRegel
|
||||||
|
if err := rows.Scan(&f.ID, &f.AccountID, &f.BedingungTyp, &f.BedingungWert, &f.GenehmigerRolleID, &f.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan freigabe regel: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteFreigabeRegel(ctx context.Context, id string) error {
|
||||||
|
tag, err := s.Pool.Exec(ctx, `DELETE FROM freigabe_regel WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: delete freigabe regel: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFreigabeRegel liest eine einzelne Freigabe-Regel — genutzt, um
|
||||||
|
// beim Löschen den Mandanten gegen den angemeldeten Account zu prüfen.
|
||||||
|
func (s *Store) GetFreigabeRegel(ctx context.Context, id string) (FreigabeRegel, error) {
|
||||||
|
var f FreigabeRegel
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
|
||||||
|
FROM freigabe_regel WHERE id = $1
|
||||||
|
`, id).Scan(&f.ID, &f.AccountID, &f.BedingungTyp, &f.BedingungWert, &f.GenehmigerRolleID, &f.CreatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return FreigabeRegel{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return FreigabeRegel{}, fmt.Errorf("store: get freigabe regel: %w", err)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Freigabeschritt ist eine einzelne, durch eine FreigabeRegel
|
||||||
|
// ausgelöste Freigabe-Aufgabe für einen konkreten Antrag. Nicht
|
||||||
|
// append-only — ein Schritt geht von "ausstehend" in genau einen
|
||||||
|
// Endzustand über, keine Historie mehrerer Entscheidungen zum selben
|
||||||
|
// Schritt.
|
||||||
|
type Freigabeschritt struct {
|
||||||
|
ID string
|
||||||
|
AntragID string
|
||||||
|
GenehmigerRolleID string
|
||||||
|
Status string // ausstehend/genehmigt/abgelehnt
|
||||||
|
EntschiedenVon *string
|
||||||
|
EntschiedenAm *time.Time
|
||||||
|
Kommentar string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateFreigabeschritt(ctx context.Context, antragID, genehmigerRolleID string) (Freigabeschritt, error) {
|
||||||
|
var f Freigabeschritt
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO freigabeschritt (antrag_id, genehmiger_rolle_id) VALUES ($1, $2)
|
||||||
|
RETURNING id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||||
|
`, antragID, genehmigerRolleID).Scan(
|
||||||
|
&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return Freigabeschritt{}, fmt.Errorf("store: create freigabeschritt: %w", err)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetFreigabeschritt(ctx context.Context, id string) (Freigabeschritt, error) {
|
||||||
|
var f Freigabeschritt
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||||
|
FROM freigabeschritt WHERE id = $1
|
||||||
|
`, id).Scan(&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Freigabeschritt{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Freigabeschritt{}, fmt.Errorf("store: get freigabeschritt: %w", err)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListFreigabeschritteForAntrag(ctx context.Context, antragID string) ([]Freigabeschritt, error) {
|
||||||
|
rows, err := s.Pool.Query(ctx, `
|
||||||
|
SELECT id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||||
|
FROM freigabeschritt WHERE antrag_id = $1 ORDER BY created_at
|
||||||
|
`, antragID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list freigabeschritte for antrag: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Freigabeschritt
|
||||||
|
for rows.Next() {
|
||||||
|
var f Freigabeschritt
|
||||||
|
if err := rows.Scan(&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan freigabeschritt: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAusstehendeFreigabeschritteForUser liefert alle offenen
|
||||||
|
// Freigabeschritte über JEDE Genehmiger-Rolle, die diese Person
|
||||||
|
// innehat — Grundlage für "Meine Freigaben".
|
||||||
|
func (s *Store) ListAusstehendeFreigabeschritteForUser(ctx context.Context, userID string) ([]Freigabeschritt, error) {
|
||||||
|
rows, err := s.Pool.Query(ctx, `
|
||||||
|
SELECT f.id, f.antrag_id, f.genehmiger_rolle_id, f.status, f.entschieden_von, f.entschieden_am, f.kommentar, f.created_at
|
||||||
|
FROM freigabeschritt f
|
||||||
|
JOIN nutzer_genehmiger_rolle ngr ON ngr.genehmiger_rolle_id = f.genehmiger_rolle_id
|
||||||
|
WHERE ngr.app_user_id = $1 AND f.status = 'ausstehend'
|
||||||
|
ORDER BY f.created_at
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list ausstehende freigabeschritte: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Freigabeschritt
|
||||||
|
for rows.Next() {
|
||||||
|
var f Freigabeschritt
|
||||||
|
if err := rows.Scan(&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan freigabeschritt: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// EntscheideFreigabeschritt trägt die Entscheidung einer Person zu
|
||||||
|
// einem einzelnen Freigabeschritt ein. Nur von "ausstehend" aus
|
||||||
|
// erlaubt — der Aufrufer prüft das vorher (ErrNotFound bei bereits
|
||||||
|
// entschiedenen Schritten wäre irreführend, deshalb hier ein
|
||||||
|
// generischer Fehler statt eines Sentinels).
|
||||||
|
func (s *Store) EntscheideFreigabeschritt(ctx context.Context, id, status, entschiedenVon, kommentar string) (Freigabeschritt, error) {
|
||||||
|
var f Freigabeschritt
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
UPDATE freigabeschritt
|
||||||
|
SET status = $2, entschieden_von = $3, entschieden_am = now(), kommentar = $4
|
||||||
|
WHERE id = $1 AND status = 'ausstehend'
|
||||||
|
RETURNING id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
|
||||||
|
`, id, status, entschiedenVon, kommentar).Scan(
|
||||||
|
&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt,
|
||||||
|
)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Freigabeschritt{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Freigabeschritt{}, fmt.Errorf("store: entscheide freigabeschritt: %w", err)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// KaskadiereAblehnung lehnt alle noch ausstehenden Freigabeschritte
|
||||||
|
// eines Antrags automatisch ab, nachdem ein anderer Schritt desselben
|
||||||
|
// Antrags bereits abgelehnt wurde — eine einzelne Ablehnung kippt den
|
||||||
|
// gesamten Antrag, die übrigen offenen Freigaben werden dadurch
|
||||||
|
// gegenstandslos statt für immer in fremden "Meine Freigaben"-Listen
|
||||||
|
// hängen zu bleiben.
|
||||||
|
func (s *Store) KaskadiereAblehnung(ctx context.Context, antragID, ausloesenderSchrittID string) error {
|
||||||
|
_, err := s.Pool.Exec(ctx, `
|
||||||
|
UPDATE freigabeschritt
|
||||||
|
SET status = 'abgelehnt', entschieden_am = now(),
|
||||||
|
kommentar = 'Automatisch abgelehnt, da eine andere erforderliche Freigabe für diesen Antrag abgelehnt wurde.'
|
||||||
|
WHERE antrag_id = $1 AND status = 'ausstehend' AND id != $2
|
||||||
|
`, antragID, ausloesenderSchrittID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: kaskadiere ablehnung: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
174
internal/store/freigabe_test.go
Normal file
174
internal/store/freigabe_test.go
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenehmigerRolleCRUDAndMitgliedschaft(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
|
||||||
|
g, err := s.CreateGenehmigerRolle(ctx, accID, "Datenschutzbeauftragter")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateGenehmigerRolle: %v", err)
|
||||||
|
}
|
||||||
|
if g.AccountID != accID || g.Name != "Datenschutzbeauftragter" {
|
||||||
|
t.Fatalf("GenehmigerRolle = %+v, unerwartete Werte", g)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetGenehmigerRolle(ctx, g.ID)
|
||||||
|
if err != nil || got.Name != "Datenschutzbeauftragter" {
|
||||||
|
t.Fatalf("GetGenehmigerRolle = %+v, err=%v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
liste, err := s.ListGenehmigerRollenForAccount(ctx, accID)
|
||||||
|
if err != nil || len(liste) != 1 {
|
||||||
|
t.Fatalf("ListGenehmigerRollenForAccount = %+v, err=%v", liste, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
user := testUserID(t, s, accID)
|
||||||
|
if err := s.AddNutzerGenehmigerRolle(ctx, user, g.ID); err != nil {
|
||||||
|
t.Fatalf("AddNutzerGenehmigerRolle: %v", err)
|
||||||
|
}
|
||||||
|
// Erneutes Hinzufügen derselben Zuordnung ist ein No-op (ON CONFLICT),
|
||||||
|
// kein Fehler.
|
||||||
|
if err := s.AddNutzerGenehmigerRolle(ctx, user, g.ID); err != nil {
|
||||||
|
t.Fatalf("AddNutzerGenehmigerRolle (erneut): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rollenDesNutzers, err := s.ListGenehmigerRollenForUser(ctx, user)
|
||||||
|
if err != nil || len(rollenDesNutzers) != 1 || rollenDesNutzers[0].ID != g.ID {
|
||||||
|
t.Fatalf("ListGenehmigerRollenForUser = %+v, err=%v", rollenDesNutzers, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mitglieder, err := s.ListNutzerForGenehmigerRolle(ctx, g.ID)
|
||||||
|
if err != nil || len(mitglieder) != 1 || mitglieder[0].UserID != user {
|
||||||
|
t.Fatalf("ListNutzerForGenehmigerRolle = %+v, err=%v", mitglieder, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.RemoveNutzerGenehmigerRolle(ctx, user, g.ID); err != nil {
|
||||||
|
t.Fatalf("RemoveNutzerGenehmigerRolle: %v", err)
|
||||||
|
}
|
||||||
|
rollenNachEntfernen, err := s.ListGenehmigerRollenForUser(ctx, user)
|
||||||
|
if err != nil || len(rollenNachEntfernen) != 0 {
|
||||||
|
t.Fatalf("ListGenehmigerRollenForUser nach Entfernen = %+v, err=%v", rollenNachEntfernen, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.DeleteGenehmigerRolle(ctx, g.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteGenehmigerRolle: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.GetGenehmigerRolle(ctx, g.ID); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err nach Delete = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFreigabeRegelCRUD(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
g, err := s.CreateGenehmigerRolle(ctx, accID, "Geschaeftsfuehrung")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateGenehmigerRolle: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
regel, err := s.CreateFreigabeRegel(ctx, accID, "einstufung", "hochrisiko", g.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFreigabeRegel: %v", err)
|
||||||
|
}
|
||||||
|
if regel.BedingungTyp != "einstufung" || regel.BedingungWert != "hochrisiko" {
|
||||||
|
t.Fatalf("FreigabeRegel = %+v, unerwartete Werte", regel)
|
||||||
|
}
|
||||||
|
|
||||||
|
liste, err := s.ListFreigabeRegelnForAccount(ctx, accID)
|
||||||
|
if err != nil || len(liste) != 1 {
|
||||||
|
t.Fatalf("ListFreigabeRegelnForAccount = %+v, err=%v", liste, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetFreigabeRegel(ctx, regel.ID)
|
||||||
|
if err != nil || got.ID != regel.ID {
|
||||||
|
t.Fatalf("GetFreigabeRegel = %+v, err=%v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.DeleteFreigabeRegel(ctx, regel.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteFreigabeRegel: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.GetFreigabeRegel(ctx, regel.ID); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err nach Delete = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFreigabeschrittLifecycleUndKaskade(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
user := testUserID(t, s, accID)
|
||||||
|
antrag, err := s.CreateAntrag(ctx, accID, user, nil, "Test-Antrag für Freigabeschritte")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAntrag: %v", err)
|
||||||
|
}
|
||||||
|
antragID := antrag.ID
|
||||||
|
|
||||||
|
dsb, _ := s.CreateGenehmigerRolle(ctx, accID, "Datenschutzbeauftragter")
|
||||||
|
gf, _ := s.CreateGenehmigerRolle(ctx, accID, "Geschaeftsfuehrung")
|
||||||
|
|
||||||
|
schrittDSB, err := s.CreateFreigabeschritt(ctx, antragID, dsb.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFreigabeschritt: %v", err)
|
||||||
|
}
|
||||||
|
if schrittDSB.Status != "ausstehend" {
|
||||||
|
t.Fatalf("Status = %q, want ausstehend", schrittDSB.Status)
|
||||||
|
}
|
||||||
|
schrittGF, err := s.CreateFreigabeschritt(ctx, antragID, gf.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFreigabeschritt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
alle, err := s.ListFreigabeschritteForAntrag(ctx, antragID)
|
||||||
|
if err != nil || len(alle) != 2 {
|
||||||
|
t.Fatalf("ListFreigabeschritteForAntrag = %+v, err=%v", alle, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ausstehend, err := s.ListAusstehendeFreigabeschritteForUser(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAusstehendeFreigabeschritteForUser: %v", err)
|
||||||
|
}
|
||||||
|
if len(ausstehend) != 0 {
|
||||||
|
t.Fatalf("erwartet 0 ausstehende Freigaben ohne Genehmiger-Rollen-Zuordnung, got %d", len(ausstehend))
|
||||||
|
}
|
||||||
|
if err := s.AddNutzerGenehmigerRolle(ctx, user, dsb.ID); err != nil {
|
||||||
|
t.Fatalf("AddNutzerGenehmigerRolle: %v", err)
|
||||||
|
}
|
||||||
|
ausstehend, err = s.ListAusstehendeFreigabeschritteForUser(ctx, user)
|
||||||
|
if err != nil || len(ausstehend) != 1 || ausstehend[0].ID != schrittDSB.ID {
|
||||||
|
t.Fatalf("ListAusstehendeFreigabeschritteForUser = %+v, err=%v", ausstehend, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entschieden, err := s.EntscheideFreigabeschritt(ctx, schrittDSB.ID, "abgelehnt", user, "nicht ausreichend")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EntscheideFreigabeschritt: %v", err)
|
||||||
|
}
|
||||||
|
if entschieden.Status != "abgelehnt" || entschieden.EntschiedenVon == nil || *entschieden.EntschiedenVon != user {
|
||||||
|
t.Fatalf("Freigabeschritt nach Entscheidung = %+v, unerwartete Werte", entschieden)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein bereits entschiedener Schritt kann nicht erneut entschieden werden.
|
||||||
|
if _, err := s.EntscheideFreigabeschritt(ctx, schrittDSB.ID, "genehmigt", user, ""); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("erneute Entscheidung err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.KaskadiereAblehnung(ctx, antragID, schrittDSB.ID); err != nil {
|
||||||
|
t.Fatalf("KaskadiereAblehnung: %v", err)
|
||||||
|
}
|
||||||
|
nachKaskade, err := s.GetFreigabeschritt(ctx, schrittGF.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetFreigabeschritt: %v", err)
|
||||||
|
}
|
||||||
|
if nachKaskade.Status != "abgelehnt" {
|
||||||
|
t.Fatalf("Status nach Kaskade = %q, want abgelehnt", nachKaskade.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
9
internal/store/migrations/0017_freigabeworkflow.down.sql
Normal file
9
internal/store/migrations/0017_freigabeworkflow.down.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
ALTER TABLE antrag DROP CONSTRAINT antrag_status_check;
|
||||||
|
UPDATE antrag SET status = 'eingereicht' WHERE status = 'wartet_auf_freigabe';
|
||||||
|
ALTER TABLE antrag ADD CONSTRAINT antrag_status_check
|
||||||
|
CHECK (status IN ('entwurf', 'eingereicht', 'entschieden'));
|
||||||
|
|
||||||
|
DROP TABLE freigabeschritt;
|
||||||
|
DROP TABLE freigabe_regel;
|
||||||
|
DROP TABLE nutzer_genehmiger_rolle;
|
||||||
|
DROP TABLE genehmiger_rolle;
|
||||||
59
internal/store/migrations/0017_freigabeworkflow.up.sql
Normal file
59
internal/store/migrations/0017_freigabeworkflow.up.sql
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
-- Konfigurierbarer Mehrfach-Freigabe-Workflow (2026-08-31): manche
|
||||||
|
-- Anträge brauchen zusätzlich zur normalen Fachebene-Entscheidung eine
|
||||||
|
-- Freigabe durch bestimmte Personen (z. B. Datenschutzbeauftragte bei
|
||||||
|
-- dsfa_erforderlich, Geschäftsführung bei hochrisiko). Bewusst additiv
|
||||||
|
-- zur bestehenden app_user.role (Zugriffskontrolle) — eine
|
||||||
|
-- Genehmiger-Rolle ist eine reine Freigabe-Funktion, keine Berechtigung,
|
||||||
|
-- und eine Person kann mehrere davon gleichzeitig innehaben.
|
||||||
|
|
||||||
|
-- Stammdaten wie abteilung, pro Mandant frei benennbar.
|
||||||
|
CREATE TABLE genehmiger_rolle (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id UUID NOT NULL REFERENCES account (id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (account_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Welche Personen eine Genehmiger-Rolle innehaben (n:m).
|
||||||
|
CREATE TABLE nutzer_genehmiger_rolle (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
app_user_id UUID NOT NULL REFERENCES app_user (id),
|
||||||
|
genehmiger_rolle_id UUID NOT NULL REFERENCES genehmiger_rolle (id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (app_user_id, genehmiger_rolle_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- "Wenn Bedingung X zutrifft, ist zusätzlich eine Freigabe durch
|
||||||
|
-- Genehmiger-Rolle Y nötig." Bedingung ist bewusst eine der bereits vom
|
||||||
|
-- Regelwerk abgeleiteten Größen (Anforderungs-ID, Einstufungs-ID,
|
||||||
|
-- Datenklasse-ID) — kein freier Regel-Editor, keine beliebige Logik.
|
||||||
|
CREATE TABLE freigabe_regel (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id UUID NOT NULL REFERENCES account (id),
|
||||||
|
bedingung_typ TEXT NOT NULL CHECK (bedingung_typ IN ('anforderung', 'einstufung', 'datenklasse')),
|
||||||
|
bedingung_wert TEXT NOT NULL,
|
||||||
|
genehmiger_rolle_id UUID NOT NULL REFERENCES genehmiger_rolle (id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Pro Antrag ein Eintrag je durch eine Regel ausgelöster nötiger
|
||||||
|
-- Freigabe. Nicht append-only (wie werkzeug) — ein Freigabeschritt ist
|
||||||
|
-- eine einzelne Aufgabe, die von ausstehend in einen Endzustand
|
||||||
|
-- übergeht, keine Historie mehrerer Entscheidungen zum selben Schritt.
|
||||||
|
CREATE TABLE freigabeschritt (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
antrag_id UUID NOT NULL REFERENCES antrag (id),
|
||||||
|
genehmiger_rolle_id UUID NOT NULL REFERENCES genehmiger_rolle (id),
|
||||||
|
status TEXT NOT NULL DEFAULT 'ausstehend' CHECK (status IN ('ausstehend', 'genehmigt', 'abgelehnt')),
|
||||||
|
entschieden_von UUID REFERENCES app_user (id),
|
||||||
|
entschieden_am TIMESTAMPTZ,
|
||||||
|
kommentar TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Ein Antrag, der auf zusätzliche Freigaben wartet, ist weder "nur
|
||||||
|
-- eingereicht" noch schon "entschieden" — eigener Zwischenzustand.
|
||||||
|
ALTER TABLE antrag DROP CONSTRAINT antrag_status_check;
|
||||||
|
ALTER TABLE antrag ADD CONSTRAINT antrag_status_check
|
||||||
|
CHECK (status IN ('entwurf', 'eingereicht', 'wartet_auf_freigabe', 'entschieden'));
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/rules"
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
"github.com/netcell-it/deklarix/internal/store"
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
@@ -337,6 +338,8 @@ type antragListData struct {
|
|||||||
Title string
|
Title string
|
||||||
Nav navData
|
Nav navData
|
||||||
Antraege []antragListItem
|
Antraege []antragListItem
|
||||||
|
Suche string
|
||||||
|
Pagination pagination
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAntragList zeigt die eigenen Anträge (Ebene 2 — "Eigene
|
// handleAntragList zeigt die eigenen Anträge (Ebene 2 — "Eigene
|
||||||
@@ -351,8 +354,16 @@ func (s *Server) handleAntragList(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "Anträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "Anträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data := antragListData{Title: "Meine Anträge", Nav: navFor(r)}
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
var gefiltert []store.Antrag
|
||||||
for _, a := range list {
|
for _, a := range list {
|
||||||
|
if matchesQuery(q, a.Titel, a.Status, a.Haeufigkeit) {
|
||||||
|
gefiltert = append(gefiltert, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pag := paginationFromRequest(r, len(gefiltert))
|
||||||
|
data := antragListData{Title: "Meine Anträge", Nav: navFor(r), Suche: q, Pagination: pag}
|
||||||
|
for _, a := range paginate(gefiltert, pag) {
|
||||||
data.Antraege = append(data.Antraege, antragListItem{
|
data.Antraege = append(data.Antraege, antragListItem{
|
||||||
ID: a.ID, Titel: a.Titel, Status: a.Status, Haeufigkeit: a.Haeufigkeit,
|
ID: a.ID, Titel: a.Titel, Status: a.Status, Haeufigkeit: a.Haeufigkeit,
|
||||||
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/auth"
|
"github.com/netcell-it/deklarix/internal/auth"
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type betreiberDashboardData struct {
|
type betreiberDashboardData struct {
|
||||||
@@ -55,6 +57,8 @@ type betreiberAccountListData struct {
|
|||||||
Title string
|
Title string
|
||||||
Nav navData
|
Nav navData
|
||||||
Accounts []betreiberAccountListItem
|
Accounts []betreiberAccountListItem
|
||||||
|
Suche string
|
||||||
|
Pagination pagination
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleBetreiberAccountList listet alle Mandanten der Plattform.
|
// handleBetreiberAccountList listet alle Mandanten der Plattform.
|
||||||
@@ -64,8 +68,16 @@ func (s *Server) handleBetreiberAccountList(w http.ResponseWriter, r *http.Reque
|
|||||||
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data := betreiberAccountListData{Title: "Accounts", Nav: navFor(r)}
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
var gefiltert []store.Account
|
||||||
for _, a := range accounts {
|
for _, a := range accounts {
|
||||||
|
if matchesQuery(q, a.Name) {
|
||||||
|
gefiltert = append(gefiltert, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pag := paginationFromRequest(r, len(gefiltert))
|
||||||
|
data := betreiberAccountListData{Title: "Accounts", Nav: navFor(r), Suche: q, Pagination: pag}
|
||||||
|
for _, a := range paginate(gefiltert, pag) {
|
||||||
data.Accounts = append(data.Accounts, betreiberAccountListItem{
|
data.Accounts = append(data.Accounts, betreiberAccountListItem{
|
||||||
ID: a.ID, Name: a.Name, CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
ID: a.ID, Name: a.Name, CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
||||||
})
|
})
|
||||||
@@ -221,17 +233,34 @@ type betreiberAuditLogData struct {
|
|||||||
Title string
|
Title string
|
||||||
Nav navData
|
Nav navData
|
||||||
Entries []betreiberAuditEntryView
|
Entries []betreiberAuditEntryView
|
||||||
|
Suche string
|
||||||
|
Pagination pagination
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// auditLogFetchLimit begrenzt, wie viele der neuesten Einträge aus der
|
||||||
|
// DB geholt werden, bevor im Go-Code gefiltert/paginiert wird — echte
|
||||||
|
// DB-seitige Offset-Paginierung wäre für ein potenziell sehr groß
|
||||||
|
// werdendes Audit-Log der sauberere Ansatz, ist hier aber (noch) nicht
|
||||||
|
// umgesetzt (siehe CLAUDE.md, Offene Punkte).
|
||||||
|
const auditLogFetchLimit = 1000
|
||||||
|
|
||||||
// handleBetreiberAuditLog zeigt das plattformweite Audit-Log.
|
// handleBetreiberAuditLog zeigt das plattformweite Audit-Log.
|
||||||
func (s *Server) handleBetreiberAuditLog(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleBetreiberAuditLog(w http.ResponseWriter, r *http.Request) {
|
||||||
entries, err := s.store.ListAuditLog(r.Context(), 200)
|
entries, err := s.store.ListAuditLog(r.Context(), auditLogFetchLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data := betreiberAuditLogData{Title: "Audit-Log", Nav: navFor(r)}
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
var gefiltert []store.AuditEntry
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
|
if matchesQuery(q, e.Action, e.TargetType, e.Details) {
|
||||||
|
gefiltert = append(gefiltert, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pag := paginationFromRequest(r, len(gefiltert))
|
||||||
|
data := betreiberAuditLogData{Title: "Audit-Log", Nav: navFor(r), Suche: q, Pagination: pag}
|
||||||
|
for _, e := range paginate(gefiltert, pag) {
|
||||||
data.Entries = append(data.Entries, betreiberAuditEntryView{
|
data.Entries = append(data.Entries, betreiberAuditEntryView{
|
||||||
CreatedAt: e.CreatedAt.Format("02.01.2006 15:04:05"), Action: e.Action,
|
CreatedAt: e.CreatedAt.Format("02.01.2006 15:04:05"), Action: e.Action,
|
||||||
TargetType: e.TargetType, TargetID: e.TargetID, Details: e.Details,
|
TargetType: e.TargetType, TargetID: e.TargetID, Details: e.Details,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -89,9 +90,18 @@ type fallDetailData struct {
|
|||||||
Entscheidung *entscheidungView
|
Entscheidung *entscheidungView
|
||||||
CanEntscheiden bool
|
CanEntscheiden bool
|
||||||
WerkzeugOptionen []werkzeugOption
|
WerkzeugOptionen []werkzeugOption
|
||||||
|
Freigabeschritte []freigabeschrittView
|
||||||
Error string
|
Error string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// freigabeschrittView zeigt den Stand eines einzelnen, zusätzlich zur
|
||||||
|
// Fachebene-Entscheidung nötigen Freigabeschritts an.
|
||||||
|
type freigabeschrittView struct {
|
||||||
|
RolleName string
|
||||||
|
Status string
|
||||||
|
Kommentar string
|
||||||
|
}
|
||||||
|
|
||||||
// hatEntscheidungsrecht — "admin" ist bewusst mit eingeschlossen, siehe
|
// hatEntscheidungsrecht — "admin" ist bewusst mit eingeschlossen, siehe
|
||||||
// requireFachebene: die Spezifikation will admin+verantwortlicher auf
|
// requireFachebene: die Spezifikation will admin+verantwortlicher auf
|
||||||
// derselben Person, ohne dass app_user.role zwei Werte tragen kann.
|
// derselben Person, ohne dass app_user.role zwei Werte tragen kann.
|
||||||
@@ -158,6 +168,24 @@ func (s *Server) handleFallDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
data.Entscheidung = v
|
data.Entscheidung = v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nicht auf Status "wartet_auf_freigabe" beschränkt: die Freigabehistorie
|
||||||
|
// bleibt auch nach vollständiger Entscheidung sichtbar (Grundregel: eine
|
||||||
|
// Entscheidung wird nie ohne vollständige Herleitung angezeigt). Für
|
||||||
|
// Anträge ohne ausgelöste Freigabe-Regel liefert die Abfrage einfach
|
||||||
|
// eine leere Liste, das Template blendet den Abschnitt dann aus.
|
||||||
|
schritte, err := s.store.ListFreigabeschritteForAntrag(r.Context(), antrag.ID)
|
||||||
|
if err == nil {
|
||||||
|
for _, schritt := range schritte {
|
||||||
|
rolleName := ""
|
||||||
|
if rolle, err := s.store.GetGenehmigerRolle(r.Context(), schritt.GenehmigerRolleID); err == nil {
|
||||||
|
rolleName = rolle.Name
|
||||||
|
}
|
||||||
|
data.Freigabeschritte = append(data.Freigabeschritte, freigabeschrittView{
|
||||||
|
RolleName: rolleName, Status: schritt.Status, Kommentar: schritt.Kommentar,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.templates.ExecuteTemplate(w, "fall-detail", data); err != nil {
|
if err := s.templates.ExecuteTemplate(w, "fall-detail", data); err != nil {
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
@@ -224,7 +252,6 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
|
|||||||
AntragID: antrag.ID, BewertungID: bewertung.ID, EntscheiderUserID: user.ID,
|
AntragID: antrag.ID, BewertungID: bewertung.ID, EntscheiderUserID: user.ID,
|
||||||
Entscheidung: entscheidungTyp, Begruendung: begruendung,
|
Entscheidung: entscheidungTyp, Begruendung: begruendung,
|
||||||
}
|
}
|
||||||
var werkzeugName string
|
|
||||||
if istGenehmigung {
|
if istGenehmigung {
|
||||||
zulaessig := false
|
zulaessig := false
|
||||||
for _, id := range bewertung.ZulaessigeWerkzeuge {
|
for _, id := range bewertung.ZulaessigeWerkzeuge {
|
||||||
@@ -242,7 +269,6 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
werkzeugName = werkzeug.Name
|
|
||||||
in.WerkzeugID = &werkzeugID
|
in.WerkzeugID = &werkzeugID
|
||||||
in.WerkzeugSnapshot = &werkzeug
|
in.WerkzeugSnapshot = &werkzeug
|
||||||
gueltigBis := gueltigkeitFuer(bewertung.Einstufung)
|
gueltigBis := gueltigkeitFuer(bewertung.Einstufung)
|
||||||
@@ -254,6 +280,39 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "Entscheidung konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "Entscheidung konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Konfigurierbarer Mehrfach-Freigabe-Workflow (siehe CLAUDE.md): eine
|
||||||
|
// Genehmigung ist nicht zwangsläufig sofort final — löst die
|
||||||
|
// Bewertung eine Freigabe-Regel des Mandanten aus (z. B.
|
||||||
|
// dsfa_erforderlich → Datenschutzbeauftragter), wartet der Antrag
|
||||||
|
// erst auf diese zusätzlichen Freigaben, bevor SetAntragStatus auf
|
||||||
|
// "entschieden" geht und ein Registereintrag entsteht.
|
||||||
|
if istGenehmigung {
|
||||||
|
regeln, err := s.store.ListFreigabeRegelnForAccount(r.Context(), user.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Freigabe-Regeln konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rollenIDs := triggeredGenehmigerRollen(regeln, bewertung); len(rollenIDs) > 0 {
|
||||||
|
for _, rolleID := range rollenIDs {
|
||||||
|
if _, err := s.store.CreateFreigabeschritt(r.Context(), antrag.ID, rolleID); err != nil {
|
||||||
|
http.Error(w, "Freigabeschritt konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.store.SetAntragStatus(r.Context(), antrag.ID, "wartet_auf_freigabe"); err != nil {
|
||||||
|
http.Error(w, "Antrag konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateAuditEntry(r.Context(), user.ID, "antrag_wartet_auf_freigabe", "antrag", antrag.ID, entscheidungTyp); err != nil {
|
||||||
|
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/faelle/"+antrag.ID, http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.store.SetAntragStatus(r.Context(), antrag.ID, "entschieden"); err != nil {
|
if err := s.store.SetAntragStatus(r.Context(), antrag.ID, "entschieden"); err != nil {
|
||||||
http.Error(w, "Antrag konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "Antrag konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -262,27 +321,8 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jede Genehmigung erzeugt automatisch einen Registereintrag (siehe
|
|
||||||
// CLAUDE.md) — bei "abgelehnt"/"rueckfrage" gibt es nichts
|
|
||||||
// Genehmigtes, das im Register stehen müsste.
|
|
||||||
if istGenehmigung {
|
if istGenehmigung {
|
||||||
abteilungName := ""
|
if err := s.registriereGenehmigung(r.Context(), antrag, entscheidung, bewertung); err != nil {
|
||||||
if antrag.AbteilungID != nil {
|
|
||||||
if abt, err := s.store.GetAbteilung(r.Context(), *antrag.AbteilungID); err == nil {
|
|
||||||
abteilungName = abt.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var auflagen []string
|
|
||||||
for _, a := range bewertung.Anforderungen {
|
|
||||||
auflagen = append(auflagen, a.Beschreibung)
|
|
||||||
}
|
|
||||||
if _, err := s.store.CreateRegistereintrag(r.Context(), store.RegistereintragInput{
|
|
||||||
AccountID: user.AccountID, AntragID: antrag.ID, EntscheidungID: entscheidung.ID,
|
|
||||||
Zweck: antrag.Titel, Abteilung: abteilungName, Werkzeug: werkzeugName,
|
|
||||||
Datenklasse: bewertung.Datenklasse, Einstufung: bewertung.Einstufung, Auflagen: auflagen,
|
|
||||||
Verantwortlicher: user.Email, EntschiedenAm: entscheidung.CreatedAt, GueltigBis: entscheidung.GueltigBis,
|
|
||||||
}); err != nil {
|
|
||||||
http.Error(w, "Registereintrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "Registereintrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -290,3 +330,67 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
http.Redirect(w, r, "/faelle/"+antrag.ID, http.StatusSeeOther)
|
http.Redirect(w, r, "/faelle/"+antrag.ID, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// registriereGenehmigung schließt eine (ggf. erst nach zusätzlichen
|
||||||
|
// Freigaben) endgültig genehmigte Entscheidung ab: Registereintrag
|
||||||
|
// anlegen (siehe CLAUDE.md — Antragstatus ist an dieser Stelle bereits
|
||||||
|
// auf "entschieden" gesetzt). Wiederverwendet von handleFallEntscheiden
|
||||||
|
// (keine zusätzliche Freigabe nötig) und handleFreigabeEntscheiden
|
||||||
|
// (letzter ausstehender Freigabeschritt wurde soeben genehmigt).
|
||||||
|
func (s *Server) registriereGenehmigung(ctx context.Context, antrag store.Antrag, entscheidung store.Entscheidung, bewertung store.Bewertung) error {
|
||||||
|
abteilungName := ""
|
||||||
|
if antrag.AbteilungID != nil {
|
||||||
|
if abt, err := s.store.GetAbteilung(ctx, *antrag.AbteilungID); err == nil {
|
||||||
|
abteilungName = abt.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
werkzeugName := ""
|
||||||
|
if entscheidung.WerkzeugSnapshot != nil {
|
||||||
|
werkzeugName = entscheidung.WerkzeugSnapshot.Name
|
||||||
|
}
|
||||||
|
verantwortlicherEmail := ""
|
||||||
|
if u, err := s.store.GetUser(ctx, entscheidung.EntscheiderUserID); err == nil {
|
||||||
|
verantwortlicherEmail = u.Email
|
||||||
|
}
|
||||||
|
var auflagen []string
|
||||||
|
for _, a := range bewertung.Anforderungen {
|
||||||
|
auflagen = append(auflagen, a.Beschreibung)
|
||||||
|
}
|
||||||
|
_, err := s.store.CreateRegistereintrag(ctx, store.RegistereintragInput{
|
||||||
|
AccountID: antrag.AccountID, AntragID: antrag.ID, EntscheidungID: entscheidung.ID,
|
||||||
|
Zweck: antrag.Titel, Abteilung: abteilungName, Werkzeug: werkzeugName,
|
||||||
|
Datenklasse: bewertung.Datenklasse, Einstufung: bewertung.Einstufung, Auflagen: auflagen,
|
||||||
|
Verantwortlicher: verantwortlicherEmail, EntschiedenAm: entscheidung.CreatedAt, GueltigBis: entscheidung.GueltigBis,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// triggeredGenehmigerRollen liefert die eindeutigen Genehmiger-Rollen,
|
||||||
|
// deren Freigabe-Regel durch diese Bewertung ausgelöst wird — Bedingung
|
||||||
|
// ist eine der vom Regelwerk bereits abgeleiteten Größen (Anforderungs-
|
||||||
|
// ID, Einstufung, Datenklasse), kein freier Regel-Editor.
|
||||||
|
func triggeredGenehmigerRollen(regeln []store.FreigabeRegel, bewertung store.Bewertung) []string {
|
||||||
|
var out []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, regel := range regeln {
|
||||||
|
var treffer bool
|
||||||
|
switch regel.BedingungTyp {
|
||||||
|
case "anforderung":
|
||||||
|
for _, a := range bewertung.Anforderungen {
|
||||||
|
if a.ID == regel.BedingungWert {
|
||||||
|
treffer = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "einstufung":
|
||||||
|
treffer = bewertung.Einstufung == regel.BedingungWert
|
||||||
|
case "datenklasse":
|
||||||
|
treffer = bewertung.Datenklasse == regel.BedingungWert
|
||||||
|
}
|
||||||
|
if treffer && !seen[regel.GenehmigerRolleID] {
|
||||||
|
seen[regel.GenehmigerRolleID] = true
|
||||||
|
out = append(out, regel.GenehmigerRolleID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
462
internal/web/freigabe_handlers.go
Normal file
462
internal/web/freigabe_handlers.go
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
// Konfigurierbarer Mehrfach-Freigabe-Workflow — siehe Migration 0017
|
||||||
|
// und CLAUDE.md. Drei Seitengruppen:
|
||||||
|
// - Admin (Ebene 4): Genehmiger-Rollen anlegen/Mitglieder zuweisen,
|
||||||
|
// Freigabe-Regeln definieren ("wenn Bedingung X, dann Freigabe
|
||||||
|
// durch Rolle Y nötig").
|
||||||
|
// - Jeder angemeldete Nutzer: "Meine Freigaben" — eigene ausstehende
|
||||||
|
// Freigabeschritte, unabhängig von app_user.role (eine
|
||||||
|
// Genehmiger-Rolle ist orthogonal zur Zugriffsrolle).
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Admin: Genehmiger-Rollen ───────────────────────────────────────
|
||||||
|
|
||||||
|
type genehmigerRolleItem struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Mitglieder []nutzerOption
|
||||||
|
}
|
||||||
|
|
||||||
|
type nutzerOption struct {
|
||||||
|
ID string
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
|
||||||
|
type genehmigerRollenListeData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Rollen []genehmigerRolleItem
|
||||||
|
Nutzer []nutzerOption
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGenehmigerRollenListe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
rollen, err := s.store.ListGenehmigerRollenForAccount(r.Context(), accountID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Genehmiger-Rollen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
users, err := s.store.ListUsersForAccount(r.Context(), accountID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := genehmigerRollenListeData{Title: "Genehmiger-Rollen", Nav: navFor(r)}
|
||||||
|
for _, u := range users {
|
||||||
|
data.Nutzer = append(data.Nutzer, nutzerOption{ID: u.ID, Email: u.Email})
|
||||||
|
}
|
||||||
|
for _, g := range rollen {
|
||||||
|
item := genehmigerRolleItem{ID: g.ID, Name: g.Name}
|
||||||
|
mitglieder, err := s.store.ListNutzerForGenehmigerRolle(r.Context(), g.ID)
|
||||||
|
if err == nil {
|
||||||
|
for _, m := range mitglieder {
|
||||||
|
item.Mitglieder = append(item.Mitglieder, nutzerOption{ID: m.UserID, Email: m.Email})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data.Rollen = append(data.Rollen, item)
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "genehmiger-rollen-liste", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGenehmigerRolleCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
|
if name == "" {
|
||||||
|
http.Error(w, "Name ist Pflicht", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateGenehmigerRolle(r.Context(), currentUser(r).AccountID, name); err != nil {
|
||||||
|
http.Error(w, "Genehmiger-Rolle konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/genehmiger-rollen", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGenehmigerRolleDelete entfernt eine Genehmiger-Rolle. Solange
|
||||||
|
// noch Mitglieder zugewiesen sind oder eine Freigabe-Regel darauf
|
||||||
|
// verweist, schlägt das per Fremdschlüssel fehl — bewusst kein CASCADE,
|
||||||
|
// ein stillschweigendes Mit-Löschen wirksamer Freigabe-Regeln wäre
|
||||||
|
// überraschend und sicherheitsrelevant.
|
||||||
|
func (s *Server) handleGenehmigerRolleDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
rolle, err := s.store.GetGenehmigerRolle(r.Context(), id)
|
||||||
|
if err != nil || rolle.AccountID != currentUser(r).AccountID {
|
||||||
|
http.Error(w, "Genehmiger-Rolle nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.DeleteGenehmigerRolle(r.Context(), id); err != nil {
|
||||||
|
http.Error(w, "Genehmiger-Rolle wird noch verwendet (Mitglieder oder Freigabe-Regeln) und kann nicht gelöscht werden", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/genehmiger-rollen", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGenehmigerRolleMitgliedHinzufuegen(w http.ResponseWriter, r *http.Request) {
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
rolleID := r.PathValue("id")
|
||||||
|
rolle, err := s.store.GetGenehmigerRolle(r.Context(), rolleID)
|
||||||
|
if err != nil || rolle.AccountID != accountID {
|
||||||
|
http.Error(w, "Genehmiger-Rolle nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := r.FormValue("user_id")
|
||||||
|
target, err := s.store.GetUser(r.Context(), userID)
|
||||||
|
if err != nil || target.AccountID != accountID {
|
||||||
|
http.Error(w, "Nutzer nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.AddNutzerGenehmigerRolle(r.Context(), userID, rolleID); err != nil {
|
||||||
|
http.Error(w, "Zuweisung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/genehmiger-rollen", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGenehmigerRolleMitgliedEntfernen(w http.ResponseWriter, r *http.Request) {
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
rolleID := r.PathValue("id")
|
||||||
|
rolle, err := s.store.GetGenehmigerRolle(r.Context(), rolleID)
|
||||||
|
if err != nil || rolle.AccountID != accountID {
|
||||||
|
http.Error(w, "Genehmiger-Rolle nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := r.PathValue("userID")
|
||||||
|
if err := s.store.RemoveNutzerGenehmigerRolle(r.Context(), userID, rolleID); err != nil {
|
||||||
|
http.Error(w, "Entfernen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/genehmiger-rollen", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Admin: Freigabe-Regeln ─────────────────────────────────────────
|
||||||
|
|
||||||
|
// bedingungOption ist ein Auswahlpunkt im Freigabe-Regel-Formular —
|
||||||
|
// Value kodiert "typ:wert" (z. B. "anforderung:dsfa_erforderlich"),
|
||||||
|
// damit ein einziges <select> ohne JavaScript reicht statt zweier
|
||||||
|
// voneinander abhängiger Dropdowns.
|
||||||
|
type bedingungOption struct {
|
||||||
|
Value string
|
||||||
|
Label string
|
||||||
|
}
|
||||||
|
|
||||||
|
type bedingungGruppe struct {
|
||||||
|
Label string
|
||||||
|
Optionen []bedingungOption
|
||||||
|
}
|
||||||
|
|
||||||
|
type freigabeRegelItem struct {
|
||||||
|
ID string
|
||||||
|
BedingungLabel string
|
||||||
|
GenehmigerRolle string
|
||||||
|
}
|
||||||
|
|
||||||
|
type freigabeRegelnListeData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Regeln []freigabeRegelItem
|
||||||
|
BedingungGruppen []bedingungGruppe
|
||||||
|
RollenOptionen []store.GenehmigerRolle
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// bedingungGruppenAusRegelwerk baut die Freigabe-Regel-Bedingungsauswahl
|
||||||
|
// aus dem tatsächlich geladenen Regelwerk (nicht frei erfunden) — jede
|
||||||
|
// Anforderungs-/Einstufungs-/Datenklasse-ID, die das System ohnehin
|
||||||
|
// kennt, ist eine mögliche Bedingung.
|
||||||
|
func (s *Server) bedingungGruppenAusRegelwerk() []bedingungGruppe {
|
||||||
|
var anforderungen []bedingungOption
|
||||||
|
for _, a := range s.regelwerk.Anforderungen.Anforderungen {
|
||||||
|
label := a.ID
|
||||||
|
if a.Beschreibung != "" {
|
||||||
|
label = a.Beschreibung
|
||||||
|
}
|
||||||
|
anforderungen = append(anforderungen, bedingungOption{Value: "anforderung:" + a.ID, Label: label})
|
||||||
|
}
|
||||||
|
var einstufungen []bedingungOption
|
||||||
|
for _, e := range s.regelwerk.Einstufung.Stufen {
|
||||||
|
einstufungen = append(einstufungen, bedingungOption{Value: "einstufung:" + e.ID, Label: e.ID})
|
||||||
|
}
|
||||||
|
var datenklassen []bedingungOption
|
||||||
|
for _, d := range s.regelwerk.Datenklasse.Stufen {
|
||||||
|
datenklassen = append(datenklassen, bedingungOption{Value: "datenklasse:" + d.ID, Label: d.ID})
|
||||||
|
}
|
||||||
|
return []bedingungGruppe{
|
||||||
|
{Label: "Anforderung", Optionen: anforderungen},
|
||||||
|
{Label: "Einstufung", Optionen: einstufungen},
|
||||||
|
{Label: "Datenklasse", Optionen: datenklassen},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleFreigabeRegelnListe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
regeln, err := s.store.ListFreigabeRegelnForAccount(r.Context(), accountID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Freigabe-Regeln konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rollen, err := s.store.ListGenehmigerRollenForAccount(r.Context(), accountID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Genehmiger-Rollen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rollenName := map[string]string{}
|
||||||
|
for _, g := range rollen {
|
||||||
|
rollenName[g.ID] = g.Name
|
||||||
|
}
|
||||||
|
data := freigabeRegelnListeData{
|
||||||
|
Title: "Freigabe-Regeln", Nav: navFor(r),
|
||||||
|
BedingungGruppen: s.bedingungGruppenAusRegelwerk(), RollenOptionen: rollen,
|
||||||
|
}
|
||||||
|
for _, regel := range regeln {
|
||||||
|
data.Regeln = append(data.Regeln, freigabeRegelItem{
|
||||||
|
ID: regel.ID,
|
||||||
|
BedingungLabel: bedingungTypLabel(regel.BedingungTyp) + ": " + regel.BedingungWert,
|
||||||
|
GenehmigerRolle: rollenName[regel.GenehmigerRolleID],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "freigabe-regeln-liste", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bedingungTypLabel(typ string) string {
|
||||||
|
switch typ {
|
||||||
|
case "anforderung":
|
||||||
|
return "Anforderung"
|
||||||
|
case "einstufung":
|
||||||
|
return "Einstufung"
|
||||||
|
case "datenklasse":
|
||||||
|
return "Datenklasse"
|
||||||
|
default:
|
||||||
|
return typ
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleFreigabeRegelCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
teile := strings.SplitN(r.FormValue("bedingung"), ":", 2)
|
||||||
|
rolleID := r.FormValue("genehmiger_rolle_id")
|
||||||
|
if len(teile) != 2 || teile[0] == "" || teile[1] == "" || rolleID == "" {
|
||||||
|
http.Error(w, "Bedingung und Genehmiger-Rolle sind Pflicht", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
typ, wert := teile[0], teile[1]
|
||||||
|
if typ != "anforderung" && typ != "einstufung" && typ != "datenklasse" {
|
||||||
|
http.Error(w, "ungültiger Bedingungstyp", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rolle, err := s.store.GetGenehmigerRolle(r.Context(), rolleID)
|
||||||
|
if err != nil || rolle.AccountID != accountID {
|
||||||
|
http.Error(w, "Genehmiger-Rolle nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateFreigabeRegel(r.Context(), accountID, typ, wert, rolleID); err != nil {
|
||||||
|
http.Error(w, "Freigabe-Regel konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/freigabe-regeln", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleFreigabeRegelDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
regel, err := s.store.GetFreigabeRegel(r.Context(), id)
|
||||||
|
if err != nil || regel.AccountID != currentUser(r).AccountID {
|
||||||
|
http.Error(w, "Freigabe-Regel nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.DeleteFreigabeRegel(r.Context(), id); err != nil {
|
||||||
|
http.Error(w, "Freigabe-Regel konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/verwaltung/freigabe-regeln", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Meine Freigaben (jeder angemeldete Nutzer) ─────────────────────
|
||||||
|
|
||||||
|
type freigabeItem struct {
|
||||||
|
ID string
|
||||||
|
AntragID string
|
||||||
|
AntragTitel string
|
||||||
|
RolleName string
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
type freigabenListeData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Freigaben []freigabeItem
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleFreigabenListe zeigt die eigenen ausstehenden Freigabeschritte
|
||||||
|
// — unabhängig von app_user.role, denn eine Genehmiger-Rolle ist eine
|
||||||
|
// orthogonale Freigabe-Funktion, keine Zugriffsrolle.
|
||||||
|
func (s *Server) handleFreigabenListe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := currentUser(r)
|
||||||
|
schritte, err := s.store.ListAusstehendeFreigabeschritteForUser(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Freigaben konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := freigabenListeData{Title: "Meine Freigaben", Nav: navFor(r)}
|
||||||
|
for _, schritt := range schritte {
|
||||||
|
antrag, err := s.store.GetAntrag(r.Context(), schritt.AntragID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rolleName := ""
|
||||||
|
if rolle, err := s.store.GetGenehmigerRolle(r.Context(), schritt.GenehmigerRolleID); err == nil {
|
||||||
|
rolleName = rolle.Name
|
||||||
|
}
|
||||||
|
data.Freigaben = append(data.Freigaben, freigabeItem{
|
||||||
|
ID: schritt.ID, AntragID: antrag.ID, AntragTitel: antrag.Titel,
|
||||||
|
RolleName: rolleName, CreatedAt: schritt.CreatedAt.Format("02.01.2006 15:04"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "freigaben-liste", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleFreigabeEntscheiden trägt die Entscheidung einer Person zu
|
||||||
|
// einem einzelnen Freigabeschritt ein. Genehmigt sie den letzten noch
|
||||||
|
// ausstehenden Schritt eines Antrags, wird die ursprüngliche Genehmigung
|
||||||
|
// jetzt final abgeschlossen (Registereintrag). Lehnt sie ab, kippt das
|
||||||
|
// den gesamten Antrag — alle anderen offenen Schritte desselben Antrags
|
||||||
|
// werden automatisch mit abgelehnt (KaskadiereAblehnung), und eine neue
|
||||||
|
// Entscheidung "abgelehnt" übersteuert die ursprüngliche Genehmigung
|
||||||
|
// (entscheidung ist append-only, siehe CLAUDE.md).
|
||||||
|
func (s *Server) handleFreigabeEntscheiden(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := currentUser(r)
|
||||||
|
id := r.PathValue("id")
|
||||||
|
|
||||||
|
schritt, err := s.store.GetFreigabeschritt(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Freigabeschritt nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rollen, err := s.store.ListGenehmigerRollenForUser(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Genehmiger-Rollen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hatRolle := false
|
||||||
|
for _, rl := range rollen {
|
||||||
|
if rl.ID == schritt.GenehmigerRolleID {
|
||||||
|
hatRolle = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hatRolle {
|
||||||
|
http.Error(w, "Freigabeschritt nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if schritt.Status != "ausstehend" {
|
||||||
|
http.Error(w, "dieser Freigabeschritt wurde bereits entschieden", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entscheidungTyp := r.FormValue("entscheidung")
|
||||||
|
kommentar := r.FormValue("kommentar")
|
||||||
|
if entscheidungTyp != "genehmigt" && entscheidungTyp != "abgelehnt" {
|
||||||
|
http.Error(w, "ungültige Entscheidung", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.store.EntscheideFreigabeschritt(r.Context(), id, entscheidungTyp, user.ID, kommentar); err != nil {
|
||||||
|
http.Error(w, "Freigabeschritt konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
antrag, err := s.store.GetAntrag(r.Context(), schritt.AntragID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Antrag nicht gefunden", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rolle, _ := s.store.GetGenehmigerRolle(r.Context(), schritt.GenehmigerRolleID)
|
||||||
|
if _, err := s.store.CreateAuditEntry(r.Context(), user.ID, "freigabeschritt_entschieden", "antrag", antrag.ID, rolle.Name+": "+entscheidungTyp); err != nil {
|
||||||
|
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Bewertung nicht gefunden", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if entscheidungTyp == "abgelehnt" {
|
||||||
|
if err := s.store.KaskadiereAblehnung(r.Context(), antrag.ID, id); err != nil {
|
||||||
|
http.Error(w, "Kaskadierung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.SetAntragStatus(r.Context(), antrag.ID, "entschieden"); err != nil {
|
||||||
|
http.Error(w, "Antrag konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
begruendung := "Freigabe durch " + rolle.Name + " wurde verweigert."
|
||||||
|
if kommentar != "" {
|
||||||
|
begruendung += " Kommentar: " + kommentar
|
||||||
|
}
|
||||||
|
if _, err := s.store.CreateEntscheidung(r.Context(), store.EntscheidungInput{
|
||||||
|
AntragID: antrag.ID, BewertungID: bewertung.ID, EntscheiderUserID: user.ID,
|
||||||
|
Entscheidung: "abgelehnt", Begruendung: begruendung,
|
||||||
|
}); err != nil {
|
||||||
|
http.Error(w, "Entscheidung konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alle, err := s.store.ListFreigabeschritteForAntrag(r.Context(), antrag.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Freigabeschritte konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vollstaendig := true
|
||||||
|
for _, fs := range alle {
|
||||||
|
if fs.Status != "genehmigt" {
|
||||||
|
vollstaendig = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if vollstaendig {
|
||||||
|
letzteEntscheidung, err := s.store.GetLatestEntscheidungForAntrag(r.Context(), antrag.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Entscheidung nicht gefunden", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.SetAntragStatus(r.Context(), antrag.ID, "entschieden"); err != nil {
|
||||||
|
http.Error(w, "Antrag konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.registriereGenehmigung(r.Context(), antrag, letzteEntscheidung, bewertung); err != nil {
|
||||||
|
http.Error(w, "Registereintrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/freigaben", http.StatusSeeOther)
|
||||||
|
}
|
||||||
283
internal/web/freigabe_handlers_test.go
Normal file
283
internal/web/freigabe_handlers_test.go
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// besondereKategorieAntragForm loest ueber b2=ja die Datenklasse
|
||||||
|
// besondere_kategorie aus, die laut rules/anforderungen.yaml die
|
||||||
|
// Anforderung dsfa_erforderlich nach sich zieht — Grundlage fuer die
|
||||||
|
// Freigabe-Workflow-Tests.
|
||||||
|
func besondereKategorieAntragForm() url.Values {
|
||||||
|
return url.Values{
|
||||||
|
"titel": {"Gesundheitsdaten auswerten"}, "beschreibung": {"KI wertet Gesundheitsdaten aus"},
|
||||||
|
"ergebnis": {"Auswertung"}, "haeufigkeit": {"gelegentlich"},
|
||||||
|
"b1": {"nein"}, "b2": {"ja"}, "b3": {"nein"}, "b4": {"nein"}, "b5": {"nein"}, "b6": {"nein"}, "b7": {"nein"},
|
||||||
|
"c1": {"nein"}, "c2": {"nein"}, "c2_folge": {"keine"}, "c3": {"nein"}, "c3_art": {"keine"},
|
||||||
|
"c4": {"nein"}, "c5": {"ja"},
|
||||||
|
"d_werkzeug_freitext": {"ChatGPT"}, "d_zugang": {"firma"}, "d_geraet": {"firma"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedFreigabeSzenario legt einen Mandanten mit Mitarbeiter (reicht
|
||||||
|
// einen Antrag ein, der dsfa_erforderlich ausloest), Verantwortlichem
|
||||||
|
// und einem zentralen Werkzeug an — die Grundlage, auf der die
|
||||||
|
// einzelnen Freigabe-Workflow-Tests aufbauen.
|
||||||
|
func seedFreigabeSzenario(t *testing.T, fs *fakeStore, s *web.Server) (accountID, antragID string, verantwortlicherCookie *http.Cookie) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
acc, err := fs.CreateAccount(ctx, "Freigabe-Mandant")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
mitarbeiterCookie := seedUserInAccount(t, fs, acc.ID, "mitarbeiter-fg@example.com", "mitarbeiter")
|
||||||
|
|
||||||
|
werkzeugForm := url.Values{
|
||||||
|
"name": {"ChatGPT Enterprise"}, "anbieter": {"OpenAI"}, "verarbeitungslaender": {"Irland"},
|
||||||
|
"avv_verfuegbar": {"on"}, "avv_url": {"https://example.com/avv"},
|
||||||
|
"training_standard": {"on"}, "letzte_pruefung": {"2026-08-01"}, "quelle": {"https://example.com/beleg"},
|
||||||
|
}
|
||||||
|
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber FG", "betreiber-fg@example.com", "betreiber")
|
||||||
|
if resp := postForm(t, s, betreiberCookie, "/betreiber/werkzeuge", werkzeugForm); resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("Werkzeug anlegen: status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, mitarbeiterCookie, "/antraege", besondereKategorieAntragForm())
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("Antrag anlegen: status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
for id, a := range fs.antraege {
|
||||||
|
if a.AccountID == acc.ID {
|
||||||
|
antragID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if antragID == "" {
|
||||||
|
t.Fatal("kein Antrag im Mandanten gefunden")
|
||||||
|
}
|
||||||
|
|
||||||
|
verantwortlicherCookie = seedUserInAccount(t, fs, acc.ID, "verantwortlicher-fg@example.com", "verantwortlicher")
|
||||||
|
return acc.ID, antragID, verantwortlicherCookie
|
||||||
|
}
|
||||||
|
|
||||||
|
// werkzeugIDFor findet die ID des einzigen im fakeStore angelegten
|
||||||
|
// Werkzeugs — fuer die Genehmigungs-Formulare in diesen Tests reicht ein
|
||||||
|
// einziges zentrales Werkzeug.
|
||||||
|
func werkzeugIDFor(fs *fakeStore, name string) string {
|
||||||
|
for id, w := range fs.werkzeuge {
|
||||||
|
if w.Name == name {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOhneFreigabeRegelnVerhaeltSichWieVorher(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
_, antragID, verantwortlicherCookie := seedFreigabeSzenario(t, fs, s)
|
||||||
|
werkzeugID := werkzeugIDFor(fs, "ChatGPT Enterprise")
|
||||||
|
|
||||||
|
resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
|
||||||
|
"entscheidung": {"genehmigt"}, "werkzeug_id": {werkzeugID},
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("entscheiden: status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if fs.antraege[antragID].Status != "entschieden" {
|
||||||
|
t.Fatalf("Status = %q, want entschieden (keine Freigabe-Regel konfiguriert)", fs.antraege[antragID].Status)
|
||||||
|
}
|
||||||
|
regEintraege := fs.registereintraege[fs.antraege[antragID].AccountID]
|
||||||
|
if len(regEintraege) != 1 {
|
||||||
|
t.Fatalf("erwartet 1 Registereintrag ohne Freigabe-Regeln, got %d", len(regEintraege))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFreigabeWorkflowGatesBisAlleZugestimmtHaben(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
accountID, antragID, verantwortlicherCookie := seedFreigabeSzenario(t, fs, s)
|
||||||
|
werkzeugID := werkzeugIDFor(fs, "ChatGPT Enterprise")
|
||||||
|
|
||||||
|
dsb, err := fs.CreateGenehmigerRolle(context.Background(), accountID, "Datenschutzbeauftragter")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateGenehmigerRolle: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fs.CreateFreigabeRegel(context.Background(), accountID, "anforderung", "dsfa_erforderlich", dsb.ID); err != nil {
|
||||||
|
t.Fatalf("CreateFreigabeRegel: %v", err)
|
||||||
|
}
|
||||||
|
dsbCookie := seedUserInAccount(t, fs, accountID, "dsb@example.com", "mitarbeiter")
|
||||||
|
dsbUser, err := fs.GetUserByEmail(context.Background(), "dsb@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
if err := fs.AddNutzerGenehmigerRolle(context.Background(), dsbUser.ID, dsb.ID); err != nil {
|
||||||
|
t.Fatalf("AddNutzerGenehmigerRolle: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
|
||||||
|
"entscheidung": {"genehmigt"}, "werkzeug_id": {werkzeugID},
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("entscheiden: status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if got := fs.antraege[antragID].Status; got != "wartet_auf_freigabe" {
|
||||||
|
t.Fatalf("Status = %q, want wartet_auf_freigabe", got)
|
||||||
|
}
|
||||||
|
if len(fs.registereintraege[accountID]) != 0 {
|
||||||
|
t.Fatalf("erwartet noch keinen Registereintrag vor vollstaendiger Freigabe, got %d", len(fs.registereintraege[accountID]))
|
||||||
|
}
|
||||||
|
|
||||||
|
listResp := getWithCookie(t, s, dsbCookie, "/freigaben")
|
||||||
|
if listResp.Code != http.StatusOK || !strings.Contains(listResp.Body.String(), "Gesundheitsdaten auswerten") {
|
||||||
|
t.Fatalf("Meine Freigaben zeigt den Antrag nicht, status=%d body=%s", listResp.Code, listResp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var schrittID string
|
||||||
|
for id, fsr := range fs.freigabeschritte {
|
||||||
|
if fsr.AntragID == antragID {
|
||||||
|
schrittID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if schrittID == "" {
|
||||||
|
t.Fatal("kein Freigabeschritt fuer den Antrag gefunden")
|
||||||
|
}
|
||||||
|
|
||||||
|
decideResp := postForm(t, s, dsbCookie, "/freigaben/"+schrittID+"/entscheiden", url.Values{"entscheidung": {"genehmigt"}})
|
||||||
|
if decideResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("freigabe entscheiden: status = %d, body: %s", decideResp.Code, decideResp.Body.String())
|
||||||
|
}
|
||||||
|
if got := fs.antraege[antragID].Status; got != "entschieden" {
|
||||||
|
t.Fatalf("Status nach vollstaendiger Freigabe = %q, want entschieden", got)
|
||||||
|
}
|
||||||
|
if len(fs.registereintraege[accountID]) != 1 {
|
||||||
|
t.Fatalf("erwartet 1 Registereintrag nach vollstaendiger Freigabe, got %d", len(fs.registereintraege[accountID]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFreigabeAblehnungKipptGesamtenAntrag(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
accountID, antragID, verantwortlicherCookie := seedFreigabeSzenario(t, fs, s)
|
||||||
|
werkzeugID := werkzeugIDFor(fs, "ChatGPT Enterprise")
|
||||||
|
|
||||||
|
dsb, _ := fs.CreateGenehmigerRolle(context.Background(), accountID, "Datenschutzbeauftragter")
|
||||||
|
gf, _ := fs.CreateGenehmigerRolle(context.Background(), accountID, "Geschaeftsfuehrung")
|
||||||
|
fs.CreateFreigabeRegel(context.Background(), accountID, "anforderung", "dsfa_erforderlich", dsb.ID)
|
||||||
|
fs.CreateFreigabeRegel(context.Background(), accountID, "datenklasse", "besondere_kategorie", gf.ID)
|
||||||
|
|
||||||
|
dsbCookie := seedUserInAccount(t, fs, accountID, "dsb2@example.com", "mitarbeiter")
|
||||||
|
dsbUser, _ := fs.GetUserByEmail(context.Background(), "dsb2@example.com")
|
||||||
|
fs.AddNutzerGenehmigerRolle(context.Background(), dsbUser.ID, dsb.ID)
|
||||||
|
seedUserInAccount(t, fs, accountID, "gf@example.com", "mitarbeiter")
|
||||||
|
|
||||||
|
resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
|
||||||
|
"entscheidung": {"genehmigt"}, "werkzeug_id": {werkzeugID},
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("entscheiden: status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var dsbSchrittID string
|
||||||
|
for id, fsr := range fs.freigabeschritte {
|
||||||
|
if fsr.AntragID == antragID && fsr.GenehmigerRolleID == dsb.ID {
|
||||||
|
dsbSchrittID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dsbSchrittID == "" {
|
||||||
|
t.Fatal("kein DSB-Freigabeschritt gefunden")
|
||||||
|
}
|
||||||
|
|
||||||
|
decideResp := postForm(t, s, dsbCookie, "/freigaben/"+dsbSchrittID+"/entscheiden", url.Values{
|
||||||
|
"entscheidung": {"abgelehnt"}, "kommentar": {"Nicht ausreichend pseudonymisiert"},
|
||||||
|
})
|
||||||
|
if decideResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("freigabe ablehnen: status = %d, body: %s", decideResp.Code, decideResp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := fs.antraege[antragID].Status; got != "entschieden" {
|
||||||
|
t.Fatalf("Status nach Ablehnung = %q, want entschieden", got)
|
||||||
|
}
|
||||||
|
letzte := fs.entscheidungen[antragID]
|
||||||
|
if len(letzte) == 0 || letzte[len(letzte)-1].Entscheidung != "abgelehnt" {
|
||||||
|
t.Fatalf("letzte Entscheidung = %+v, want abgelehnt", letzte)
|
||||||
|
}
|
||||||
|
for _, fsr := range fs.freigabeschritte {
|
||||||
|
if fsr.AntragID == antragID && fsr.Status == "ausstehend" {
|
||||||
|
t.Fatalf("erwartet keine ausstehenden Freigabeschritte mehr nach Ablehnung, gefunden: %+v", fsr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(fs.registereintraege[accountID]) != 0 {
|
||||||
|
t.Fatalf("erwartet keinen Registereintrag nach Ablehnung, got %d", len(fs.registereintraege[accountID]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFreigabeEntscheidenOhneRolleWirdAbgelehnt(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
accountID, antragID, verantwortlicherCookie := seedFreigabeSzenario(t, fs, s)
|
||||||
|
werkzeugID := werkzeugIDFor(fs, "ChatGPT Enterprise")
|
||||||
|
|
||||||
|
dsb, _ := fs.CreateGenehmigerRolle(context.Background(), accountID, "Datenschutzbeauftragter")
|
||||||
|
fs.CreateFreigabeRegel(context.Background(), accountID, "anforderung", "dsfa_erforderlich", dsb.ID)
|
||||||
|
|
||||||
|
fremdCookie := seedUserInAccount(t, fs, accountID, "fremd@example.com", "mitarbeiter")
|
||||||
|
|
||||||
|
postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
|
||||||
|
"entscheidung": {"genehmigt"}, "werkzeug_id": {werkzeugID},
|
||||||
|
})
|
||||||
|
var schrittID string
|
||||||
|
for id, fsr := range fs.freigabeschritte {
|
||||||
|
if fsr.AntragID == antragID {
|
||||||
|
schrittID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, fremdCookie, "/freigaben/"+schrittID+"/entscheiden", url.Values{"entscheidung": {"genehmigt"}})
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 fuer eine Person ohne die noetige Genehmiger-Rolle", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenehmigerRolleCRUD(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "GR-Mandant", "admin-gr@example.com", "admin")
|
||||||
|
admin, err := fs.GetUserByEmail(context.Background(), "admin-gr@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, adminCookie, "/verwaltung/genehmiger-rollen", url.Values{"name": {"Geschaeftsfuehrung"}})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("anlegen: status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
var rolleID string
|
||||||
|
for id, g := range fs.genehmigerRollen {
|
||||||
|
if g.Name == "Geschaeftsfuehrung" {
|
||||||
|
rolleID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rolleID == "" {
|
||||||
|
t.Fatal("Genehmiger-Rolle wurde nicht angelegt")
|
||||||
|
}
|
||||||
|
|
||||||
|
assignResp := postForm(t, s, adminCookie, "/verwaltung/genehmiger-rollen/"+rolleID+"/mitglieder", url.Values{"user_id": {admin.ID}})
|
||||||
|
if assignResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("zuweisen: status = %d, body: %s", assignResp.Code, assignResp.Body.String())
|
||||||
|
}
|
||||||
|
list := getWithCookie(t, s, adminCookie, "/verwaltung/genehmiger-rollen")
|
||||||
|
if !strings.Contains(list.Body.String(), "admin-gr@example.com") {
|
||||||
|
t.Errorf("erwartet das zugewiesene Mitglied auf der Liste, got: %s", list.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
removeResp := postForm(t, s, adminCookie, "/verwaltung/genehmiger-rollen/"+rolleID+"/mitglieder/"+admin.ID+"/entfernen", url.Values{})
|
||||||
|
if removeResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("entfernen: status = %d, body: %s", removeResp.Code, removeResp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,6 +76,27 @@ type Store interface {
|
|||||||
GetAbteilung(ctx context.Context, id string) (store.Abteilung, error)
|
GetAbteilung(ctx context.Context, id string) (store.Abteilung, error)
|
||||||
CreateRegistereintrag(ctx context.Context, in store.RegistereintragInput) (store.Registereintrag, error)
|
CreateRegistereintrag(ctx context.Context, in store.RegistereintragInput) (store.Registereintrag, error)
|
||||||
ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]store.Registereintrag, error)
|
ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]store.Registereintrag, error)
|
||||||
|
|
||||||
|
CreateGenehmigerRolle(ctx context.Context, accountID, name string) (store.GenehmigerRolle, error)
|
||||||
|
GetGenehmigerRolle(ctx context.Context, id string) (store.GenehmigerRolle, error)
|
||||||
|
ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]store.GenehmigerRolle, error)
|
||||||
|
DeleteGenehmigerRolle(ctx context.Context, id string) error
|
||||||
|
AddNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error
|
||||||
|
RemoveNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error
|
||||||
|
ListGenehmigerRollenForUser(ctx context.Context, userID string) ([]store.GenehmigerRolle, error)
|
||||||
|
ListNutzerForGenehmigerRolle(ctx context.Context, genehmigerRolleID string) ([]store.GenehmigerMitglied, error)
|
||||||
|
|
||||||
|
CreateFreigabeRegel(ctx context.Context, accountID, bedingungTyp, bedingungWert, genehmigerRolleID string) (store.FreigabeRegel, error)
|
||||||
|
ListFreigabeRegelnForAccount(ctx context.Context, accountID string) ([]store.FreigabeRegel, error)
|
||||||
|
GetFreigabeRegel(ctx context.Context, id string) (store.FreigabeRegel, error)
|
||||||
|
DeleteFreigabeRegel(ctx context.Context, id string) error
|
||||||
|
|
||||||
|
CreateFreigabeschritt(ctx context.Context, antragID, genehmigerRolleID string) (store.Freigabeschritt, error)
|
||||||
|
GetFreigabeschritt(ctx context.Context, id string) (store.Freigabeschritt, error)
|
||||||
|
ListFreigabeschritteForAntrag(ctx context.Context, antragID string) ([]store.Freigabeschritt, error)
|
||||||
|
ListAusstehendeFreigabeschritteForUser(ctx context.Context, userID string) ([]store.Freigabeschritt, error)
|
||||||
|
EntscheideFreigabeschritt(ctx context.Context, id, status, entschiedenVon, kommentar string) (store.Freigabeschritt, error)
|
||||||
|
KaskadiereAblehnung(ctx context.Context, antragID, ausloesenderSchrittID string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regelwerk bündelt die drei geladenen Regelwerk-Dateien, die die
|
// Regelwerk bündelt die drei geladenen Regelwerk-Dateien, die die
|
||||||
@@ -151,6 +172,16 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
|||||||
mux.HandleFunc("GET /verwaltung/werkzeuge/{id}", s.requireAdmin(s.handleMandantWerkzeugEditForm))
|
mux.HandleFunc("GET /verwaltung/werkzeuge/{id}", s.requireAdmin(s.handleMandantWerkzeugEditForm))
|
||||||
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}", s.requireAdmin(s.handleMandantWerkzeugUpdate))
|
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}", s.requireAdmin(s.handleMandantWerkzeugUpdate))
|
||||||
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/loeschen", s.requireAdmin(s.handleMandantWerkzeugDelete))
|
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/loeschen", s.requireAdmin(s.handleMandantWerkzeugDelete))
|
||||||
|
mux.HandleFunc("GET /verwaltung/genehmiger-rollen", s.requireAdmin(s.handleGenehmigerRollenListe))
|
||||||
|
mux.HandleFunc("POST /verwaltung/genehmiger-rollen", s.requireAdmin(s.handleGenehmigerRolleCreate))
|
||||||
|
mux.HandleFunc("POST /verwaltung/genehmiger-rollen/{id}/loeschen", s.requireAdmin(s.handleGenehmigerRolleDelete))
|
||||||
|
mux.HandleFunc("POST /verwaltung/genehmiger-rollen/{id}/mitglieder", s.requireAdmin(s.handleGenehmigerRolleMitgliedHinzufuegen))
|
||||||
|
mux.HandleFunc("POST /verwaltung/genehmiger-rollen/{id}/mitglieder/{userID}/entfernen", s.requireAdmin(s.handleGenehmigerRolleMitgliedEntfernen))
|
||||||
|
mux.HandleFunc("GET /verwaltung/freigabe-regeln", s.requireAdmin(s.handleFreigabeRegelnListe))
|
||||||
|
mux.HandleFunc("POST /verwaltung/freigabe-regeln", s.requireAdmin(s.handleFreigabeRegelCreate))
|
||||||
|
mux.HandleFunc("POST /verwaltung/freigabe-regeln/{id}/loeschen", s.requireAdmin(s.handleFreigabeRegelDelete))
|
||||||
|
mux.HandleFunc("GET /freigaben", s.requirePage(s.handleFreigabenListe))
|
||||||
|
mux.HandleFunc("POST /freigaben/{id}/entscheiden", s.requireAPI(s.handleFreigabeEntscheiden))
|
||||||
mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard))
|
mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard))
|
||||||
mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList))
|
mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList))
|
||||||
mux.HandleFunc("GET /betreiber/accounts/neu", s.requireBetreiber(s.handleBetreiberAccountNeuForm))
|
mux.HandleFunc("GET /betreiber/accounts/neu", s.requireBetreiber(s.handleBetreiberAccountNeuForm))
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ type fakeStore struct {
|
|||||||
entscheidungen map[string][]store.Entscheidung // antragID -> Entscheidungen, älteste zuerst
|
entscheidungen map[string][]store.Entscheidung // antragID -> Entscheidungen, älteste zuerst
|
||||||
registereintraege map[string][]store.Registereintrag // accountID -> Registereintraege
|
registereintraege map[string][]store.Registereintrag // accountID -> Registereintraege
|
||||||
werkzeugSperren map[string][]store.WerkzeugSperre // accountID -> Sperrungen
|
werkzeugSperren map[string][]store.WerkzeugSperre // accountID -> Sperrungen
|
||||||
|
|
||||||
|
genehmigerRollen map[string]store.GenehmigerRolle
|
||||||
|
nutzerGenehmigerRollen map[string]map[string]bool // userID -> Set von genehmigerRolleID
|
||||||
|
freigabeRegeln map[string]store.FreigabeRegel
|
||||||
|
freigabeschritte map[string]store.Freigabeschritt
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFakeStore() *fakeStore {
|
func newFakeStore() *fakeStore {
|
||||||
@@ -53,6 +58,10 @@ func newFakeStore() *fakeStore {
|
|||||||
entscheidungen: map[string][]store.Entscheidung{},
|
entscheidungen: map[string][]store.Entscheidung{},
|
||||||
registereintraege: map[string][]store.Registereintrag{},
|
registereintraege: map[string][]store.Registereintrag{},
|
||||||
werkzeugSperren: map[string][]store.WerkzeugSperre{},
|
werkzeugSperren: map[string][]store.WerkzeugSperre{},
|
||||||
|
genehmigerRollen: map[string]store.GenehmigerRolle{},
|
||||||
|
nutzerGenehmigerRollen: map[string]map[string]bool{},
|
||||||
|
freigabeRegeln: map[string]store.FreigabeRegel{},
|
||||||
|
freigabeschritte: map[string]store.Freigabeschritt{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,6 +556,208 @@ func (f *fakeStore) ListRegistereintraegeForAccount(ctx context.Context, account
|
|||||||
return f.registereintraege[accountID], nil
|
return f.registereintraege[accountID], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateGenehmigerRolle(ctx context.Context, accountID, name string) (store.GenehmigerRolle, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
g := store.GenehmigerRolle{ID: f.newID(), AccountID: accountID, Name: name, CreatedAt: time.Now()}
|
||||||
|
f.genehmigerRollen[g.ID] = g
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetGenehmigerRolle(ctx context.Context, id string) (store.GenehmigerRolle, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
g, ok := f.genehmigerRollen[id]
|
||||||
|
if !ok {
|
||||||
|
return store.GenehmigerRolle{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]store.GenehmigerRolle, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []store.GenehmigerRolle
|
||||||
|
for _, g := range f.genehmigerRollen {
|
||||||
|
if g.AccountID == accountID {
|
||||||
|
out = append(out, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) DeleteGenehmigerRolle(ctx context.Context, id string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if _, ok := f.genehmigerRollen[id]; !ok {
|
||||||
|
return store.ErrNotFound
|
||||||
|
}
|
||||||
|
delete(f.genehmigerRollen, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) AddNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.nutzerGenehmigerRollen[userID] == nil {
|
||||||
|
f.nutzerGenehmigerRollen[userID] = map[string]bool{}
|
||||||
|
}
|
||||||
|
f.nutzerGenehmigerRollen[userID][genehmigerRolleID] = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) RemoveNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
delete(f.nutzerGenehmigerRollen[userID], genehmigerRolleID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListGenehmigerRollenForUser(ctx context.Context, userID string) ([]store.GenehmigerRolle, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []store.GenehmigerRolle
|
||||||
|
for grID := range f.nutzerGenehmigerRollen[userID] {
|
||||||
|
if g, ok := f.genehmigerRollen[grID]; ok {
|
||||||
|
out = append(out, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListNutzerForGenehmigerRolle(ctx context.Context, genehmigerRolleID string) ([]store.GenehmigerMitglied, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []store.GenehmigerMitglied
|
||||||
|
for userID, set := range f.nutzerGenehmigerRollen {
|
||||||
|
if !set[genehmigerRolleID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if u, ok := f.users[userID]; ok {
|
||||||
|
out = append(out, store.GenehmigerMitglied{UserID: u.ID, Email: u.Email})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateFreigabeRegel(ctx context.Context, accountID, bedingungTyp, bedingungWert, genehmigerRolleID string) (store.FreigabeRegel, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
fr := store.FreigabeRegel{
|
||||||
|
ID: f.newID(), AccountID: accountID, BedingungTyp: bedingungTyp,
|
||||||
|
BedingungWert: bedingungWert, GenehmigerRolleID: genehmigerRolleID, CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
f.freigabeRegeln[fr.ID] = fr
|
||||||
|
return fr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListFreigabeRegelnForAccount(ctx context.Context, accountID string) ([]store.FreigabeRegel, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []store.FreigabeRegel
|
||||||
|
for _, fr := range f.freigabeRegeln {
|
||||||
|
if fr.AccountID == accountID {
|
||||||
|
out = append(out, fr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetFreigabeRegel(ctx context.Context, id string) (store.FreigabeRegel, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
fr, ok := f.freigabeRegeln[id]
|
||||||
|
if !ok {
|
||||||
|
return store.FreigabeRegel{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
return fr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) DeleteFreigabeRegel(ctx context.Context, id string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if _, ok := f.freigabeRegeln[id]; !ok {
|
||||||
|
return store.ErrNotFound
|
||||||
|
}
|
||||||
|
delete(f.freigabeRegeln, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateFreigabeschritt(ctx context.Context, antragID, genehmigerRolleID string) (store.Freigabeschritt, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
fs := store.Freigabeschritt{
|
||||||
|
ID: f.newID(), AntragID: antragID, GenehmigerRolleID: genehmigerRolleID,
|
||||||
|
Status: "ausstehend", CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
f.freigabeschritte[fs.ID] = fs
|
||||||
|
return fs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetFreigabeschritt(ctx context.Context, id string) (store.Freigabeschritt, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
fs, ok := f.freigabeschritte[id]
|
||||||
|
if !ok {
|
||||||
|
return store.Freigabeschritt{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
return fs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListFreigabeschritteForAntrag(ctx context.Context, antragID string) ([]store.Freigabeschritt, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []store.Freigabeschritt
|
||||||
|
for _, fs := range f.freigabeschritte {
|
||||||
|
if fs.AntragID == antragID {
|
||||||
|
out = append(out, fs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListAusstehendeFreigabeschritteForUser(ctx context.Context, userID string) ([]store.Freigabeschritt, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []store.Freigabeschritt
|
||||||
|
for grID := range f.nutzerGenehmigerRollen[userID] {
|
||||||
|
for _, fs := range f.freigabeschritte {
|
||||||
|
if fs.GenehmigerRolleID == grID && fs.Status == "ausstehend" {
|
||||||
|
out = append(out, fs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) EntscheideFreigabeschritt(ctx context.Context, id, status, entschiedenVon, kommentar string) (store.Freigabeschritt, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
fs, ok := f.freigabeschritte[id]
|
||||||
|
if !ok || fs.Status != "ausstehend" {
|
||||||
|
return store.Freigabeschritt{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
fs.Status, fs.EntschiedenVon, fs.EntschiedenAm, fs.Kommentar = status, &entschiedenVon, &now, kommentar
|
||||||
|
f.freigabeschritte[id] = fs
|
||||||
|
return fs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) KaskadiereAblehnung(ctx context.Context, antragID, ausloesenderSchrittID string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
for id, fs := range f.freigabeschritte {
|
||||||
|
if fs.AntragID == antragID && fs.Status == "ausstehend" && id != ausloesenderSchrittID {
|
||||||
|
fs.Status = "abgelehnt"
|
||||||
|
now := time.Now()
|
||||||
|
fs.EntschiedenAm = &now
|
||||||
|
fs.Kommentar = "Automatisch abgelehnt, da eine andere erforderliche Freigabe für diesen Antrag abgelehnt wurde."
|
||||||
|
f.freigabeschritte[id] = fs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Test-Setup ───────────────────────────────────────────────────────
|
// ─── Test-Setup ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
// loadTestRegelwerk lädt die echten rules/*.yaml-Dateien — dieselben,
|
// loadTestRegelwerk lädt die echten rules/*.yaml-Dateien — dieselben,
|
||||||
|
|||||||
@@ -440,6 +440,7 @@ nav button {
|
|||||||
.beitraege-liste > li:not(:has(> a)) form {
|
.beitraege-liste > li:not(:has(> a)) form {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.beitraege-liste > li:not(:has(> a)) button {
|
.beitraege-liste > li:not(:has(> a)) button {
|
||||||
|
|||||||
@@ -4,11 +4,24 @@
|
|||||||
<body>
|
<body>
|
||||||
{{template "nav" .Nav}}
|
{{template "nav" .Nav}}
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<h1>Meine Anträge</h1>
|
<div class="page-header">
|
||||||
{{if not .Antraege}}
|
<div>
|
||||||
<p class="hinweis">Noch keine Anträge gestellt.</p>
|
<h1>Meine Anträge</h1>
|
||||||
{{else}}
|
<p class="hinweis">Eigene Anträge und ihr Bearbeitungsstand.</p>
|
||||||
<ul class="beitraege-liste">
|
</div>
|
||||||
|
<a class="page-header-action" href="/antraege/neu">Neuen Antrag stellen</a>
|
||||||
|
</div>
|
||||||
|
<form method="get">
|
||||||
|
<div class="filter-bar">
|
||||||
|
<input type="search" name="q" value="{{.Suche}}" placeholder="Suche (Titel, Status, Häufigkeit)">
|
||||||
|
<button type="submit">Filtern</button>
|
||||||
|
{{if .Suche}}<a href="/antraege">Zurücksetzen</a>{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if not .Antraege}}
|
||||||
|
<p class="hinweis">{{if .Suche}}Keine Treffer für diese Suche.{{else}}Noch keine Anträge gestellt.{{end}}</p>
|
||||||
|
{{else}}
|
||||||
|
<ul class="beitraege-liste">
|
||||||
{{range .Antraege}}
|
{{range .Antraege}}
|
||||||
<li>
|
<li>
|
||||||
<a href="/antraege/{{.ID}}">
|
<a href="/antraege/{{.ID}}">
|
||||||
@@ -17,9 +30,16 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
{{end}}
|
<div class="pagination">
|
||||||
<p><a href="/antraege/neu">Neuen Antrag stellen</a></p>
|
<span class="hinweis">Seite {{.Pagination.Page}} von {{.Pagination.TotalPages}} ({{.Pagination.Total}} Einträge)</span>
|
||||||
|
<span>
|
||||||
|
{{if .Pagination.HasPrev}}<button type="submit" name="page" value="{{.Pagination.PrevPage}}">← Zurück</button>{{end}}
|
||||||
|
{{if .Pagination.HasNext}}<button type="submit" name="page" value="{{.Pagination.NextPage}}">Weiter →</button>{{end}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,9 +5,20 @@
|
|||||||
{{template "nav" .Nav}}
|
{{template "nav" .Nav}}
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<p><a href="/betreiber">← Plattform</a></p>
|
<p><a href="/betreiber">← Plattform</a></p>
|
||||||
<h1>Accounts</h1>
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>Accounts</h1>
|
||||||
|
<p class="hinweis">Alle Mandanten der Plattform.</p>
|
||||||
|
</div>
|
||||||
|
<a class="page-header-action" href="/betreiber/accounts/neu">Firma anlegen</a>
|
||||||
|
</div>
|
||||||
|
<form method="get" class="filter-bar">
|
||||||
|
<input type="search" name="q" value="{{.Suche}}" placeholder="Suche (Firmenname)">
|
||||||
|
<button type="submit">Filtern</button>
|
||||||
|
{{if .Suche}}<a href="/betreiber/accounts">Zurücksetzen</a>{{end}}
|
||||||
|
</form>
|
||||||
{{if not .Accounts}}
|
{{if not .Accounts}}
|
||||||
<p class="hinweis">Noch keine Accounts.</p>
|
<p class="hinweis">{{if .Suche}}Keine Treffer für diese Suche.{{else}}Noch keine Accounts.{{end}}</p>
|
||||||
{{else}}
|
{{else}}
|
||||||
<ul class="beitraege-liste">
|
<ul class="beitraege-liste">
|
||||||
{{range .Accounts}}
|
{{range .Accounts}}
|
||||||
@@ -18,8 +29,26 @@
|
|||||||
</li>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
|
<div class="pagination">
|
||||||
|
<span class="hinweis">Seite {{.Pagination.Page}} von {{.Pagination.TotalPages}} ({{.Pagination.Total}} Einträge)</span>
|
||||||
|
<span>
|
||||||
|
{{if .Pagination.HasPrev}}
|
||||||
|
<form method="get" style="display:inline">
|
||||||
|
<input type="hidden" name="q" value="{{.Suche}}">
|
||||||
|
<input type="hidden" name="page" value="{{.Pagination.PrevPage}}">
|
||||||
|
<button type="submit">← Zurück</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
{{if .Pagination.HasNext}}
|
||||||
|
<form method="get" style="display:inline">
|
||||||
|
<input type="hidden" name="q" value="{{.Suche}}">
|
||||||
|
<input type="hidden" name="page" value="{{.Pagination.NextPage}}">
|
||||||
|
<button type="submit">Weiter →</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<p><a href="/betreiber/accounts/neu">Firma anlegen</a></p>
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,9 +5,19 @@
|
|||||||
{{template "nav" .Nav}}
|
{{template "nav" .Nav}}
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<p><a href="/betreiber">← Plattform</a></p>
|
<p><a href="/betreiber">← Plattform</a></p>
|
||||||
<h1>Audit-Log</h1>
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>Audit-Log</h1>
|
||||||
|
<p class="hinweis">Plattformweit, append-only — zeigt die neuesten Einträge.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="get" class="filter-bar">
|
||||||
|
<input type="search" name="q" value="{{.Suche}}" placeholder="Suche (Aktion, Zieltyp, Details)">
|
||||||
|
<button type="submit">Filtern</button>
|
||||||
|
{{if .Suche}}<a href="/betreiber/audit-log">Zurücksetzen</a>{{end}}
|
||||||
|
</form>
|
||||||
{{if not .Entries}}
|
{{if not .Entries}}
|
||||||
<p class="hinweis">Noch keine Einträge.</p>
|
<p class="hinweis">{{if .Suche}}Keine Treffer für diese Suche.{{else}}Noch keine Einträge.{{end}}</p>
|
||||||
{{else}}
|
{{else}}
|
||||||
<ul class="beteiligte">
|
<ul class="beteiligte">
|
||||||
{{range .Entries}}
|
{{range .Entries}}
|
||||||
@@ -17,6 +27,25 @@
|
|||||||
</li>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
|
<div class="pagination">
|
||||||
|
<span class="hinweis">Seite {{.Pagination.Page}} von {{.Pagination.TotalPages}} ({{.Pagination.Total}} Einträge)</span>
|
||||||
|
<span>
|
||||||
|
{{if .Pagination.HasPrev}}
|
||||||
|
<form method="get" style="display:inline">
|
||||||
|
<input type="hidden" name="q" value="{{.Suche}}">
|
||||||
|
<input type="hidden" name="page" value="{{.Pagination.PrevPage}}">
|
||||||
|
<button type="submit">← Zurück</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
{{if .Pagination.HasNext}}
|
||||||
|
<form method="get" style="display:inline">
|
||||||
|
<input type="hidden" name="q" value="{{.Suche}}">
|
||||||
|
<input type="hidden" name="page" value="{{.Pagination.NextPage}}">
|
||||||
|
<button type="submit">Weiter →</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -33,6 +33,23 @@
|
|||||||
<p><strong>{{.Entscheidung.Entscheidung}}</strong>{{if .Entscheidung.WerkzeugName}} — {{.Entscheidung.WerkzeugName}}{{end}}<br>
|
<p><strong>{{.Entscheidung.Entscheidung}}</strong>{{if .Entscheidung.WerkzeugName}} — {{.Entscheidung.WerkzeugName}}{{end}}<br>
|
||||||
<span class="hinweis">am {{.Entscheidung.CreatedAt}}{{if .Entscheidung.GueltigBis}}, gültig bis {{.Entscheidung.GueltigBis}}{{end}}</span></p>
|
<span class="hinweis">am {{.Entscheidung.CreatedAt}}{{if .Entscheidung.GueltigBis}}, gültig bis {{.Entscheidung.GueltigBis}}{{end}}</span></p>
|
||||||
{{if .Entscheidung.Begruendung}}<p>{{.Entscheidung.Begruendung}}</p>{{end}}
|
{{if .Entscheidung.Begruendung}}<p>{{.Entscheidung.Begruendung}}</p>{{end}}
|
||||||
|
|
||||||
|
{{if .Freigabeschritte}}
|
||||||
|
<h2>Zusätzliche Freigaben</h2>
|
||||||
|
{{if eq .Status "wartet_auf_freigabe"}}
|
||||||
|
<p class="hinweis">Die Genehmigung ist erst endgültig, wenn alle unten stehenden Freigaben erteilt sind.</p>
|
||||||
|
{{end}}
|
||||||
|
<ul class="beitraege-liste">
|
||||||
|
{{range .Freigabeschritte}}
|
||||||
|
<li>
|
||||||
|
{{.RolleName}}
|
||||||
|
<span class="status {{if eq .Status "genehmigt"}}status-published{{else if eq .Status "abgelehnt"}}status-hoch{{else}}status-mittel{{end}}">{{.Status}}</span>
|
||||||
|
{{if .Kommentar}}<p>{{.Kommentar}}</p>{{end}}
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{else if .CanEntscheiden}}
|
{{else if .CanEntscheiden}}
|
||||||
<h2>Entscheiden</h2>
|
<h2>Entscheiden</h2>
|
||||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||||
|
|||||||
93
internal/web/templates/freigabe_regeln_liste.html
Normal file
93
internal/web/templates/freigabe_regeln_liste.html
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
{{define "freigabe-regeln-liste"}}<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>{{template "head" .}}</head>
|
||||||
|
<body>
|
||||||
|
{{template "nav" .Nav}}
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>Freigabe-Regeln</h1>
|
||||||
|
<p class="hinweis">
|
||||||
|
"Wenn Bedingung X zutrifft, ist zusätzlich eine Freigabe durch
|
||||||
|
Genehmiger-Rolle Y nötig." Bedingungen sind die vom Regelwerk
|
||||||
|
abgeleiteten Anforderungen/Einstufungen/Datenklassen — keine
|
||||||
|
freie Regel, sondern eine Auswahl aus dem, was das System ohnehin
|
||||||
|
schon berechnet. Eine Genehmigung durch die Fachebene wartet dann
|
||||||
|
erst auf diese zusätzlichen Freigaben, bevor sie endgültig ist;
|
||||||
|
lehnt eine davon ab, gilt der gesamte Antrag als abgelehnt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||||
|
|
||||||
|
{{if not .RollenOptionen}}
|
||||||
|
<p class="hinweis">
|
||||||
|
Zuerst mindestens eine <a href="/verwaltung/genehmiger-rollen">Genehmiger-Rolle</a>
|
||||||
|
anlegen, bevor eine Freigabe-Regel definiert werden kann.
|
||||||
|
</p>
|
||||||
|
{{else}}
|
||||||
|
<div class="form-card">
|
||||||
|
<form method="post" action="/verwaltung/freigabe-regeln">
|
||||||
|
<fieldset class="form-section">
|
||||||
|
<legend>Neue Freigabe-Regel</legend>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div>
|
||||||
|
<label for="bedingung">Bedingung</label>
|
||||||
|
<select id="bedingung" name="bedingung" required>
|
||||||
|
<option value="">Bitte wählen…</option>
|
||||||
|
{{range .BedingungGruppen}}
|
||||||
|
{{$gruppe := .Label}}
|
||||||
|
<optgroup label="{{$gruppe}}">
|
||||||
|
{{range .Optionen}}
|
||||||
|
<option value="{{.Value}}">{{.Label}}</option>
|
||||||
|
{{end}}
|
||||||
|
</optgroup>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="genehmiger_rolle_id">Genehmiger-Rolle</label>
|
||||||
|
<select id="genehmiger_rolle_id" name="genehmiger_rolle_id" required>
|
||||||
|
<option value="">Bitte wählen…</option>
|
||||||
|
{{range .RollenOptionen}}
|
||||||
|
<option value="{{.ID}}">{{.Name}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit">Anlegen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if not .Regeln}}
|
||||||
|
<p class="hinweis">Noch keine Freigabe-Regeln.</p>
|
||||||
|
{{else}}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Bedingung</th><th>Erforderliche Freigabe</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Regeln}}
|
||||||
|
<tr>
|
||||||
|
<td data-label="Bedingung">{{.BedingungLabel}}</td>
|
||||||
|
<td data-label="Erforderliche Freigabe">{{.GenehmigerRolle}}</td>
|
||||||
|
<td data-label="">
|
||||||
|
<form method="post" action="/verwaltung/freigabe-regeln/{{.ID}}/loeschen" style="display:inline" onsubmit="return confirm('Freigabe-Regel wirklich löschen?')">
|
||||||
|
<button type="submit" class="entfernen">Löschen</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
38
internal/web/templates/freigaben_liste.html
Normal file
38
internal/web/templates/freigaben_liste.html
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{{define "freigaben-liste"}}<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>{{template "head" .}}</head>
|
||||||
|
<body>
|
||||||
|
{{template "nav" .Nav}}
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>Meine Freigaben</h1>
|
||||||
|
<p class="hinweis">Anträge, die zusätzlich zur Fachebene-Entscheidung deine Freigabe brauchen.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{if not .Freigaben}}
|
||||||
|
<p class="keine-findings">Keine ausstehenden Freigaben.</p>
|
||||||
|
{{else}}
|
||||||
|
<ul class="beitraege-liste">
|
||||||
|
{{range .Freigaben}}
|
||||||
|
<li>
|
||||||
|
<div class="beteiligter-kopf">
|
||||||
|
<strong>{{.AntragTitel}}</strong> · Freigabe als {{.RolleName}} · seit {{.CreatedAt}}
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/freigaben/{{.ID}}/entscheiden">
|
||||||
|
<label for="kommentar-{{.ID}}">Kommentar (optional, Pflicht bei Ablehnung empfohlen)</label>
|
||||||
|
<input type="text" id="kommentar-{{.ID}}" name="kommentar">
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" name="entscheidung" value="genehmigt">Freigeben</button>
|
||||||
|
<button type="submit" name="entscheidung" value="abgelehnt" class="entfernen" onclick="return confirm('Wirklich ablehnen? Das lehnt den gesamten Antrag ab.')">Ablehnen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p><a href="/faelle/{{.AntragID}}">Antrag ansehen →</a></p>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
79
internal/web/templates/genehmiger_rollen_liste.html
Normal file
79
internal/web/templates/genehmiger_rollen_liste.html
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
{{define "genehmiger-rollen-liste"}}<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>{{template "head" .}}</head>
|
||||||
|
<body>
|
||||||
|
{{template "nav" .Nav}}
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>Genehmiger-Rollen</h1>
|
||||||
|
<p class="hinweis">
|
||||||
|
Freigabe-Funktionen wie "Datenschutzbeauftragter" oder
|
||||||
|
"Geschäftsführung" — unabhängig von den normalen Zugriffsrollen,
|
||||||
|
eine Person kann beides zugleich sein. Wer wann zusätzlich
|
||||||
|
freigeben muss, legst du unter
|
||||||
|
<a href="/verwaltung/freigabe-regeln">Freigabe-Regeln</a> fest.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||||
|
|
||||||
|
<div class="form-card">
|
||||||
|
<form method="post" action="/verwaltung/genehmiger-rollen">
|
||||||
|
<fieldset class="form-section">
|
||||||
|
<legend>Neue Genehmiger-Rolle</legend>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div>
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input type="text" id="name" name="name" placeholder="z. B. Datenschutzbeauftragter" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit">Anlegen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if not .Rollen}}
|
||||||
|
<p class="hinweis">Noch keine Genehmiger-Rollen.</p>
|
||||||
|
{{else}}
|
||||||
|
<ul class="beitraege-liste">
|
||||||
|
{{range .Rollen}}
|
||||||
|
{{$rolleID := .ID}}
|
||||||
|
<li>
|
||||||
|
<div class="beteiligter-kopf"><strong>{{.Name}}</strong></div>
|
||||||
|
{{if .Mitglieder}}
|
||||||
|
<ul class="beitraege-liste">
|
||||||
|
{{range .Mitglieder}}
|
||||||
|
<li>
|
||||||
|
{{.Email}}
|
||||||
|
<form method="post" action="/verwaltung/genehmiger-rollen/{{$rolleID}}/mitglieder/{{.ID}}/entfernen" style="display:inline">
|
||||||
|
<button type="submit" class="entfernen">Entfernen</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{else}}
|
||||||
|
<p class="hinweis">Noch keine Mitglieder.</p>
|
||||||
|
{{end}}
|
||||||
|
<form method="post" action="/verwaltung/genehmiger-rollen/{{$rolleID}}/mitglieder" style="display:inline">
|
||||||
|
<select name="user_id" required>
|
||||||
|
<option value="">Person hinzufügen…</option>
|
||||||
|
{{range $.Nutzer}}
|
||||||
|
<option value="{{.ID}}">{{.Email}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
<button type="submit">Hinzufügen</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/verwaltung/genehmiger-rollen/{{$rolleID}}/loeschen" style="display:inline" onsubmit="return confirm('Genehmiger-Rolle wirklich löschen?')">
|
||||||
|
<button type="submit" class="entfernen">Rolle löschen</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
<nav class="app-nav">
|
<nav class="app-nav">
|
||||||
<a href="/">Start</a>
|
<a href="/">Start</a>
|
||||||
{{if not .IsBetreiber}}<a href="/antraege">Meine Anträge</a>{{end}}
|
{{if not .IsBetreiber}}<a href="/antraege">Meine Anträge</a>{{end}}
|
||||||
|
{{if not .IsBetreiber}}<a href="/freigaben">Meine Freigaben</a>{{end}}
|
||||||
{{if .IsFachebene}}<a href="/faelle">Posteingang</a>{{end}}
|
{{if .IsFachebene}}<a href="/faelle">Posteingang</a>{{end}}
|
||||||
{{if .IsFachebene}}<a href="/registereintraege">Register</a>{{end}}
|
{{if .IsFachebene}}<a href="/registereintraege">Register</a>{{end}}
|
||||||
{{if .IsFachebene}}<a href="/wiedervorlage">Wiedervorlage</a>{{end}}
|
{{if .IsFachebene}}<a href="/wiedervorlage">Wiedervorlage</a>{{end}}
|
||||||
@@ -31,6 +32,8 @@
|
|||||||
{{if .IsAdmin}}<a href="/verwaltung/einladung">Einladungslink</a>{{end}}
|
{{if .IsAdmin}}<a href="/verwaltung/einladung">Einladungslink</a>{{end}}
|
||||||
{{if .IsAdmin}}<a href="/verwaltung/abteilungen">Abteilungen</a>{{end}}
|
{{if .IsAdmin}}<a href="/verwaltung/abteilungen">Abteilungen</a>{{end}}
|
||||||
{{if .IsAdmin}}<a href="/verwaltung/werkzeuge">Werkzeugkatalog</a>{{end}}
|
{{if .IsAdmin}}<a href="/verwaltung/werkzeuge">Werkzeugkatalog</a>{{end}}
|
||||||
|
{{if .IsAdmin}}<a href="/verwaltung/genehmiger-rollen">Genehmiger-Rollen</a>{{end}}
|
||||||
|
{{if .IsAdmin}}<a href="/verwaltung/freigabe-regeln">Freigabe-Regeln</a>{{end}}
|
||||||
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
|
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
|
||||||
<form method="post" action="/logout">
|
<form method="post" action="/logout">
|
||||||
<button type="submit">Abmelden</button>
|
<button type="submit">Abmelden</button>
|
||||||
|
|||||||
@@ -4,11 +4,15 @@
|
|||||||
<body>
|
<body>
|
||||||
{{template "nav" .Nav}}
|
{{template "nav" .Nav}}
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<h1>Wiedervorlage</h1>
|
<div class="page-header">
|
||||||
<p class="hinweis">
|
<div>
|
||||||
|
<h1>Wiedervorlage</h1>
|
||||||
|
<p class="hinweis">
|
||||||
Genehmigungen, die abgelaufen sind, bald ablaufen, oder deren
|
Genehmigungen, die abgelaufen sind, bald ablaufen, oder deren
|
||||||
Werkzeug sich seit der Entscheidung im Katalog geändert hat.
|
Werkzeug sich seit der Entscheidung im Katalog geändert hat.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{{if not .Eintraege}}
|
{{if not .Eintraege}}
|
||||||
<p class="keine-findings">Nichts zur Wiedervorlage.</p>
|
<p class="keine-findings">Nichts zur Wiedervorlage.</p>
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
Reference in New Issue
Block a user