Sechs neue Zweck-Kategorien (Datenanalyse, Video, Audio/Sprache, Kundenservice/Chatbot, Präsentation/Design, Automatisierung/Agenten) ergänzen die ursprünglichen sechs — deckt jetzt die tatsächliche Bandbreite an Einsatzgebieten ab statt nur klassischer Wissensarbeit. Werkzeug-Formular (Betreiber + Mandant, gleiche Vorlage) neu strukturiert: Karten-Layout mit thematischen Abschnitten, 2-Spalten-Grid für kurze Felder, Chip-Gruppen statt langer Checkbox-Listen, abgesetzte Gefahrenzone für den Löschen-Button. Dabei einen vorbestehenden Bug gefunden und behoben: das Lösch-Formular war fest auf die Betreiber-Route verdrahtet, obwohl dieselbe Vorlage auch vom Mandanten-Formular genutzt wird.
375 lines
15 KiB
Go
375 lines
15 KiB
Go
// 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"
|
|
)
|
|
|
|
var gueltigeVerarbeitungsorte = []string{"EU", "USA", "gemischt", "on-prem"}
|
|
|
|
func istGueltigerVerarbeitungsort(v string) bool {
|
|
for _, o := range gueltigeVerarbeitungsorte {
|
|
if o == 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
|
|
Verarbeitungsort string
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
data := werkzeugListeData{Title: "Werkzeugkatalog", Nav: navFor(r)}
|
|
for _, wz := range werkzeuge {
|
|
label, css := pruefStatus(wz.LetztePruefung)
|
|
data.Werkzeuge = append(data.Werkzeuge, werkzeugListItem{
|
|
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
|
|
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
|
|
Verarbeitungsorte []string
|
|
Verarbeitungsort string
|
|
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
|
|
}
|
|
|
|
// zweckeAusgewaehltVon baut die Checkbox-Vorbelegung aus einer bereits
|
|
// gespeicherten oder erneut eingereichten Liste von Zwecken.
|
|
func zweckeAusgewaehltVon(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, Verarbeitungsorte: gueltigeVerarbeitungsorte,
|
|
Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
|
|
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: zweckeAusgewaehltVon(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",
|
|
Verarbeitungsorte: gueltigeVerarbeitungsorte, 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"
|
|
}
|
|
verarbeitungsort := r.FormValue("verarbeitungsort")
|
|
if !istGueltigerVerarbeitungsort(verarbeitungsort) {
|
|
return store.WerkzeugInput{}, "ungültiger Verarbeitungsort"
|
|
}
|
|
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,
|
|
Verarbeitungsort: verarbeitungsort,
|
|
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: 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, Verarbeitungsorte: gueltigeVerarbeitungsorte,
|
|
Name: r.FormValue("name"), Anbieter: r.FormValue("anbieter"), Verarbeitungsort: r.FormValue("verarbeitungsort"),
|
|
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: zweckeAusgewaehltVon(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)
|
|
}
|