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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user