From cff8c9eadff8e3f796cb5936b396910aefacd2e2 Mon Sep 17 00:00:00 2001 From: noroot Date: Sat, 29 Aug 2026 12:42:48 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Werkzeugkatalog-Pflege=20f=C3=BCr=20die?= =?UTF-8?q?=20Plattform=20(Ebene=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Betreiber können den zentralen Werkzeugkatalog jetzt über die UI pflegen (GET /betreiber/werkzeuge, GET/POST .../neu, GET/POST .../{id}, POST .../{id}/loeschen) statt nur per SQL. Bearbeitet ausschließlich zentrale (account_id IS NULL) Einträge — ein mandantenspezifischer Katalogeintrag bleibt über diese Seiten unerreichbar (404), das ist weiterhin Sache des jeweiligen Mandanten. Ohne befüllten Katalog konnte bisher keine Bewertung tatsächlich zu "genehmigt" mit einem echten Werkzeug führen. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 14 +- internal/web/betreiber_handlers.go | 9 +- internal/web/betreiber_werkzeug_handlers.go | 250 ++++++++++++++++++ .../web/betreiber_werkzeug_handlers_test.go | 118 +++++++++ internal/web/server.go | 10 + internal/web/server_test.go | 54 +++- .../web/templates/betreiber_dashboard.html | 1 + .../templates/betreiber_werkzeug_form.html | 65 +++++ .../web/templates/betreiber_werkzeuge.html | 26 ++ 9 files changed, 542 insertions(+), 5 deletions(-) create mode 100644 internal/web/betreiber_werkzeug_handlers.go create mode 100644 internal/web/betreiber_werkzeug_handlers_test.go create mode 100644 internal/web/templates/betreiber_werkzeug_form.html create mode 100644 internal/web/templates/betreiber_werkzeuge.html diff --git a/CLAUDE.md b/CLAUDE.md index 1d86fa3..486ad9b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -625,7 +625,15 @@ journalctl -u deklarix -f hat; `app_user.role` ist aktuell ein einzelner Wert. Muss geklärt werden: zwei Rollen pro Nutzer zulassen (Datenmodell-Änderung) oder zwei `app_user`-Zeilen für dieselbe Person? -- **Zentraler Werkzeugkatalog ist leer** — "Werkzeugkatalog aus der - Plattform vorbelegt" beim Onboarding ist nicht automatisiert, es gibt - noch keine Ebene-5/Betreiber-UI zur Katalogpflege. +- **Zentraler Werkzeugkatalog muss noch befüllt werden** — die + Ebene-5/Betreiber-UI zur Katalogpflege ist jetzt gebaut (`GET + /betreiber/werkzeuge` Liste, `GET/POST /betreiber/werkzeuge/neu` + anlegen, `GET/POST /betreiber/werkzeuge/{id}` bearbeiten, `POST + .../loeschen` löschen — siehe `internal/web/betreiber_werkzeug_handlers.go`. + Bearbeitet ausschließlich `account_id IS NULL`-Einträge, ein + mandantenspezifischer Eintrag ist über diese Seiten nicht erreichbar + [404]). "Werkzeugkatalog aus der Plattform vorbelegt" beim Firma- + Onboarding bleibt trotzdem nicht automatisiert — der Katalog ist + inhaltlich weiterhin leer, bis der Betreiber tatsächlich Einträge + pflegt. - Markenanmeldung Deklarix, sobald erster Kunde zahlt. diff --git a/internal/web/betreiber_handlers.go b/internal/web/betreiber_handlers.go index a9a9c5c..8ee4715 100644 --- a/internal/web/betreiber_handlers.go +++ b/internal/web/betreiber_handlers.go @@ -8,6 +8,7 @@ type betreiberDashboardData struct { Title string Nav navData AccountCount int + WerkzeugCount int RecentAuditCount int } @@ -21,6 +22,11 @@ func (s *Server) handleBetreiberDashboard(w http.ResponseWriter, r *http.Request http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) return } + werkzeuge, err := s.store.ListZentraleWerkzeuge(ctx) + if err != nil { + http.Error(w, "Werkzeugkatalog konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError) + return + } auditLog, err := s.store.ListAuditLog(ctx, 5) if err != nil { http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError) @@ -28,7 +34,8 @@ func (s *Server) handleBetreiberDashboard(w http.ResponseWriter, r *http.Request } data := betreiberDashboardData{ - Title: "Plattform", Nav: navFor(r), AccountCount: len(accounts), RecentAuditCount: len(auditLog), + Title: "Plattform", Nav: navFor(r), AccountCount: len(accounts), + WerkzeugCount: len(werkzeuge), RecentAuditCount: len(auditLog), } if err := s.templates.ExecuteTemplate(w, "betreiber-dashboard", data); err != nil { http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) diff --git a/internal/web/betreiber_werkzeug_handlers.go b/internal/web/betreiber_werkzeug_handlers.go new file mode 100644 index 0000000..063b8ee --- /dev/null +++ b/internal/web/betreiber_werkzeug_handlers.go @@ -0,0 +1,250 @@ +// 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 +} + +// 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 + LetztePruefung string +} + +type werkzeugListeData struct { + Title string + Nav navData + Werkzeuge []werkzeugListItem +} + +// 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 { + data.Werkzeuge = append(data.Werkzeuge, werkzeugListItem{ + 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, "betreiber-werkzeuge", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} + +type werkzeugFormData struct { + Title string + Nav navData + ID string // leer bei "neu" + Name string + Anbieter string + Verarbeitungsorte []string + Verarbeitungsort string + AVVVerfuegbar bool + AVVURL string + TrainingOptOut bool + TrainingStandard bool + AufbewahrungTage int + Zertifizierungen string + GeeigneteZwecke string + Einschraenkungen string + LetztePruefung string + Quelle string + Error string +} + +func (s *Server) handleBetreiberWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) { + data := werkzeugFormData{Title: "Werkzeug anlegen", Nav: navFor(r), Verarbeitungsorte: gueltigeVerarbeitungsorte} + 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 immer nil — diese Seiten pflegen ausschließlich den +// zentralen Katalog. +func werkzeugInputFromForm(r *http.Request) (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" + } + aufbewahrungTage, _ := strconv.Atoi(r.FormValue("aufbewahrung_tage")) + + return store.WerkzeugInput{ + AccountID: nil, + 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")), + GeeigneteZwecke: splitListe(r.FormValue("geeignete_zwecke")), + 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, id, errMsg string) werkzeugFormData { + return werkzeugFormData{ + Title: title, Nav: navFor(r), 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", + Zertifizierungen: r.FormValue("zertifizierungen"), GeeigneteZwecke: r.FormValue("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) + if errMsg != "" { + data := werkzeugFormDataFromRequest(r, "Werkzeug anlegen", "", 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", "", "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 := werkzeugFormData{ + Title: "Werkzeug bearbeiten", Nav: navFor(r), 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, + } + 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) + if errMsg != "" { + data := werkzeugFormDataFromRequest(r, "Werkzeug bearbeiten", 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", 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) +} diff --git a/internal/web/betreiber_werkzeug_handlers_test.go b/internal/web/betreiber_werkzeug_handlers_test.go new file mode 100644 index 0000000..cf77d34 --- /dev/null +++ b/internal/web/betreiber_werkzeug_handlers_test.go @@ -0,0 +1,118 @@ +package web_test + +import ( + "context" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/netcell-it/deklarix/internal/store" +) + +func werkzeugForm() url.Values { + return url.Values{ + "name": {"ChatGPT Enterprise"}, "anbieter": {"OpenAI"}, "verarbeitungsort": {"EU"}, + "avv_verfuegbar": {"on"}, "avv_url": {"https://example.com/avv"}, + "training_opt_out": {"on"}, "training_standard": {"on"}, "aufbewahrung_tage": {"30"}, + "zertifizierungen": {"ISO 27001, SOC 2"}, "geeignete_zwecke": {"Textgenerierung"}, + "einschraenkungen": {""}, "letzte_pruefung": {"2026-08-01"}, "quelle": {"https://example.com/beleg"}, + } +} + +func TestBetreiberCanCreateZentralesWerkzeug(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber") + + resp := postForm(t, s, betreiberCookie, "/betreiber/werkzeuge", werkzeugForm()) + if resp.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String()) + } + + var w store.Werkzeug + for _, wz := range fs.werkzeuge { + w = wz + } + if w.Name != "ChatGPT Enterprise" || w.AccountID != nil { + t.Fatalf("Werkzeug = %+v, unerwartete Werte", w) + } + if len(w.Zertifizierungen) != 2 { + t.Errorf("Zertifizierungen = %v, want 2 Einträge", w.Zertifizierungen) + } + + list := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge") + if !strings.Contains(list.Body.String(), "ChatGPT Enterprise") { + t.Errorf("expected the new werkzeug in the Katalogliste, got: %s", list.Body.String()) + } +} + +func TestBetreiberWerkzeugCreateRequiresLetztePruefungUndQuelle(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber") + + form := werkzeugForm() + form.Set("letzte_pruefung", "") + resp := postForm(t, s, betreiberCookie, "/betreiber/werkzeuge", form) + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (Formular mit Fehler) ohne letzte_pruefung", resp.Code) + } + if len(fs.werkzeuge) != 0 { + t.Errorf("expected no werkzeug to be created, got %d", len(fs.werkzeuge)) + } +} + +func TestBetreiberCanUpdateAndDeleteZentralesWerkzeug(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber") + postForm(t, s, betreiberCookie, "/betreiber/werkzeuge", werkzeugForm()) + var id string + for wid := range fs.werkzeuge { + id = wid + } + + editResp := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge/"+id) + if editResp.Code != http.StatusOK || !strings.Contains(editResp.Body.String(), "ChatGPT Enterprise") { + t.Fatalf("edit form status = %d, body: %s", editResp.Code, editResp.Body.String()) + } + + updateForm := werkzeugForm() + updateForm.Set("name", "ChatGPT Enterprise (aktualisiert)") + updateResp := postForm(t, s, betreiberCookie, "/betreiber/werkzeuge/"+id, updateForm) + if updateResp.Code != http.StatusSeeOther { + t.Fatalf("update status = %d, body: %s", updateResp.Code, updateResp.Body.String()) + } + if fs.werkzeuge[id].Name != "ChatGPT Enterprise (aktualisiert)" { + t.Errorf("Name = %q nach Update, unerwartet", fs.werkzeuge[id].Name) + } + + deleteResp := postForm(t, s, betreiberCookie, "/betreiber/werkzeuge/"+id+"/loeschen", url.Values{}) + if deleteResp.Code != http.StatusSeeOther { + t.Fatalf("delete status = %d, body: %s", deleteResp.Code, deleteResp.Body.String()) + } + if _, ok := fs.werkzeuge[id]; ok { + t.Error("expected the werkzeug to be deleted") + } +} + +// TestBetreiberCannotEditMandantenEigenesWerkzeug: ein mandantenspezifischer +// Katalogeintrag (account_id gesetzt) ist über die Betreiber-Seiten +// nicht erreichbar — das bleibt Sache des jeweiligen Mandanten. +func TestBetreiberCannotEditMandantenEigenesWerkzeug(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber") + acc, err := fs.CreateAccount(context.Background(), "Mandant mit eigenem Werkzeug") + if err != nil { + t.Fatalf("CreateAccount: %v", err) + } + eigenesID := "eigenes-werkzeug" + fs.werkzeuge[eigenesID] = store.Werkzeug{ID: eigenesID, AccountID: &acc.ID, Name: "Mandanten-eigenes Tool"} + + resp := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge/"+eigenesID) + if resp.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 for a mandanten-eigenes werkzeug", resp.Code) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index bc61c39..5cd8c28 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -50,7 +50,11 @@ type Store interface { ListAntraegeForAccount(ctx context.Context, accountID string) ([]store.Antrag, error) ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]store.Werkzeug, error) + ListZentraleWerkzeuge(ctx context.Context) ([]store.Werkzeug, error) GetWerkzeug(ctx context.Context, id string) (store.Werkzeug, error) + CreateWerkzeug(ctx context.Context, in store.WerkzeugInput) (store.Werkzeug, error) + UpdateWerkzeug(ctx context.Context, id string, in store.WerkzeugInput) (store.Werkzeug, error) + DeleteWerkzeug(ctx context.Context, id string) error CurrentKatalogVersion(ctx context.Context) (string, error) CreateBewertung(ctx context.Context, in store.BewertungInput) (store.Bewertung, error) GetLatestBewertungForAntrag(ctx context.Context, antragID string) (store.Bewertung, error) @@ -113,6 +117,12 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) { mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList)) mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail)) mux.HandleFunc("GET /betreiber/audit-log", s.requireBetreiber(s.handleBetreiberAuditLog)) + mux.HandleFunc("GET /betreiber/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugListe)) + mux.HandleFunc("GET /betreiber/werkzeuge/neu", s.requireBetreiber(s.handleBetreiberWerkzeugNeuForm)) + mux.HandleFunc("POST /betreiber/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugCreate)) + mux.HandleFunc("GET /betreiber/werkzeuge/{id}", s.requireBetreiber(s.handleBetreiberWerkzeugEditForm)) + mux.HandleFunc("POST /betreiber/werkzeuge/{id}", s.requireBetreiber(s.handleBetreiberWerkzeugUpdate)) + mux.HandleFunc("POST /betreiber/werkzeuge/{id}/loeschen", s.requireBetreiber(s.handleBetreiberWerkzeugDelete)) mux.Handle("GET /static/", http.FileServerFS(staticFS)) s.mux = mux diff --git a/internal/web/server_test.go b/internal/web/server_test.go index b5bfc8d..b83cca8 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -269,6 +269,58 @@ func (f *fakeStore) ListWerkzeugeForAccount(ctx context.Context, accountID strin return out, nil } +func (f *fakeStore) ListZentraleWerkzeuge(ctx context.Context) ([]store.Werkzeug, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []store.Werkzeug + for _, w := range f.werkzeuge { + if w.AccountID == nil { + out = append(out, w) + } + } + return out, nil +} + +func (f *fakeStore) CreateWerkzeug(ctx context.Context, in store.WerkzeugInput) (store.Werkzeug, error) { + f.mu.Lock() + defer f.mu.Unlock() + w := store.Werkzeug{ + ID: f.newID(), AccountID: in.AccountID, Name: in.Name, Anbieter: in.Anbieter, + Verarbeitungsort: in.Verarbeitungsort, AVVVerfuegbar: in.AVVVerfuegbar, AVVURL: in.AVVURL, + TrainingOptOut: in.TrainingOptOut, TrainingStandard: in.TrainingStandard, AufbewahrungTage: in.AufbewahrungTage, + Zertifizierungen: in.Zertifizierungen, GeeigneteZwecke: in.GeeigneteZwecke, Einschraenkungen: in.Einschraenkungen, + LetztePruefung: in.LetztePruefung, Quelle: in.Quelle, CreatedAt: time.Now(), UpdatedAt: time.Now(), + } + f.werkzeuge[w.ID] = w + return w, nil +} + +func (f *fakeStore) UpdateWerkzeug(ctx context.Context, id string, in store.WerkzeugInput) (store.Werkzeug, error) { + f.mu.Lock() + defer f.mu.Unlock() + w, ok := f.werkzeuge[id] + if !ok { + return store.Werkzeug{}, store.ErrNotFound + } + w.Name, w.Anbieter, w.Verarbeitungsort = in.Name, in.Anbieter, in.Verarbeitungsort + w.AVVVerfuegbar, w.AVVURL = in.AVVVerfuegbar, in.AVVURL + w.TrainingOptOut, w.TrainingStandard, w.AufbewahrungTage = in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage + w.Zertifizierungen, w.GeeigneteZwecke, w.Einschraenkungen = in.Zertifizierungen, in.GeeigneteZwecke, in.Einschraenkungen + w.LetztePruefung, w.Quelle, w.UpdatedAt = in.LetztePruefung, in.Quelle, time.Now() + f.werkzeuge[id] = w + return w, nil +} + +func (f *fakeStore) DeleteWerkzeug(ctx context.Context, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + if _, ok := f.werkzeuge[id]; !ok { + return store.ErrNotFound + } + delete(f.werkzeuge, id) + return nil +} + func (f *fakeStore) GetWerkzeug(ctx context.Context, id string) (store.Werkzeug, error) { f.mu.Lock() defer f.mu.Unlock() @@ -666,7 +718,7 @@ func TestHandleIndexRendersForMitarbeiter(t *testing.T) { func TestBetreiberRoutesRejectNonBetreiberWith404(t *testing.T) { s, _, cookie := newAuthedTestServer(t) - for _, path := range []string{"/betreiber", "/betreiber/accounts", "/betreiber/audit-log"} { + for _, path := range []string{"/betreiber", "/betreiber/accounts", "/betreiber/audit-log", "/betreiber/werkzeuge"} { resp := getWithCookie(t, s, cookie, path) if resp.Code != http.StatusNotFound { t.Errorf("GET %s status = %d, want 404 for a non-betreiber user", path, resp.Code) diff --git a/internal/web/templates/betreiber_dashboard.html b/internal/web/templates/betreiber_dashboard.html index 72eeeb7..41f281c 100644 --- a/internal/web/templates/betreiber_dashboard.html +++ b/internal/web/templates/betreiber_dashboard.html @@ -7,6 +7,7 @@

Plattform

diff --git a/internal/web/templates/betreiber_werkzeug_form.html b/internal/web/templates/betreiber_werkzeug_form.html new file mode 100644 index 0000000..6d99d8b --- /dev/null +++ b/internal/web/templates/betreiber_werkzeug_form.html @@ -0,0 +1,65 @@ +{{define "betreiber-werkzeug-form"}} + +{{template "head" .}} + +{{template "nav" .Nav}} +
+

← Werkzeugkatalog

+

{{.Title}}

+

+ Jede Zusicherung hier (AVV, Verarbeitungsort, Training-Opt-out, + Zertifizierungen) muss belegbar sein — Quelle und letzte Prüfung sind + Pflicht. +

+{{if .Error}}

{{.Error}}

{{end}} +
+ + + + + + +
+ Verarbeitungsort + {{$gewaehlt := .Verarbeitungsort}} + {{range .Verarbeitungsorte}} + + {{end}} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+{{if .ID}} +
+ +
+{{end}} +
+ + +{{end}} diff --git a/internal/web/templates/betreiber_werkzeuge.html b/internal/web/templates/betreiber_werkzeuge.html new file mode 100644 index 0000000..a1b22c4 --- /dev/null +++ b/internal/web/templates/betreiber_werkzeuge.html @@ -0,0 +1,26 @@ +{{define "betreiber-werkzeuge"}} + +{{template "head" .}} + +{{template "nav" .Nav}} +
+

← Plattform

+

Werkzeugkatalog

+{{if not .Werkzeuge}} +

Noch keine zentralen Katalogeinträge.

+{{else}} + +{{end}} +

Werkzeug anlegen

+
+ + +{{end}}