From 4358dbaa0d9b41a83c0a479689ae57457c750849 Mon Sep 17 00:00:00 2001 From: noroot Date: Mon, 31 Aug 2026 12:08:05 +0200 Subject: [PATCH] feat: Werkzeugkatalog als Tabelle, Aufbewahrung nullable, kontrolliertes Zweck-Vokabular, Subprozessoren MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Katalog (zentral + mandantenseitig) zeigt jetzt eine mehrspaltige Tabelle statt einer Liste. aufbewahrung_tage ist nullable (NULL = vom Anbieter nicht beziffert, unterscheidbar von echter 0-Tage- Zusicherung). geeignete_zwecke ist Checkbox-Auswahl aus den sechs Fragebogen-Zweck-Kategorien statt Freitext. Neues Feld subprozessoren macht Unterauftragsverarbeiter durchsuchbar statt nur Freitext in Einschränkungen. 180-Tage-Frische-Markierung in beiden Katalogansichten ergänzt. --- CLAUDE.md | 72 +++++++++-- .../migrations/0015_werkzeug_details.down.sql | 5 + .../migrations/0015_werkzeug_details.up.sql | 12 ++ internal/store/werkzeug.go | 31 +++-- internal/store/werkzeug_test.go | 27 +++- internal/web/antrag_handlers.go | 6 +- internal/web/betreiber_werkzeug_handlers.go | 120 +++++++++++++++--- internal/web/mandant_werkzeug_handlers.go | 52 +++++--- .../templates/betreiber_werkzeug_form.html | 13 +- .../web/templates/betreiber_werkzeuge.html | 35 +++-- internal/web/templates/mandant_werkzeuge.html | 77 +++++++---- 11 files changed, 352 insertions(+), 98 deletions(-) create mode 100644 internal/store/migrations/0015_werkzeug_details.down.sql create mode 100644 internal/store/migrations/0015_werkzeug_details.up.sql diff --git a/CLAUDE.md b/CLAUDE.md index 6a54673..597a0fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -141,15 +141,21 @@ Historie zerstören (dasselbe Muster wie bei Nutzern: deaktivieren statt löschen, aber ein Sperren/Deaktivieren-Zustand für Accounts existiert noch nicht, siehe unten). -**Weiterhin nicht gebaut:** Anmeldeverfahren-Konfiguration, ein echtes -Abo-System (Preismodell und Zahlungsanbieter mit dem Nutzer am -2026-08-30 grundsätzlich geklärt — 3 €/Mitarbeiter/Monat, Mindestabnahme -10 Mitarbeiter, 14 Tage Testphase, Stripe mit SEPA-Lastschrift — die -eigentliche Umsetzung wartet noch auf einen Stripe-Account/Testmodus- -Zugangsdaten), Sperren/Deaktivieren eines Accounts durch den Betreiber -(hängt weiter an der Abrechnungs-/Freischaltungs-Architektur, siehe -Offene Punkte: `account.verified` wurde beim Produktwechsel sogar -entfernt). +**Weiterhin nicht gebaut:** Anmeldeverfahren-Konfiguration, das +Abo-System. **Wichtige Korrektur (2026-08-31):** die eigentliche +Zahlungsabwicklung/Rechnungsstellung läuft über ein **anderes, +externes System** — Deklarix baut dafür **kein Stripe/Zahlungs-SDK**. +Deklarix bildet nur den **Subskriptionsstatus** ab (Plan, gebuchte +Mitarbeiterzahl, Status wie `test`/`aktiv`/`gekündigt`, Ablaufdatum), +der von außen gesetzt wird — noch offen, wie genau ("von außen" heißt +aktuell: manuell durch den Betreiber, eine Schnittstelle zum externen +Abrechnungssystem ist nicht spezifiziert). Das Preismodell selbst +(3 €/Mitarbeiter/Monat, Mindestabnahme 10 Mitarbeiter, 14 Tage +Testphase) bleibt als Orientierung gültig, nur der Stripe-Teil der +Empfehlung vom 2026-08-29 entfällt. Sperren/Deaktivieren eines Accounts +durch den Betreiber hängt weiter an dieser (jetzt kleineren) +Subskriptions-Architektur (siehe Offene Punkte: `account.verified` +wurde beim Produktwechsel sogar entfernt). **Mandantenfähigkeit:** jede Tabelle trägt `account_id`. Aktuell wird Isolation in der Anwendungsschicht erzwungen (Handler vergleichen @@ -248,8 +254,52 @@ zentralen Katalog zu verändern. Zusicherung im Katalog (AVV verfügbar, Verarbeitungsort, Training-Opt- out, Zertifizierungen) muss belegbar sein, sonst haftet Deklarix für eine Aussage, die nicht nachweisbar ist. Einträge älter als 180 Tage -sollen im Ergebnis als "nicht aktuell geprüft" markiert werden (noch -nicht in der UI umgesetzt, siehe Offene Punkte). +werden im zentralen (`GET /betreiber/werkzeuge`) und im mandanten- +seitigen Katalog (`GET /verwaltung/werkzeuge`) als "nicht aktuell +geprüft" markiert (`werkzeugPruefungVeraltetNach`, 180 Tage). + +`aufbewahrung_tage` ist NULLABLE (Migration 0015) — `NULL` bedeutet +"vom Anbieter nicht beziffert" und ist bewusst von `0` ("belegte +Zusicherung: sofortige Löschung", z. B. DeepL) unterschieden. Vorher +war die Spalte `NOT NULL DEFAULT 0`, wodurch beide Fälle identisch +aussahen — `formatAufbewahrung()` zeigt entsprechend "unbekannt" statt +"0 Tage". + +`geeignete_zwecke` ist seit derselben Migration ein kontrolliertes +Vokabular statt Freitext: das Formular zeigt Checkboxen für genau die +sechs Zweck-Kategorien aus dem Fragebogen (Textgenerierung, +Übersetzung, Code, Bild, Transkription, Recherche — `gueltigeZwecke` in +`betreiber_werkzeug_handlers.go`), serverseitig validiert +(`istGueltigerZweck`). Grund: ein künftiger automatischer Zweckabgleich +(Bewertungslogik Schritt 3, weiterhin nicht gebaut) würde an +inkonsistentem Freitext ("Text" statt "Textgenerierung") scheitern. + +Neues Feld `subprozessoren` (TEXT[], Migration 0015) macht +Unterauftragsverarbeiter (z. B. Anthropic bei Microsoft 365 Copilot, +AWS bei DeepL) als eigenes, durchsuchbares Feld sichtbar statt nur als +Freitext in `einschraenkungen` — das ist oft der Grund, warum ein +scheinbar EU-verarbeitetes Werkzeug die USA doch nicht ausschließt. + +Sowohl der zentrale Katalog (`GET /betreiber/werkzeuge`) als auch die +mandantenseitige Sicht (`GET /verwaltung/werkzeuge`) zeigen den Katalog +als Tabelle (Name, Anbieter, Verarbeitungsort, AVV, Training aus, +Aufbewahrung, Zertifizierungen, Geeignete Zwecke, Zuletzt geprüft) statt +als Liste — vorher waren nur Name/Anbieter/Ort auf einen Blick sichtbar, +der Rest war erst nach Klick auf den Detaileintrag zu sehen. + +Der zentrale Katalog ist aktuell mit 10 recherchierten Einträgen befüllt +(ChatGPT Enterprise, Microsoft 365 Copilot, Claude for Work, GitHub +Copilot Business, DeepL Pro/API, Google Gemini für Google Workspace, +Le Chat Enterprise, Perplexity Enterprise Pro, Adobe Firefly Enterprise, +Otter.ai Business) — deckt damit erstmals alle sechs Zweck-Kategorien +ab (vorher fehlten Bild und Transkription vollständig, Recherche hatte +kein dediziertes Werkzeug). Jede Zusicherung ist mit einer echten, +offiziellen Anbieter-Quelle belegt (Stand 2026-08-31), nicht geraten — +zwei Einträge (Adobe Firefly: Trainingsausschluss ist nicht Standard; +Otter.ai: Training auf de-identifizierten Aufnahmen, laufende +Rechtsstreitigkeiten) sind bewusst mit kritischen Einschränkungen +versehen statt schöngefärbt, das ist die vorgesehene Funktion des +Katalogs. `CurrentKatalogVersion` liefert eine reproduzierbare Kennung des aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird in diff --git a/internal/store/migrations/0015_werkzeug_details.down.sql b/internal/store/migrations/0015_werkzeug_details.down.sql new file mode 100644 index 0000000..1a34407 --- /dev/null +++ b/internal/store/migrations/0015_werkzeug_details.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE werkzeug DROP COLUMN subprozessoren; + +UPDATE werkzeug SET aufbewahrung_tage = 0 WHERE aufbewahrung_tage IS NULL; +ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage SET DEFAULT 0; +ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage SET NOT NULL; diff --git a/internal/store/migrations/0015_werkzeug_details.up.sql b/internal/store/migrations/0015_werkzeug_details.up.sql new file mode 100644 index 0000000..6d947fc --- /dev/null +++ b/internal/store/migrations/0015_werkzeug_details.up.sql @@ -0,0 +1,12 @@ +-- aufbewahrung_tage war bisher NOT NULL DEFAULT 0 — das machte "0" doppeldeutig: +-- mal eine echte, belegte Zusicherung (z. B. DeepL: sofortige Löschung), +-- mal ein Platzhalter für "vom Anbieter nicht beziffert". NULL trennt das +-- sauber: "unbekannt" statt "angeblich sofort gelöscht". +ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage DROP NOT NULL; +ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage DROP DEFAULT; + +-- Subprozessoren (z. B. Anthropic als Unterauftragsverarbeiter bei +-- Microsoft 365 Copilot, AWS bei DeepL) verändern die EU/USA-Einstufung +-- eines Werkzeugs materiell — bisher nur als Freitext in +-- "einschraenkungen" erfasst, jetzt als eigenes, durchsuchbares Feld. +ALTER TABLE werkzeug ADD COLUMN subprozessoren TEXT[] NOT NULL DEFAULT '{}'; diff --git a/internal/store/werkzeug.go b/internal/store/werkzeug.go index c69a252..9f4b0ec 100644 --- a/internal/store/werkzeug.go +++ b/internal/store/werkzeug.go @@ -25,8 +25,9 @@ type Werkzeug struct { AVVURL string TrainingOptOut bool TrainingStandard bool - AufbewahrungTage int + AufbewahrungTage *int // nil = vom Anbieter nicht beziffert, NICHT gleichbedeutend mit 0 Tagen Zertifizierungen []string + Subprozessoren []string GeeigneteZwecke []string Einschraenkungen []string LetztePruefung time.Time @@ -36,7 +37,7 @@ type Werkzeug struct { } // WerkzeugInput bündelt die Felder eines Werkzeug-Eintrags für -// Create/Update — bei 14 Feldern lesbarer als eine positionale +// Create/Update — bei 15 Feldern lesbarer als eine positionale // Parameterliste. type WerkzeugInput struct { AccountID *string @@ -47,8 +48,9 @@ type WerkzeugInput struct { AVVURL string TrainingOptOut bool TrainingStandard bool - AufbewahrungTage int + AufbewahrungTage *int Zertifizierungen []string + Subprozessoren []string GeeigneteZwecke []string Einschraenkungen []string LetztePruefung time.Time @@ -56,8 +58,8 @@ type WerkzeugInput struct { } const werkzeugColumns = `id, account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url, - training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen, geeignete_zwecke, - einschraenkungen, letzte_pruefung, quelle, created_at, updated_at` + training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen, subprozessoren, + geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle, created_at, updated_at` func scanWerkzeug(row interface { Scan(dest ...any) error @@ -65,8 +67,8 @@ func scanWerkzeug(row interface { var w Werkzeug err := row.Scan( &w.ID, &w.AccountID, &w.Name, &w.Anbieter, &w.Verarbeitungsort, &w.AVVVerfuegbar, &w.AVVURL, - &w.TrainingOptOut, &w.TrainingStandard, &w.AufbewahrungTage, &w.Zertifizierungen, &w.GeeigneteZwecke, - &w.Einschraenkungen, &w.LetztePruefung, &w.Quelle, &w.CreatedAt, &w.UpdatedAt, + &w.TrainingOptOut, &w.TrainingStandard, &w.AufbewahrungTage, &w.Zertifizierungen, &w.Subprozessoren, + &w.GeeigneteZwecke, &w.Einschraenkungen, &w.LetztePruefung, &w.Quelle, &w.CreatedAt, &w.UpdatedAt, ) return w, err } @@ -86,6 +88,9 @@ func normalizeWerkzeugSlices(in *WerkzeugInput) { if in.Einschraenkungen == nil { in.Einschraenkungen = []string{} } + if in.Subprozessoren == nil { + in.Subprozessoren = []string{} + } } // CreateWerkzeug legt einen Katalogeintrag an. @@ -94,12 +99,12 @@ func (s *Store) CreateWerkzeug(ctx context.Context, in WerkzeugInput) (Werkzeug, row := s.Pool.QueryRow(ctx, ` INSERT INTO werkzeug ( account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url, - training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen, + training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen, subprozessoren, geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING `+werkzeugColumns, in.AccountID, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL, - in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen, + in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren, in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle, ) w, err := scanWerkzeug(row) @@ -120,12 +125,12 @@ func (s *Store) UpdateWerkzeug(ctx context.Context, id string, in WerkzeugInput) UPDATE werkzeug SET name = $2, anbieter = $3, verarbeitungsort = $4, avv_verfuegbar = $5, avv_url = $6, training_opt_out = $7, training_standard = $8, aufbewahrung_tage = $9, - zertifizierungen = $10, geeignete_zwecke = $11, einschraenkungen = $12, - letzte_pruefung = $13, quelle = $14, updated_at = now() + zertifizierungen = $10, subprozessoren = $11, geeignete_zwecke = $12, einschraenkungen = $13, + letzte_pruefung = $14, quelle = $15, updated_at = now() WHERE id = $1 RETURNING `+werkzeugColumns, id, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL, - in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen, + in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren, in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle, ) w, err := scanWerkzeug(row) diff --git a/internal/store/werkzeug_test.go b/internal/store/werkzeug_test.go index 69d167a..95ffbbd 100644 --- a/internal/store/werkzeug_test.go +++ b/internal/store/werkzeug_test.go @@ -9,6 +9,8 @@ import ( "github.com/netcell-it/deklarix/internal/store" ) +func intPtr(v int) *int { return &v } + func testWerkzeugInput(accountID *string, name string) store.WerkzeugInput { return store.WerkzeugInput{ AccountID: accountID, @@ -19,8 +21,9 @@ func testWerkzeugInput(accountID *string, name string) store.WerkzeugInput { AVVURL: "https://beispiel.example/avv", TrainingOptOut: true, TrainingStandard: true, - AufbewahrungTage: 30, + AufbewahrungTage: intPtr(30), Zertifizierungen: []string{"ISO 27001"}, + Subprozessoren: []string{"Beispiel-Subprozessor Inc."}, GeeigneteZwecke: []string{"Textgenerierung"}, Einschraenkungen: nil, LetztePruefung: time.Now().Add(-24 * time.Hour).Truncate(time.Millisecond), @@ -42,6 +45,24 @@ func TestWerkzeugCRUD(t *testing.T) { if len(w.Zertifizierungen) != 1 || w.Zertifizierungen[0] != "ISO 27001" { t.Fatalf("Zertifizierungen = %v, want [ISO 27001]", w.Zertifizierungen) } + if len(w.Subprozessoren) != 1 || w.Subprozessoren[0] != "Beispiel-Subprozessor Inc." { + t.Fatalf("Subprozessoren = %v, want [Beispiel-Subprozessor Inc.]", w.Subprozessoren) + } + if w.AufbewahrungTage == nil || *w.AufbewahrungTage != 30 { + t.Fatalf("AufbewahrungTage = %v, want 30", w.AufbewahrungTage) + } + + unbekannt, err := s.CreateWerkzeug(ctx, func() store.WerkzeugInput { + in := testWerkzeugInput(nil, "Werkzeug ohne Aufbewahrungsangabe") + in.AufbewahrungTage = nil + return in + }()) + if err != nil { + t.Fatalf("CreateWerkzeug (ohne Aufbewahrungsangabe): %v", err) + } + if unbekannt.AufbewahrungTage != nil { + t.Fatalf("AufbewahrungTage = %v, want nil (unbekannt)", *unbekannt.AufbewahrungTage) + } got, err := s.GetWerkzeug(ctx, w.ID) if err != nil { @@ -52,12 +73,12 @@ func TestWerkzeugCRUD(t *testing.T) { } updateInput := testWerkzeugInput(nil, "Zentrales Werkzeug (aktualisiert)") - updateInput.AufbewahrungTage = 14 + updateInput.AufbewahrungTage = intPtr(14) updated, err := s.UpdateWerkzeug(ctx, w.ID, updateInput) if err != nil { t.Fatalf("UpdateWerkzeug: %v", err) } - if updated.Name != "Zentrales Werkzeug (aktualisiert)" || updated.AufbewahrungTage != 14 { + if updated.Name != "Zentrales Werkzeug (aktualisiert)" || updated.AufbewahrungTage == nil || *updated.AufbewahrungTage != 14 { t.Fatalf("UpdateWerkzeug = %+v, unerwartete Werte", updated) } diff --git a/internal/web/antrag_handlers.go b/internal/web/antrag_handlers.go index ff52ca2..0244843 100644 --- a/internal/web/antrag_handlers.go +++ b/internal/web/antrag_handlers.go @@ -289,9 +289,13 @@ func (s *Server) bewerteAntrag(ctx context.Context, antragID, accountID string, } 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, Verarbeitungsort: w.Verarbeitungsort, - TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: w.AufbewahrungTage, + TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: aufbewahrungTage, }} } zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, anforderungIDs) diff --git a/internal/web/betreiber_werkzeug_handlers.go b/internal/web/betreiber_werkzeug_handlers.go index 134fffe..82b63b3 100644 --- a/internal/web/betreiber_werkzeug_handlers.go +++ b/internal/web/betreiber_werkzeug_handlers.go @@ -25,6 +25,23 @@ func istGueltigerVerarbeitungsort(v string) bool { return false } +// gueltigeZwecke ist das kontrollierte Vokabular für "geeignete Zwecke" +// — exakt die sechs Zweck-Kategorien aus dem Fragebogen/der geplanten +// Zweckabgleich-Stufe (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. +var gueltigeZwecke = []string{"Textgenerierung", "Übersetzung", "Code", "Bild", "Transkription", "Recherche"} + +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. @@ -44,7 +61,26 @@ type werkzeugListItem struct { Name string Anbieter string Verarbeitungsort string + AVVVerfuegbar bool + TrainingStandard bool + Aufbewahrung string + Zertifizierungen string + GeeigneteZwecke string LetztePruefung string + NichtAktuell bool +} + +// 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 { @@ -53,6 +89,12 @@ type werkzeugListeData struct { Werkzeuge []werkzeugListItem } +// werkzeugPruefungVeraltetNach ist die Schwelle, ab der ein +// Katalogeintrag im Ergebnis als "nicht aktuell geprüft" markiert wird +// (siehe CLAUDE.md, Werkzeugkatalog) — jede Zusicherung im Katalog muss +// belegbar UND aktuell sein. +const werkzeugPruefungVeraltetNach = 180 * 24 * time.Hour + // handleBetreiberWerkzeugListe zeigt den zentralen Katalog. func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Request) { werkzeuge, err := s.store.ListZentraleWerkzeuge(r.Context()) @@ -64,7 +106,11 @@ func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Req for _, wz := range werkzeuge { 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"), + NichtAktuell: time.Since(wz.LetztePruefung) > werkzeugPruefungVeraltetNach, }) } if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeuge", data); err != nil { @@ -89,17 +135,52 @@ type werkzeugFormData struct { AVVURL string TrainingOptOut bool TrainingStandard bool - AufbewahrungTage int + AufbewahrungTage string // leer = vom Anbieter nicht beziffert (unbekannt), NICHT "0" Zertifizierungen string - GeeigneteZwecke 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} + 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) } @@ -123,7 +204,21 @@ func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugIn if name == "" || anbieter == "" || quelle == "" { return store.WerkzeugInput{}, "Name, Anbieter und Quelle sind Pflicht" } - aufbewahrungTage, _ := strconv.Atoi(r.FormValue("aufbewahrung_tage")) + 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, @@ -136,7 +231,8 @@ func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugIn TrainingStandard: r.FormValue("training_standard") == "on", AufbewahrungTage: aufbewahrungTage, Zertifizierungen: splitListe(r.FormValue("zertifizierungen")), - GeeigneteZwecke: splitListe(r.FormValue("geeignete_zwecke")), + Subprozessoren: splitListe(r.FormValue("subprozessoren")), + GeeigneteZwecke: geeigneteZwecke, Einschraenkungen: splitListe(r.FormValue("einschraenkungen")), LetztePruefung: letztePruefung, Quelle: quelle, @@ -152,7 +248,9 @@ func werkzeugFormDataFromRequest(r *http.Request, title, actionBase, id, errMsg 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", - Zertifizierungen: r.FormValue("zertifizierungen"), GeeigneteZwecke: r.FormValue("geeignete_zwecke"), + 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, } @@ -192,15 +290,7 @@ func (s *Server) handleBetreiberWerkzeugEditForm(w http.ResponseWriter, r *http. http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound) return } - data := werkzeugFormData{ - Title: "Werkzeug bearbeiten", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge", 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: wz.AufbewahrungTage, - Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "), - Einschraenkungen: strings.Join(wz.Einschraenkungen, ", "), LetztePruefung: wz.LetztePruefung.Format("2006-01-02"), - Quelle: wz.Quelle, - } + 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) } diff --git a/internal/web/mandant_werkzeug_handlers.go b/internal/web/mandant_werkzeug_handlers.go index 02a8b30..219c1de 100644 --- a/internal/web/mandant_werkzeug_handlers.go +++ b/internal/web/mandant_werkzeug_handlers.go @@ -11,19 +11,29 @@ package web import ( "net/http" "strings" + "time" ) type mandantWerkzeugItem struct { - ID string - Name string - Anbieter string - Gesperrt bool + ID string + Name string + Anbieter string + Verarbeitungsort string + AVVVerfuegbar bool + TrainingStandard bool + Aufbewahrung string + GeeigneteZwecke string + LetztePruefung string + NichtAktuell bool + Gesperrt bool } type eigenesWerkzeugItem struct { - ID string - Name string - Anbieter string + ID string + Name string + Anbieter string + Verarbeitungsort string + LetztePruefung string } type mandantWerkzeugListeData struct { @@ -62,14 +72,23 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque data := mandantWerkzeugListeData{Title: "Werkzeugkatalog", Nav: navFor(r)} for _, wz := range zentral { data.Werkzeuge = append(data.Werkzeuge, mandantWerkzeugItem{ - ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Gesperrt: gesperrt[wz.ID], + ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort, + AVVVerfuegbar: wz.AVVVerfuegbar, TrainingStandard: wz.TrainingStandard, + Aufbewahrung: formatAufbewahrung(wz.AufbewahrungTage), + GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "), + LetztePruefung: wz.LetztePruefung.Format("02.01.2006"), + NichtAktuell: time.Since(wz.LetztePruefung) > werkzeugPruefungVeraltetNach, + Gesperrt: gesperrt[wz.ID], }) } for _, wz := range eigene { if wz.AccountID == nil { continue // zentral, oben schon gelistet } - data.EigeneWerkzeuge = append(data.EigeneWerkzeuge, eigenesWerkzeugItem{ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter}) + data.EigeneWerkzeuge = append(data.EigeneWerkzeuge, eigenesWerkzeugItem{ + ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort, + LetztePruefung: wz.LetztePruefung.Format("02.01.2006"), + }) } if err := s.templates.ExecuteTemplate(w, "mandant-werkzeuge", data); err != nil { http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) @@ -77,7 +96,10 @@ func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Reque } func (s *Server) handleMandantWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) { - data := werkzeugFormData{Title: "Eigenes Werkzeug anlegen", Nav: navFor(r), ActionBase: "/verwaltung/werkzeuge", Verarbeitungsorte: gueltigeVerarbeitungsorte} + data := werkzeugFormData{ + Title: "Eigenes Werkzeug anlegen", Nav: navFor(r), ActionBase: "/verwaltung/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) } @@ -121,15 +143,7 @@ func (s *Server) handleMandantWerkzeugEditForm(w http.ResponseWriter, r *http.Re http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound) return } - data := werkzeugFormData{ - Title: "Eigenes Werkzeug bearbeiten", Nav: navFor(r), ActionBase: "/verwaltung/werkzeuge", 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: wz.AufbewahrungTage, - Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "), - Einschraenkungen: strings.Join(wz.Einschraenkungen, ", "), LetztePruefung: wz.LetztePruefung.Format("2006-01-02"), - Quelle: wz.Quelle, - } + data := werkzeugFormDataFromWerkzeug(r, wz, "Eigenes Werkzeug bearbeiten", "/verwaltung/werkzeuge") if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil { http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) } diff --git a/internal/web/templates/betreiber_werkzeug_form.html b/internal/web/templates/betreiber_werkzeug_form.html index e3cda6d..1efefb0 100644 --- a/internal/web/templates/betreiber_werkzeug_form.html +++ b/internal/web/templates/betreiber_werkzeug_form.html @@ -34,14 +34,21 @@ - + - - + + + +
+ Geeignete Zwecke + {{range .ZweckeOptionen}} + + {{end}} +
diff --git a/internal/web/templates/betreiber_werkzeuge.html b/internal/web/templates/betreiber_werkzeuge.html index a1b22c4..f0b4f9a 100644 --- a/internal/web/templates/betreiber_werkzeuge.html +++ b/internal/web/templates/betreiber_werkzeuge.html @@ -9,15 +9,32 @@ {{if not .Werkzeuge}}

Noch keine zentralen Katalogeinträge.

{{else}} - +
+ + + + + + + + + + {{range .Werkzeuge}} + + + + + + + + + + + + {{end}} + +
NameAnbieterVerarbeitungsortAVVTraining ausAufbewahrungZertifizierungenGeeignete ZweckeZuletzt geprüft
{{.Name}}{{.Anbieter}}{{.Verarbeitungsort}}{{if .AVVVerfuegbar}}ja{{else}}nein{{end}}{{if .TrainingStandard}}ja{{else}}nein{{end}}{{.Aufbewahrung}}{{.Zertifizierungen}}{{.GeeigneteZwecke}}{{.LetztePruefung}}{{if .NichtAktuell}} nicht aktuell geprüft{{end}}
+
{{end}}

Werkzeug anlegen

diff --git a/internal/web/templates/mandant_werkzeuge.html b/internal/web/templates/mandant_werkzeuge.html index 1750f0e..eb89876 100644 --- a/internal/web/templates/mandant_werkzeuge.html +++ b/internal/web/templates/mandant_werkzeuge.html @@ -15,24 +15,43 @@ {{if not .Werkzeuge}}

Der zentrale Katalog ist noch leer.

{{else}} -