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>
397 lines
14 KiB
Go
397 lines
14 KiB
Go
// Ebene 3 (Fachebene) — Posteingang offener Anträge und Entscheiden.
|
|
// Rollen "verantwortlicher" (Entscheidungsrecht) und "pruefer"
|
|
// (identische Sicht, kein Entscheidungsrecht) sehen dieselben Seiten;
|
|
// siehe requireFachebene in middleware.go und CLAUDE.md, Fünf Ebenen.
|
|
package web
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/netcell-it/deklarix/internal/store"
|
|
)
|
|
|
|
type fallListItem struct {
|
|
ID string
|
|
Titel string
|
|
Haeufigkeit string
|
|
CreatedAt string
|
|
}
|
|
|
|
type fallListData struct {
|
|
Title string
|
|
Nav navData
|
|
Faelle []fallListItem
|
|
Suche string
|
|
Pagination pagination
|
|
}
|
|
|
|
// handleFaelleListe zeigt den Posteingang: alle noch offenen (Status
|
|
// "eingereicht") Anträge des ganzen Mandanten — im Unterschied zu
|
|
// GET /antraege, das nur die eigenen Anträge eines Nutzers zeigt.
|
|
func (s *Server) handleFaelleListe(w http.ResponseWriter, r *http.Request) {
|
|
antraege, err := s.store.ListAntraegeForAccount(r.Context(), currentUser(r).AccountID)
|
|
if err != nil {
|
|
http.Error(w, "Anträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
|
var offene []fallListItem
|
|
for _, a := range antraege {
|
|
if a.Status != "eingereicht" {
|
|
continue
|
|
}
|
|
if !matchesQuery(q, a.Titel, a.Haeufigkeit) {
|
|
continue
|
|
}
|
|
offene = append(offene, fallListItem{
|
|
ID: a.ID, Titel: a.Titel, Haeufigkeit: a.Haeufigkeit,
|
|
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
|
})
|
|
}
|
|
pag := paginationFromRequest(r, len(offene))
|
|
data := fallListData{
|
|
Title: "Posteingang", Nav: navFor(r),
|
|
Faelle: paginate(offene, pag), Suche: q, Pagination: pag,
|
|
}
|
|
if err := s.templates.ExecuteTemplate(w, "faelle-liste", data); err != nil {
|
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
type entscheidungView struct {
|
|
Entscheidung string
|
|
Begruendung string
|
|
WerkzeugName string
|
|
GueltigBis string
|
|
CreatedAt string
|
|
}
|
|
|
|
type werkzeugOption struct {
|
|
ID string
|
|
Name string
|
|
}
|
|
|
|
type fallDetailData struct {
|
|
Title string
|
|
Nav navData
|
|
ID string
|
|
Titel string
|
|
Beschreibung string
|
|
Ergebnis string
|
|
Haeufigkeit string
|
|
Status string
|
|
CreatedAt string
|
|
Antworten []antwortZeile
|
|
Bewertung *bewertungView
|
|
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.
|
|
func hatEntscheidungsrecht(role string) bool {
|
|
return role == "verantwortlicher" || role == "admin"
|
|
}
|
|
|
|
// vorschlagFuer leitet aus einer Bewertung ab, welche Entscheidung das
|
|
// System vorschlagen würde — nicht gespeichert, nur zur Bestimmung, ob
|
|
// eine tatsächliche Entscheidung davon abweicht (dann ist die
|
|
// Begründung Pflicht, siehe CLAUDE.md, Grundregel).
|
|
func vorschlagFuer(b store.Bewertung) string {
|
|
if b.Verboten {
|
|
return "abgelehnt"
|
|
}
|
|
if len(b.ZulaessigeWerkzeuge) > 0 {
|
|
return "genehmigt"
|
|
}
|
|
return "rueckfrage"
|
|
}
|
|
|
|
// handleFallDetail zeigt einen Antrag mit Bewertung für die Fachebene
|
|
// — anders als GET /antraege/{id} (nur der eigene Antrag) ist hier
|
|
// jeder Antrag des Mandanten sichtbar, unabhängig vom Ersteller.
|
|
func (s *Server) handleFallDetail(w http.ResponseWriter, r *http.Request) {
|
|
antrag, err := s.store.GetAntrag(r.Context(), r.PathValue("id"))
|
|
if err != nil || antrag.AccountID != currentUser(r).AccountID {
|
|
http.Error(w, "Antrag nicht gefunden", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
var antworten map[string]any
|
|
if len(antrag.Antworten) > 0 {
|
|
_ = json.Unmarshal(antrag.Antworten, &antworten)
|
|
}
|
|
|
|
data := fallDetailData{
|
|
Title: "Fall", Nav: navFor(r), ID: antrag.ID, Titel: antrag.Titel,
|
|
Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit,
|
|
Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"), Antworten: antwortenAnzeige(antworten),
|
|
}
|
|
|
|
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
|
|
if err == nil {
|
|
data.Bewertung = s.toBewertungView(r.Context(), bewertung)
|
|
for _, id := range bewertung.ZulaessigeWerkzeuge {
|
|
data.WerkzeugOptionen = append(data.WerkzeugOptionen, werkzeugOption{ID: id, Name: s.werkzeugName(r.Context(), id)})
|
|
}
|
|
}
|
|
data.CanEntscheiden = hatEntscheidungsrecht(currentUser(r).Role) && antrag.Status == "eingereicht" && data.Bewertung != nil
|
|
|
|
entscheidung, err := s.store.GetLatestEntscheidungForAntrag(r.Context(), antrag.ID)
|
|
if err == nil {
|
|
v := &entscheidungView{
|
|
Entscheidung: entscheidung.Entscheidung, Begruendung: entscheidung.Begruendung,
|
|
CreatedAt: entscheidung.CreatedAt.Format("02.01.2006 15:04"),
|
|
}
|
|
if entscheidung.WerkzeugSnapshot != nil {
|
|
v.WerkzeugName = entscheidung.WerkzeugSnapshot.Name
|
|
}
|
|
if entscheidung.GueltigBis != nil {
|
|
v.GueltigBis = entscheidung.GueltigBis.Format("02.01.2006")
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
// gueltigkeitFuer liefert das Ablaufdatum einer Genehmigung — 12 Monate,
|
|
// bei hochrisiko 6 (siehe CLAUDE.md, Entscheidung/Register/Wiedervorlage).
|
|
func gueltigkeitFuer(einstufung string) time.Time {
|
|
if einstufung == "hochrisiko" {
|
|
return time.Now().AddDate(0, 6, 0)
|
|
}
|
|
return time.Now().AddDate(1, 0, 0)
|
|
}
|
|
|
|
// handleFallEntscheiden speichert die Entscheidung eines/einer
|
|
// Verantwortlichen (oder admin, siehe hatEntscheidungsrecht). Prüfer
|
|
// haben dieselbe Sicht, aber kein Entscheidungsrecht — anders als bei
|
|
// requireFachebene (404, weil die Seite für sie gar nicht existieren
|
|
// soll) ist das hier ein 403: ein Prüfer darf wissen, dass der Fall
|
|
// existiert, nur nicht entscheiden.
|
|
func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
|
|
user := currentUser(r)
|
|
if !hatEntscheidungsrecht(user.Role) {
|
|
http.Error(w, "kein Entscheidungsrecht", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
antrag, err := s.store.GetAntrag(r.Context(), r.PathValue("id"))
|
|
if err != nil || antrag.AccountID != user.AccountID {
|
|
http.Error(w, "Antrag nicht gefunden", http.StatusNotFound)
|
|
return
|
|
}
|
|
if antrag.Status != "eingereicht" {
|
|
http.Error(w, "Antrag ist bereits entschieden", http.StatusBadRequest)
|
|
return
|
|
}
|
|
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
|
|
if err != nil {
|
|
http.Error(w, "Bewertung nicht gefunden", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
|
return
|
|
}
|
|
entscheidungTyp := r.FormValue("entscheidung")
|
|
begruendung := r.FormValue("begruendung")
|
|
werkzeugID := r.FormValue("werkzeug_id")
|
|
|
|
switch entscheidungTyp {
|
|
case "genehmigt", "genehmigt_mit_auflagen", "abgelehnt", "rueckfrage":
|
|
default:
|
|
http.Error(w, "ungültige Entscheidung", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if vorschlagFuer(bewertung) != entscheidungTyp && begruendung == "" {
|
|
http.Error(w, "Begründung erforderlich, wenn die Entscheidung vom Vorschlag abweicht", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
istGenehmigung := entscheidungTyp == "genehmigt" || entscheidungTyp == "genehmigt_mit_auflagen"
|
|
in := store.EntscheidungInput{
|
|
AntragID: antrag.ID, BewertungID: bewertung.ID, EntscheiderUserID: user.ID,
|
|
Entscheidung: entscheidungTyp, Begruendung: begruendung,
|
|
}
|
|
if istGenehmigung {
|
|
zulaessig := false
|
|
for _, id := range bewertung.ZulaessigeWerkzeuge {
|
|
if id == werkzeugID {
|
|
zulaessig = true
|
|
break
|
|
}
|
|
}
|
|
if werkzeugID == "" || !zulaessig {
|
|
http.Error(w, "bei Genehmigung muss ein zulässiges Werkzeug gewählt werden", http.StatusBadRequest)
|
|
return
|
|
}
|
|
werkzeug, err := s.store.GetWerkzeug(r.Context(), werkzeugID)
|
|
if err != nil {
|
|
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
|
return
|
|
}
|
|
in.WerkzeugID = &werkzeugID
|
|
in.WerkzeugSnapshot = &werkzeug
|
|
gueltigBis := gueltigkeitFuer(bewertung.Einstufung)
|
|
in.GueltigBis = &gueltigBis
|
|
}
|
|
|
|
entscheidung, err := s.store.CreateEntscheidung(r.Context(), in)
|
|
if err != nil {
|
|
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
|
|
}
|
|
if _, err := s.store.CreateAuditEntry(r.Context(), user.ID, "antrag_entschieden", "antrag", antrag.ID, entscheidungTyp); err != nil {
|
|
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if istGenehmigung {
|
|
if err := s.registriereGenehmigung(r.Context(), antrag, entscheidung, bewertung); err != nil {
|
|
http.Error(w, "Registereintrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|