feat: konfigurierbaren Freigabe-Workflow mit Genehmiger-Rollen einführen

Mandanten können jetzt selbst festlegen, wer bei welcher abgeleiteten
Bedingung (Anforderung/Einstufung/Datenklasse) zusätzlich zur
Fachebene-Entscheidung zustimmen muss (z. B. Datenschutzbeauftragter,
Geschäftsführung) - Genehmiger-Rollen sind orthogonal zu den fünf
bestehenden Zugriffsrollen. Alle ausgelösten Rollen müssen zustimmen,
eine Ablehnung kippt kaskadierend den gesamten Antrag. Mandanten ohne
Freigabe-Regeln (aktuell alle) sind unverändert vom alten Direktpfad
betroffen, siehe TestOhneFreigabeRegelnVerhaeltSichWieVorher.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
noroot
2026-08-31 21:42:28 +02:00
parent 9e2c7f92ff
commit e2f38e9efb
16 changed files with 2107 additions and 36 deletions

128
CLAUDE.md
View File

@@ -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
**Firma (Ebene 1, öffentlich, `POST /register`):** Registrierungsformular
@@ -818,12 +930,22 @@ wiederverwendet aus dem alten Produkt), ebenso `bewertung`,
(`GET /wiedervorlage`: abgelaufene/bald ablaufende Genehmigungen und
Genehmigungen, deren Werkzeug sich seither im Katalog geändert hat
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
tatsächlicher Werkzeug-Nutzung, Mitarbeiterüberwachung (nichts, was
Nutzung einzelner Personen auswertet), konfigurierbare Rollen mit
eigener Oberfläche, Konzernstrukturen mit Vererbung, mandantenspezifische
Regelwerke, Workflow-Designer, SAML, Schnittstellen zu Fremdsystemen.
Nutzung einzelner Personen auswertet), Konzernstrukturen mit Vererbung,
mandantenspezifische Regelwerke, 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
View 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
}

View 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)
}
}

View 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;

View 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'));

View File

@@ -5,6 +5,7 @@
package web
import (
"context"
"encoding/json"
"net/http"
"strings"
@@ -89,9 +90,18 @@ type fallDetailData struct {
Entscheidung *entscheidungView
CanEntscheiden bool
WerkzeugOptionen []werkzeugOption
Freigabeschritte []freigabeschrittView
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
// requireFachebene: die Spezifikation will admin+verantwortlicher auf
// 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
}
// 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 {
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,
Entscheidung: entscheidungTyp, Begruendung: begruendung,
}
var werkzeugName string
if istGenehmigung {
zulaessig := false
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)
return
}
werkzeugName = werkzeug.Name
in.WerkzeugID = &werkzeugID
in.WerkzeugSnapshot = &werkzeug
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)
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 {
http.Error(w, "Antrag konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
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)
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 {
abteilungName := ""
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 {
if err := s.registriereGenehmigung(r.Context(), antrag, entscheidung, bewertung); err != nil {
http.Error(w, "Registereintrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
return
}
@@ -290,3 +330,67 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
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
}

View 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)
}

View 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())
}
}

View File

@@ -76,6 +76,27 @@ type Store interface {
GetAbteilung(ctx context.Context, id string) (store.Abteilung, error)
CreateRegistereintrag(ctx context.Context, in store.RegistereintragInput) (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
@@ -151,6 +172,16 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
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}/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/accounts", s.requireBetreiber(s.handleBetreiberAccountList))
mux.HandleFunc("GET /betreiber/accounts/neu", s.requireBetreiber(s.handleBetreiberAccountNeuForm))

View File

@@ -38,21 +38,30 @@ type fakeStore struct {
entscheidungen map[string][]store.Entscheidung // antragID -> Entscheidungen, älteste zuerst
registereintraege map[string][]store.Registereintrag // accountID -> Registereintraege
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 {
return &fakeStore{
accounts: map[string]store.Account{},
users: map[string]store.User{},
usersByEmail: map[string]string{},
sessions: map[string]store.Session{},
abteilungen: map[string][]store.Abteilung{},
antraege: map[string]store.Antrag{},
werkzeuge: map[string]store.Werkzeug{},
bewertungen: map[string][]store.Bewertung{},
entscheidungen: map[string][]store.Entscheidung{},
registereintraege: map[string][]store.Registereintrag{},
werkzeugSperren: map[string][]store.WerkzeugSperre{},
accounts: map[string]store.Account{},
users: map[string]store.User{},
usersByEmail: map[string]string{},
sessions: map[string]store.Session{},
abteilungen: map[string][]store.Abteilung{},
antraege: map[string]store.Antrag{},
werkzeuge: map[string]store.Werkzeug{},
bewertungen: map[string][]store.Bewertung{},
entscheidungen: map[string][]store.Entscheidung{},
registereintraege: map[string][]store.Registereintrag{},
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
}
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 ───────────────────────────────────────────────────────
// loadTestRegelwerk lädt die echten rules/*.yaml-Dateien — dieselben,

View File

@@ -440,6 +440,7 @@ nav button {
.beitraege-liste > li:not(:has(> a)) form {
margin: 0;
flex: 0 0 auto;
max-width: 100%;
}
.beitraege-liste > li:not(:has(> a)) button {

View File

@@ -33,6 +33,23 @@
<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>
{{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}}
<h2>Entscheiden</h2>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}

View 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}}

View 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}}

View 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}}

View File

@@ -24,6 +24,7 @@
<nav class="app-nav">
<a href="/">Start</a>
{{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="/registereintraege">Register</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/abteilungen">Abteilungen</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}}
<form method="post" action="/logout">
<button type="submit">Abmelden</button>