package web import ( "context" "encoding/json" "net/http" "strings" "github.com/netcell-it/deklarix/internal/rules" "github.com/netcell-it/deklarix/internal/store" ) // antwortenB/C/D-Feldnamen sind bewusst identisch zu den Fakten- // Schlüsseln in rules/datenklasse.yaml (b1..b7) und // rules/kivo_einstufung.yaml (c1..c5, c2_folge, c3_art) — Schritt 3 // (Ableitungen) liest dieselben Schlüssel direkt aus antrag.antworten, // ohne sie erst umbenennen zu müssen. var datenfragen = []struct { Key string Label string }{ {"b1", "Namen, E-Mail-Adressen oder andere Angaben zu Personen"}, {"b2", "Angaben zu Gesundheit, Religion, Herkunft, Gewerkschaft, Sexualleben"}, {"b3", "Daten von Patienten, Mandanten oder Versicherten"}, {"b4", "Bewerbungs-, Personal- oder Leistungsdaten von Beschäftigten"}, {"b5", "Daten, die uns ein Kunde anvertraut hat"}, {"b6", "Konstruktions-, Kalkulations- oder Vertragsunterlagen"}, {"b7", "Nur allgemein zugängliche oder selbst erfundene Inhalte"}, } var c2FolgeLabels = map[string]string{ "beschaeftigung": "Einstellung, Beförderung, Kündigung", "kreditwuerdigkeit": "Kreditwürdigkeit", "leistungsbewilligung": "Leistungsbewilligung", "bildung": "Zugang zu Bildung", "gesundheit": "medizinische Beurteilung", } var c3ArtLabels = map[string]string{ "emotionserkennung_arbeitsplatz": "Emotionserkennung am Arbeitsplatz", "social_scoring": "Social Scoring", "biometrische_kategorisierung": "Biometrische Kategorisierung", } // antwortZeile ist eine einzelne Frage-Antwort-Zeile für die Anzeige — // siehe antwortenAnzeige. type antwortZeile struct { Frage string Antwort string } func antwortLabel(v string) string { switch v { case "ja": return "Ja" case "nein": return "Nein" case "unsicher": return "Unsicher" default: return v } } func jaNein(v bool) string { if v { return "Ja" } return "Nein" } // antwortenAnzeige baut aus den rohen Fragebogen-Antworten // (antrag.antworten, JSON) eine lesbare Liste — dieselben Fragen-Labels // wie im Fragebogen selbst (antrag_neu.html). Ohne das sieht sowohl der // antragstellende Mitarbeiter als auch die Fachebene beim Entscheiden // nur die daraus abgeleitete Bewertung, nie die tatsächlich gegebenen // Antworten, auf denen sie beruht — im Audit ist das nicht // nachvollziehbar (siehe CLAUDE.md, Grundregel). func antwortenAnzeige(antworten map[string]any) []antwortZeile { var out []antwortZeile for _, f := range datenfragen { v, _ := antworten[f.Key].(string) if v == "" { v = "nein" } out = append(out, antwortZeile{Frage: f.Label, Antwort: antwortLabel(v)}) } c1, _ := antworten["c1"].(bool) out = append(out, antwortZeile{Frage: "Geht das Ergebnis unverändert nach außen?", Antwort: jaNein(c1)}) c2, _ := antworten["c2"].(bool) c2Zeile := antwortZeile{Frage: "Beeinflusst es eine Entscheidung über einen Menschen?", Antwort: jaNein(c2)} if c2 { if folge, _ := antworten["c2_folge"].(string); folge != "" { if label, ok := c2FolgeLabels[folge]; ok { c2Zeile.Antwort += " — " + label } } } out = append(out, c2Zeile) c3, _ := antworten["c3"].(bool) c3Zeile := antwortZeile{Frage: "Erkennt/bewertet es Emotionen, Verhalten oder biometrische Merkmale?", Antwort: jaNein(c3)} if c3 { if art, _ := antworten["c3_art"].(string); art != "" { if label, ok := c3ArtLabels[art]; ok { c3Zeile.Antwort += " — " + label } } } out = append(out, c3Zeile) c4, _ := antworten["c4"].(bool) out = append(out, antwortZeile{Frage: "Läuft es ohne menschliche Prüfung?", Antwort: jaNein(c4)}) c5, _ := antworten["c5"].(bool) out = append(out, antwortZeile{Frage: "Merkt der Empfänger, dass es von einer KI stammt?", Antwort: jaNein(c5)}) if werkzeug, _ := antworten["d_werkzeug_freitext"].(string); werkzeug != "" { out = append(out, antwortZeile{Frage: "Gewünschtes Werkzeug", Antwort: werkzeug}) } if zugang, _ := antworten["d_zugang"].(string); zugang != "" { label := "Privater Zugang" if zugang == "firma" { label = "Firmenkonto" } out = append(out, antwortZeile{Frage: "Zugang", Antwort: label}) } if geraet, _ := antworten["d_geraet"].(string); geraet != "" { label := "Privatgerät" if geraet == "firma" { label = "Firmengerät" } out = append(out, antwortZeile{Frage: "Gerät", Antwort: label}) } return out } type antragFormData struct { Title string Nav navData Abteilungen []store.Abteilung Datenfragen []struct { Key string Label string } Error string } // handleAntragNewForm zeigt den Fragebogen (Ebene 2, "Antrag stellen"). // Bewusst eine einzelne Seite statt eines mehrseitigen Assistenten — // jede zusätzliche Seite kostet Zeit, und "dauert der Antrag länger als // fünf Minuten, wird er umgangen" (siehe CLAUDE.md). Adaptive // Folgefragen (C2/C3) werden rein clientseitig per CSS ein-/ausgeblendet, // ohne zusätzlichen Server-Roundtrip. func (s *Server) handleAntragNewForm(w http.ResponseWriter, r *http.Request) { if currentUser(r).Role == "betreiber" { http.Redirect(w, r, "/betreiber", http.StatusSeeOther) return } abteilungen, err := s.store.ListAbteilungenForAccount(r.Context(), currentUser(r).AccountID) if err != nil { http.Error(w, "Abteilungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) return } data := antragFormData{Title: "Antrag stellen", Nav: navFor(r), Abteilungen: abteilungen, Datenfragen: datenfragen} if err := s.templates.ExecuteTemplate(w, "antrag-neu", data); err != nil { http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) } } // antworten ist die JSON-Struktur, die in antrag.antworten gespeichert // wird — ein flaches Objekt aus den Fragebogen-Fakten-Schlüsseln. // "Unsicher" wird für B unverändert als "unsicher" gespeichert, NICHT // zu "ja" normalisiert: diese Normalisierung ("Unsicher zählt wie Ja") // ist eine Auswertungsregel (Schritt 3), keine Speicherregel — was der // Mitarbeiter tatsächlich geantwortet hat, bleibt im Antrag unverändert // erhalten. func antwortenFromForm(r *http.Request) []byte { m := map[string]any{} for _, f := range datenfragen { v := r.FormValue(f.Key) if v == "" { v = "nein" } m[f.Key] = v } m["c1"] = r.FormValue("c1") == "ja" m["c2"] = r.FormValue("c2") == "ja" m["c2_folge"] = r.FormValue("c2_folge") m["c3"] = r.FormValue("c3") == "ja" m["c3_art"] = r.FormValue("c3_art") m["c4"] = r.FormValue("c4") == "ja" m["c5"] = r.FormValue("c5") == "ja" m["d_werkzeug_freitext"] = r.FormValue("d_werkzeug_freitext") m["d_zugang"] = r.FormValue("d_zugang") m["d_geraet"] = r.FormValue("d_geraet") data, _ := json.Marshal(m) // map[string]any mit nur primitiven Werten kann nicht scheitern return data } // handleAntragCreate legt einen Antrag an und reicht ihn direkt ein // (entwurf -> eingereicht in einem Schritt) — der Fragebogen ist eine // einzelne Seite, ein Zwischenspeichern als Entwurf ist für v1 nicht // vorgesehen. func (s *Server) handleAntragCreate(w http.ResponseWriter, r *http.Request) { if currentUser(r).Role == "betreiber" { http.Redirect(w, r, "/betreiber", http.StatusSeeOther) return } if err := r.ParseForm(); err != nil { http.Error(w, "ungültiges Formular", http.StatusBadRequest) return } titel := r.FormValue("titel") beschreibung := r.FormValue("beschreibung") ergebnis := r.FormValue("ergebnis") haeufigkeit := r.FormValue("haeufigkeit") if titel == "" || beschreibung == "" || ergebnis == "" || haeufigkeit == "" { http.Error(w, "Titel, Beschreibung, Ergebnis und Häufigkeit sind Pflichtfelder", http.StatusBadRequest) return } var abteilungID *string if v := r.FormValue("abteilung_id"); v != "" { abteilungID = &v } ctx := r.Context() user := currentUser(r) antworten := antwortenFromForm(r) antrag, err := s.store.CreateAntrag(ctx, user.AccountID, user.ID, abteilungID, titel) if err != nil { http.Error(w, "Antrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError) return } if _, err := s.store.UpdateAntragFelder(ctx, antrag.ID, titel, beschreibung, ergebnis, haeufigkeit, antworten); err != nil { http.Error(w, "Antrag konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError) return } if err := s.store.SetAntragStatus(ctx, antrag.ID, "eingereicht"); err != nil { http.Error(w, "Antrag konnte nicht eingereicht werden: "+err.Error(), http.StatusInternalServerError) return } if err := s.bewerteAntrag(ctx, antrag.ID, user.AccountID, antworten); err != nil { http.Error(w, "Bewertung konnte nicht berechnet werden: "+err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/antraege/"+antrag.ID, http.StatusSeeOther) } // bewerteAntrag berechnet die Bewertung für einen eingereichten Antrag // (Schritt 3: Ableitungen und harte Filter) und speichert sie. Bei // "verboten" erfolgt die K.-o.-Prüfung — keine Werkzeugsuche, siehe // CLAUDE.md, Bewertungslogik. func (s *Server) bewerteAntrag(ctx context.Context, antragID, accountID string, antwortenJSON []byte) error { antworten, err := rules.ParseAntworten(antwortenJSON) if err != nil { return err } datenklasse := rules.EvaluateDatenklasse(s.regelwerk.Datenklasse, antworten) einstufung, err := rules.EvaluateEinstufung(s.regelwerk.Einstufung, antworten) if err != nil { return err } verboten := rules.IstVerboten(einstufung.ID) in := store.BewertungInput{ AntragID: antragID, Datenklasse: datenklasse.ID, DatenklasseHerleitung: datenklasse.Herleitung, Einstufung: einstufung.ID, EinstufungHerleitung: einstufungHerleitungText(einstufung), Verboten: verboten, KatalogVersion: "n/a (K.-o.-Prüfung, keine Werkzeugsuche)", } if !verboten { anforderungen := rules.DeriveAnforderungen(s.regelwerk.Anforderungen, datenklasse.ID, einstufung.ID) anforderungIDs := make([]string, len(anforderungen)) for i, a := range anforderungen { in.Anforderungen = append(in.Anforderungen, store.BewertungAnforderung{ID: a.ID, Beschreibung: a.Beschreibung, Herleitung: a.Herleitung}) anforderungIDs[i] = a.ID } werkzeuge, err := s.store.ListWerkzeugeForAccount(ctx, accountID) if err != nil { return err } kandidaten := make([]rules.WerkzeugKandidat, len(werkzeuge)) for i, w := range werkzeuge { aufbewahrungTage := 0 if w.AufbewahrungTage != nil { aufbewahrungTage = *w.AufbewahrungTage } kandidaten[i] = rules.WerkzeugKandidat{ID: w.ID, Eigenschaften: rules.WerkzeugEigenschaften{ AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungslaender: w.Verarbeitungslaender, TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: aufbewahrungTage, }} } zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, anforderungIDs) in.ZulaessigeWerkzeuge = zulaessig for _, a := range ausgeschlossen { in.AusgeschlosseneWerkzeuge = append(in.AusgeschlosseneWerkzeuge, store.BewertungAusschluss{WerkzeugID: a.WerkzeugID, NichtErfuellt: a.NichtErfuellt}) } katalogVersion, err := s.store.CurrentKatalogVersion(ctx) if err != nil { return err } in.KatalogVersion = katalogVersion } in.RegelwerkVersion = rules.RegelwerkVersion(s.regelwerk.Datenklasse, s.regelwerk.Einstufung, s.regelwerk.Anforderungen) _, err = s.store.CreateBewertung(ctx, in) return err } // einstufungHerleitungText fügt die Quelle (z. B. "Art. 5 KI-VO") mit // an, falls das Regelwerk eine nennt. func einstufungHerleitungText(e rules.EinstufungErgebnis) string { if e.Quelle == "" { return e.Herleitung } return e.Herleitung + " (" + e.Quelle + ")" } type antragListItem struct { ID string Titel string Status string Haeufigkeit string CreatedAt string } type antragListData struct { Title string Nav navData Antraege []antragListItem Suche string Pagination pagination } // handleAntragList zeigt die eigenen Anträge (Ebene 2 — "Eigene // Anträge einsehen, Status verfolgen"), nicht die des ganzen Mandanten. func (s *Server) handleAntragList(w http.ResponseWriter, r *http.Request) { if currentUser(r).Role == "betreiber" { http.Redirect(w, r, "/betreiber", http.StatusSeeOther) return } list, err := s.store.ListAntraegeForUser(r.Context(), currentUser(r).ID) 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 gefiltert []store.Antrag for _, a := range list { if matchesQuery(q, a.Titel, a.Status, a.Haeufigkeit) { gefiltert = append(gefiltert, a) } } pag := paginationFromRequest(r, len(gefiltert)) data := antragListData{Title: "Meine Anträge", Nav: navFor(r), Suche: q, Pagination: pag} for _, a := range paginate(gefiltert, pag) { data.Antraege = append(data.Antraege, antragListItem{ ID: a.ID, Titel: a.Titel, Status: a.Status, Haeufigkeit: a.Haeufigkeit, CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"), }) } if err := s.templates.ExecuteTemplate(w, "antraege-liste", data); err != nil { http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) } } type anforderungView struct { Beschreibung string Herleitung string } type werkzeugAusschlussView struct { Name string NichtErfuellt []string } type bewertungView struct { Datenklasse string DatenklasseHerleitung string Einstufung string EinstufungHerleitung string Verboten bool Anforderungen []anforderungView ZulaessigeWerkzeuge []string // Namen, nicht IDs AusgeschlosseneWerkzeuge []werkzeugAusschlussView RegelwerkVersion string KatalogVersion string CreatedAt string } type antragDetailData struct { Title string Nav navData Titel string Beschreibung string Ergebnis string Haeufigkeit string Status string CreatedAt string Antworten []antwortZeile Bewertung *bewertungView } // handleAntragDetail zeigt einen einzelnen Antrag mit allen Antworten. // Ein Antrag eines fremden Nutzers wird wie ein nicht existierender // behandelt (404), nie mit einer expliziten 403 bestätigt — dasselbe // Muster wie schon beim alten Produkt für fremde Mandanten. func (s *Server) handleAntragDetail(w http.ResponseWriter, r *http.Request) { antrag, err := s.store.GetAntrag(r.Context(), r.PathValue("id")) if err != nil || antrag.ErstellerUserID != currentUser(r).ID { 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 := antragDetailData{ Title: "Antrag", Nav: navFor(r), 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) } if err := s.templates.ExecuteTemplate(w, "antrag-detail", data); err != nil { http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) } } // toBewertungView löst die in der Bewertung nur als ID gespeicherten // Werkzeuge zu Anzeigenamen auf. Ein Werkzeug, das seit der Berechnung // gelöscht wurde, wird mit seiner ID statt eines Namens angezeigt, // statt den ganzen Antrag unanzeigbar zu machen. func (s *Server) toBewertungView(ctx context.Context, b store.Bewertung) *bewertungView { v := &bewertungView{ Datenklasse: b.Datenklasse, DatenklasseHerleitung: b.DatenklasseHerleitung, Einstufung: b.Einstufung, EinstufungHerleitung: b.EinstufungHerleitung, Verboten: b.Verboten, RegelwerkVersion: b.RegelwerkVersion, KatalogVersion: b.KatalogVersion, CreatedAt: b.CreatedAt.Format("02.01.2006 15:04"), } for _, a := range b.Anforderungen { v.Anforderungen = append(v.Anforderungen, anforderungView{Beschreibung: a.Beschreibung, Herleitung: a.Herleitung}) } for _, id := range b.ZulaessigeWerkzeuge { v.ZulaessigeWerkzeuge = append(v.ZulaessigeWerkzeuge, s.werkzeugName(ctx, id)) } for _, a := range b.AusgeschlosseneWerkzeuge { v.AusgeschlosseneWerkzeuge = append(v.AusgeschlosseneWerkzeuge, werkzeugAusschlussView{ Name: s.werkzeugName(ctx, a.WerkzeugID), NichtErfuellt: a.NichtErfuellt, }) } return v } func (s *Server) werkzeugName(ctx context.Context, id string) string { w, err := s.store.GetWerkzeug(ctx, id) if err != nil { return id } return w.Name }