// Ebene 5 (Plattform) — Pflege des zentralen Werkzeugkatalogs // (`werkzeug.account_id IS NULL`). Mandantenspezifische Ergänzungen // (`account_id` gesetzt) sind NICHT Teil dieser Seiten — die bleiben // Sache des jeweiligen Mandanten (Ebene 4, "eigene Werkzeug- // Freigaben/-Sperrungen", noch nicht gebaut), siehe CLAUDE.md. package web import ( "net/http" "strconv" "strings" "time" "github.com/netcell-it/deklarix/internal/store" ) // werkzeugFilter bündelt die Filterkriterien für die Katalog-Listen // (Betreiber wie Mandant) — ein einziges GET-Formular setzt "q" // (Freitext über Name/Anbieter/Länder) und optional "zweck". type werkzeugFilter struct { Suche string Zweck string } func werkzeugFilterFromRequest(r *http.Request) werkzeugFilter { return werkzeugFilter{ Suche: strings.TrimSpace(r.URL.Query().Get("q")), Zweck: r.URL.Query().Get("zweck"), } } // Aktiv meldet, ob mindestens ein Filter gesetzt ist — steuert, ob ein // "Zurücksetzen"-Link angezeigt wird. Exportiert, weil html/template // nur exportierte Methoden per Reflection aufrufen kann. func (f werkzeugFilter) Aktiv() bool { return f.Suche != "" || f.Zweck != "" } func (f werkzeugFilter) matches(wz store.Werkzeug) bool { if f.Zweck != "" { gefunden := false for _, z := range wz.GeeigneteZwecke { if z == f.Zweck { gefunden = true break } } if !gefunden { return false } } if f.Suche != "" { q := strings.ToLower(f.Suche) heuhaufen := strings.ToLower(wz.Name + " " + wz.Anbieter + " " + strings.Join(wz.Verarbeitungslaender, " ")) if !strings.Contains(heuhaufen, q) { return false } } return true } // istGueltigesLand validiert gegen weltLaenderliste (laenderliste.go) // — dieselbe kontrollierte-Vokabular-Logik wie bei istGueltigerZweck, // nur für Verarbeitungsländer statt Zwecke. func istGueltigesLand(v string) bool { for _, l := range weltLaenderliste { if l == v { return true } } return false } // gueltigeZwecke ist das kontrollierte Vokabular für "geeignete Zwecke" // (siehe CLAUDE.md, Bewertungslogik Schritt 3). Vorher war das Feld // Freitext; ein Tippfehler ("Text" statt "Textgenerierung") hätte den // künftigen automatischen Zweckabgleich unbemerkt kaputt gemacht. Am // 2026-08-31 von sechs auf zwölf Kategorien erweitert (bewusste // v1-Scope-Entscheidung überarbeitet, nicht stillschweigend geändert) // — die ursprünglichen sechs deckten nur klassische Wissensarbeit ab, // nicht Datenanalyse/Video/Audio/Kundenservice/Design/Automatisierung. var gueltigeZwecke = []string{ "Textgenerierung", "Übersetzung", "Code", "Bild", "Transkription", "Recherche", "Datenanalyse", "Video", "Audio/Sprache", "Kundenservice/Chatbot", "Präsentation/Design", "Automatisierung/Agenten", } func istGueltigerZweck(v string) bool { for _, z := range gueltigeZwecke { if z == v { return true } } return false } // splitListe zerlegt ein kommasepariertes Formularfeld (Zertifizierungen, // geeignete Zwecke, Einschränkungen) in eine bereinigte Liste — ohne // führende/folgende Leerzeichen und ohne leere Einträge. func splitListe(s string) []string { var out []string for _, part := range strings.Split(s, ",") { part = strings.TrimSpace(part) if part != "" { out = append(out, part) } } return out } type werkzeugListItem struct { ID string Name string Anbieter string Verarbeitungslaender string DPFZertifiziert bool AVVVerfuegbar bool TrainingStandard bool Aufbewahrung string Zertifizierungen string GeeigneteZwecke string LetztePruefung string PruefStatusLabel string PruefStatusCSS string } // formatAufbewahrung stellt sicher, dass "unbekannt" (Anbieter hat // keine Angabe gemacht) nie mit "0 Tage" (belegte Zusicherung einer // sofortigen Löschung) verwechselt wird. func formatAufbewahrung(tage *int) string { if tage == nil { return "unbekannt" } if *tage == 0 { return "0 Tage (sofort)" } return strconv.Itoa(*tage) + " Tage" } type werkzeugListeData struct { Title string Nav navData Werkzeuge []werkzeugListItem Filter werkzeugFilter ZweckeOptionen []string Pagination pagination } // werkzeugPruefungVeraltetNach ist die Schwelle, ab der ein // Katalogeintrag im Ergebnis als "abgelaufen" markiert wird (siehe // CLAUDE.md, Werkzeugkatalog) — jede Zusicherung im Katalog muss // belegbar UND aktuell sein. werkzeugPruefungBaldFaelligNach liegt // 30 Tage davor (dasselbe Vorwarnfenster wie die Wiedervorlage, // wiedervorlageSchwelle) — "bald fällig" soll rechtzeitig auffallen, // nicht erst wenn die Frist schon gerissen ist. const ( werkzeugPruefungBaldFaelligNach = 150 * 24 * time.Hour werkzeugPruefungVeraltetNach = 180 * 24 * time.Hour ) // pruefStatus liefert eine Ampel-Kennzeichnung für das Alter einer // letzten Werkzeug-Prüfung: aktuell (grün) / bald fällig (gelb) / // abgelaufen (rot) — die CSS-Klassen sind dieselben wie für die // Risiko-Ampel anderswo im Produkt (status-niedrig/-mittel/-hoch). func pruefStatus(letztePruefung time.Time) (label, css string) { alter := time.Since(letztePruefung) switch { case alter > werkzeugPruefungVeraltetNach: return "abgelaufen", "status-hoch" case alter > werkzeugPruefungBaldFaelligNach: return "bald fällig", "status-mittel" default: return "aktuell", "status-niedrig" } } // handleBetreiberWerkzeugListe zeigt den zentralen Katalog. func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Request) { werkzeuge, err := s.store.ListZentraleWerkzeuge(r.Context()) if err != nil { http.Error(w, "Werkzeugkatalog konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError) return } filter := werkzeugFilterFromRequest(r) var gefiltert []store.Werkzeug for _, wz := range werkzeuge { if filter.matches(wz) { gefiltert = append(gefiltert, wz) } } pag := paginationFromRequest(r, len(gefiltert)) seite := paginate(gefiltert, pag) data := werkzeugListeData{ Title: "Werkzeugkatalog", Nav: navFor(r), Filter: filter, ZweckeOptionen: gueltigeZwecke, Pagination: pag, } for _, wz := range seite { label, css := pruefStatus(wz.LetztePruefung) data.Werkzeuge = append(data.Werkzeuge, werkzeugListItem{ ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungslaender: strings.Join(wz.Verarbeitungslaender, ", "), DPFZertifiziert: wz.DPFZertifiziert, AVVVerfuegbar: wz.AVVVerfuegbar, TrainingStandard: wz.TrainingStandard, Aufbewahrung: formatAufbewahrung(wz.AufbewahrungTage), Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "), LetztePruefung: wz.LetztePruefung.Format("02.01.2006"), PruefStatusLabel: label, PruefStatusCSS: css, }) } if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeuge", data); err != nil { http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) } } // werkzeugFormData bedient sowohl das Betreiber-Formular (zentraler // Katalog, ActionBase "/betreiber/werkzeuge") als auch das Mandanten- // Formular für eigene Katalogeinträge (ActionBase "/verwaltung/werkzeuge") // — dieselbe Template-Datei, siehe betreiber_werkzeug_form.html. type werkzeugFormData struct { Title string Nav navData ActionBase string ID string // leer bei "neu" Name string Anbieter string LaenderOptionen []string LaenderAusgewaehlt map[string]bool DPFZertifiziert bool AVVVerfuegbar bool AVVURL string TrainingOptOut bool TrainingStandard bool AufbewahrungTage string // leer = vom Anbieter nicht beziffert (unbekannt), NICHT "0" Zertifizierungen string Subprozessoren string ZweckeOptionen []string ZweckeAusgewaehlt map[string]bool Einschraenkungen string LetztePruefung string Quelle string Error string } // auswahlSet baut die Checkbox-Vorbelegung aus einer bereits // gespeicherten oder erneut eingereichten Liste von Zwecken. func auswahlSet(ausgewaehlt []string) map[string]bool { m := make(map[string]bool, len(ausgewaehlt)) for _, z := range ausgewaehlt { m[z] = true } return m } // werkzeugFormDataFromWerkzeug baut die Formular-Vorbelegung zum // Bearbeiten eines bestehenden Katalogeintrags — gemeinsam für Betreiber- // und Mandanten-Formular, damit beide bei neuen Feldern nicht auseinanderlaufen. func werkzeugFormDataFromWerkzeug(r *http.Request, wz store.Werkzeug, title, actionBase string) werkzeugFormData { aufbewahrung := "" if wz.AufbewahrungTage != nil { aufbewahrung = strconv.Itoa(*wz.AufbewahrungTage) } return werkzeugFormData{ Title: title, Nav: navFor(r), ActionBase: actionBase, ID: wz.ID, LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: auswahlSet(wz.Verarbeitungslaender), DPFZertifiziert: wz.DPFZertifiziert, Name: wz.Name, Anbieter: wz.Anbieter, AVVVerfuegbar: wz.AVVVerfuegbar, AVVURL: wz.AVVURL, TrainingOptOut: wz.TrainingOptOut, TrainingStandard: wz.TrainingStandard, AufbewahrungTage: aufbewahrung, Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), Subprozessoren: strings.Join(wz.Subprozessoren, ", "), ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: auswahlSet(wz.GeeigneteZwecke), Einschraenkungen: strings.Join(wz.Einschraenkungen, ", "), LetztePruefung: wz.LetztePruefung.Format("2006-01-02"), Quelle: wz.Quelle, } } func (s *Server) handleBetreiberWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) { data := werkzeugFormData{ Title: "Werkzeug anlegen", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge", LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: map[string]bool{}, ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: map[string]bool{}, } if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil { http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) } } // werkzeugInputFromForm liest ein WerkzeugInput aus dem Formular. // accountID ist nil für den zentralen Katalog (Betreiber) oder gesetzt // für einen mandantenspezifischen Eintrag (Ebene 4). func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugInput, string) { letztePruefung, err := time.Parse("2006-01-02", r.FormValue("letzte_pruefung")) if err != nil { return store.WerkzeugInput{}, "Datum der letzten Prüfung ist ungültig oder fehlt" } var verarbeitungslaender []string for _, l := range r.Form["verarbeitungslaender"] { if istGueltigesLand(l) { verarbeitungslaender = append(verarbeitungslaender, l) } } if len(verarbeitungslaender) == 0 { return store.WerkzeugInput{}, "mindestens ein Verarbeitungsland ist Pflicht" } name := r.FormValue("name") anbieter := r.FormValue("anbieter") quelle := r.FormValue("quelle") if name == "" || anbieter == "" || quelle == "" { return store.WerkzeugInput{}, "Name, Anbieter und Quelle sind Pflicht" } var aufbewahrungTage *int if rohwert := strings.TrimSpace(r.FormValue("aufbewahrung_tage")); rohwert != "" { wert, err := strconv.Atoi(rohwert) if err != nil || wert < 0 { return store.WerkzeugInput{}, "Aufbewahrung in Tagen muss eine Zahl ≥ 0 sein (leer lassen, wenn unbekannt)" } aufbewahrungTage = &wert } var geeigneteZwecke []string for _, z := range r.Form["geeignete_zwecke"] { if istGueltigerZweck(z) { geeigneteZwecke = append(geeigneteZwecke, z) } } return store.WerkzeugInput{ AccountID: accountID, Name: name, Anbieter: anbieter, Verarbeitungslaender: verarbeitungslaender, AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on", AVVURL: r.FormValue("avv_url"), TrainingOptOut: r.FormValue("training_opt_out") == "on", TrainingStandard: r.FormValue("training_standard") == "on", DPFZertifiziert: r.FormValue("dpf_zertifiziert") == "on", AufbewahrungTage: aufbewahrungTage, Zertifizierungen: splitListe(r.FormValue("zertifizierungen")), Subprozessoren: splitListe(r.FormValue("subprozessoren")), GeeigneteZwecke: geeigneteZwecke, Einschraenkungen: splitListe(r.FormValue("einschraenkungen")), LetztePruefung: letztePruefung, Quelle: quelle, }, "" } // formDataFromForm baut die Formular-Daten für eine erneute Anzeige bei // einem Validierungsfehler — die Eingaben des Nutzers bleiben erhalten, // statt das Formular zu leeren. func werkzeugFormDataFromRequest(r *http.Request, title, actionBase, id, errMsg string) werkzeugFormData { return werkzeugFormData{ Title: title, Nav: navFor(r), ActionBase: actionBase, ID: id, LaenderOptionen: weltLaenderliste, LaenderAusgewaehlt: auswahlSet(r.Form["verarbeitungslaender"]), DPFZertifiziert: r.FormValue("dpf_zertifiziert") == "on", Name: r.FormValue("name"), Anbieter: r.FormValue("anbieter"), AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on", AVVURL: r.FormValue("avv_url"), TrainingOptOut: r.FormValue("training_opt_out") == "on", TrainingStandard: r.FormValue("training_standard") == "on", AufbewahrungTage: strings.TrimSpace(r.FormValue("aufbewahrung_tage")), Zertifizierungen: r.FormValue("zertifizierungen"), Subprozessoren: r.FormValue("subprozessoren"), ZweckeOptionen: gueltigeZwecke, ZweckeAusgewaehlt: auswahlSet(r.Form["geeignete_zwecke"]), Einschraenkungen: r.FormValue("einschraenkungen"), LetztePruefung: r.FormValue("letzte_pruefung"), Quelle: r.FormValue("quelle"), Error: errMsg, } } // handleBetreiberWerkzeugCreate legt einen zentralen Katalogeintrag an. func (s *Server) handleBetreiberWerkzeugCreate(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "ungültiges Formular", http.StatusBadRequest) return } in, errMsg := werkzeugInputFromForm(r, nil) if errMsg != "" { data := werkzeugFormDataFromRequest(r, "Werkzeug anlegen", "/betreiber/werkzeuge", "", errMsg) if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil { http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) } return } if _, err := s.store.CreateWerkzeug(r.Context(), in); err != nil { data := werkzeugFormDataFromRequest(r, "Werkzeug anlegen", "/betreiber/werkzeuge", "", "Werkzeug konnte nicht angelegt werden: "+err.Error()) if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil { http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) } return } http.Redirect(w, r, "/betreiber/werkzeuge", http.StatusSeeOther) } // handleBetreiberWerkzeugEditForm zeigt einen zentralen Katalogeintrag // zum Bearbeiten. Ein mandantenspezifischer Eintrag (account_id // gesetzt) ist hier bewusst nicht erreichbar — das ist Sache des // jeweiligen Mandanten, nicht der Plattform. func (s *Server) handleBetreiberWerkzeugEditForm(w http.ResponseWriter, r *http.Request) { wz, err := s.store.GetWerkzeug(r.Context(), r.PathValue("id")) if err != nil || wz.AccountID != nil { http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound) return } data := werkzeugFormDataFromWerkzeug(r, wz, "Werkzeug bearbeiten", "/betreiber/werkzeuge") if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil { http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) } } // handleBetreiberWerkzeugUpdate speichert Änderungen an einem zentralen // Katalogeintrag. Werkzeug ist nicht append-only (siehe store.UpdateWerkzeug) // — eine bereits getroffene Entscheidung hält ihren eigenen, zum // Entscheidungszeitpunkt eingefrorenen Snapshot (entscheidung.werkzeug_snapshot). func (s *Server) handleBetreiberWerkzeugUpdate(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") wz, err := s.store.GetWerkzeug(r.Context(), id) if err != nil || wz.AccountID != nil { http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound) return } if err := r.ParseForm(); err != nil { http.Error(w, "ungültiges Formular", http.StatusBadRequest) return } in, errMsg := werkzeugInputFromForm(r, nil) if errMsg != "" { data := werkzeugFormDataFromRequest(r, "Werkzeug bearbeiten", "/betreiber/werkzeuge", id, errMsg) if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil { http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) } return } if _, err := s.store.UpdateWerkzeug(r.Context(), id, in); err != nil { data := werkzeugFormDataFromRequest(r, "Werkzeug bearbeiten", "/betreiber/werkzeuge", id, "Werkzeug konnte nicht gespeichert werden: "+err.Error()) if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil { http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) } return } http.Redirect(w, r, "/betreiber/werkzeuge", http.StatusSeeOther) } // handleBetreiberWerkzeugDelete entfernt einen zentralen Katalogeintrag. func (s *Server) handleBetreiberWerkzeugDelete(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") wz, err := s.store.GetWerkzeug(r.Context(), id) if err != nil || wz.AccountID != nil { http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound) return } if err := s.store.DeleteWerkzeug(r.Context(), id); err != nil { http.Error(w, "Werkzeug konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/betreiber/werkzeuge", http.StatusSeeOther) }