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