From d52b325424eb84fed88ec9bae99bb59e455f3b47 Mon Sep 17 00:00:00 2001 From: noroot Date: Sat, 29 Aug 2026 12:55:24 +0200 Subject: [PATCH] feat: Registereintrag und Export (Schritt 6 der Baureihenfolge) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jede Genehmigung (genehmigt/genehmigt_mit_auflagen) erzeugt jetzt automatisch einen append-only registereintrag mit Zweck, Abteilung, Werkzeug, Datenklasse, Einstufung, Auflagen, Verantwortliche/r und Gültigkeit. Ebene 3 (verantwortlicher/pruefer) kann das Register unter GET /registereintraege einsehen und als CSV oder PDF exportieren. Der PDF-Export nutzt das bereits vorhandene, bisher ungenutzte go-pdf/fpdf (keine neue Abhängigkeit) mit UnicodeTranslatorFromDescriptor für deutsche Umlaute. Abgelehnte Entscheidungen und Rückfragen erzeugen bewusst keinen Registereintrag. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 40 +++-- .../migrations/0011_registereintrag.down.sql | 1 + .../migrations/0011_registereintrag.up.sql | 27 ++++ internal/store/registereintrag.go | 106 +++++++++++++ internal/store/registereintrag_test.go | 100 ++++++++++++ internal/web/fachebene_handlers.go | 33 +++- internal/web/register_handlers.go | 133 ++++++++++++++++ internal/web/register_handlers_test.go | 142 ++++++++++++++++++ internal/web/server.go | 7 + internal/web/server_test.go | 77 +++++++--- internal/web/templates/layout.html | 1 + internal/web/templates/register_liste.html | 38 +++++ 12 files changed, 672 insertions(+), 33 deletions(-) create mode 100644 internal/store/migrations/0011_registereintrag.down.sql create mode 100644 internal/store/migrations/0011_registereintrag.up.sql create mode 100644 internal/store/registereintrag.go create mode 100644 internal/store/registereintrag_test.go create mode 100644 internal/web/register_handlers.go create mode 100644 internal/web/register_handlers_test.go create mode 100644 internal/web/templates/register_liste.html diff --git a/CLAUDE.md b/CLAUDE.md index 486ad9b..b7628a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -220,7 +220,7 @@ mehrfacher Neubewertung eines Antrags erhalten bleibt. --- -## Entscheidung, Register, Wiedervorlage (Schritt 5 erledigt, Schritt 6-7 geplant) +## Entscheidung, Register, Wiedervorlage (Schritt 5+6 erledigt, Schritt 7 geplant) **Schritt 5 (Entscheidung, Snapshot, Audit-Log) ist umgesetzt.** Ebene 3 (Fachebene) hat einen Posteingang (`GET /faelle`, alle offenen — @@ -255,10 +255,16 @@ Genehmigungen erhalten ein Ablaufdatum (`gueltig_bis`: 12 Monate, bei die Benachrichtigung, wenn sich im Katalog eine Eigenschaft ändert, auf der eine aktive Genehmigung beruht (Wiedervorlage). -**Noch nicht gebaut (Schritt 6):** ein `registereintrag` wird bislang -NICHT automatisch aus einer Genehmigung erzeugt, und es gibt keinen -PDF-/CSV-Export. Geplant: Zweck, Abteilung, Werkzeug, Datenklasse, -Einstufung, Auflagen, Verantwortliche/r, Datum, Gültigkeit. +**Schritt 6 (Registereintrag, Export) ist umgesetzt.** Jede Genehmigung +erzeugt automatisch einen `registereintrag` (append-only, Migration +0011): Zweck (`antrag.titel`), Abteilung, Werkzeug, Datenklasse, +Einstufung, Auflagen (die Anforderungen der Bewertung), Verantwortliche/r, +Entscheidungsdatum, Gültigkeit — siehe `internal/web/register_handlers.go`. +`GET /registereintraege` (Ebene 3, `verantwortlicher`/`pruefer`) zeigt +sie, `GET /registereintraege/export.{csv,pdf}` exportiert. Abgelehnte +Entscheidungen und Rückfragen erzeugen bewusst KEINEN Registereintrag — +das Register ist eine Genehmigungsliste, kein vollständiges Antragslog +(das leistet bereits `GET /faelle`). --- @@ -346,12 +352,13 @@ nicht anzulegen. Noch nicht gebaut (kein Abo-System). - `entscheidung` — die Entscheidung eines/einer Verantwortlichen über einen Antrag, mit Snapshot des gewählten Werkzeugs (`werkzeug_snapshot`, JSONB) und Ablaufdatum (`gueltig_bis`) bei Genehmigung (Schritt 5). -- `registereintrag` — **noch nicht gebaut**, geplant für Schritt 6. +- `registereintrag` — automatisch bei jeder Genehmigung erzeugt + (Schritt 6), denormalisierter Nachweis (eigene Textspalten statt + Joins über bewertung/entscheidung/werkzeug). **Append-only:** kein UPDATE auf `audit_log` (Trigger `forbid_update_delete`, -wiederverwendet aus dem alten Produkt), ebenso `bewertung` und -`entscheidung`. `registereintrag` wird bei seiner Einführung ebenfalls -append-only. +wiederverwendet aus dem alten Produkt), ebenso `bewertung`, +`entscheidung` und `registereintrag`. --- @@ -391,7 +398,20 @@ append-only. vollständigen Werkzeugdatensatz ein und braucht ein zulässiges Werkzeug aus der Bewertung, `entscheidung` ist append-only, jede Entscheidung erzeugt einen `audit_log`-Eintrag.) -6. Registereintrag und Export +6. ~~Registereintrag und Export~~ — **erledigt** (`internal/web/register_handlers.go`: + `GET /registereintraege` zeigt alle Registereinträge des Mandanten, + `GET /registereintraege/export.{csv,pdf}` exportiert sie. Jede + Genehmigung — `genehmigt` oder `genehmigt_mit_auflagen` — erzeugt in + `handleFallEntscheiden` automatisch einen `registereintrag` + [append-only, Migration 0011]: Zweck [`antrag.titel`], Abteilung, + Werkzeug, Datenklasse, Einstufung, Auflagen [die Anforderungen der + Bewertung], Verantwortliche/r, Entscheidungsdatum, Gültigkeit — + bewusst denormalisiert [eigene Textspalten statt Joins], damit ein + Eintrag nicht von einem späteren Katalog-/Abteilungs-Wandel abhängt. + Abgelehnte Entscheidungen und Rückfragen erzeugen keinen Eintrag. + Der PDF-Export nutzt das bereits vorhandene, bis dahin ungenutzte + `go-pdf/fpdf` [keine neue Abhängigkeit] mit `UnicodeTranslatorFromDescriptor` + für deutsche Umlaute.) 7. Wiedervorlage und Katalog-Benachrichtigung Nicht bauen (v1): automatische Genehmigung ohne Mensch, Erkennung diff --git a/internal/store/migrations/0011_registereintrag.down.sql b/internal/store/migrations/0011_registereintrag.down.sql new file mode 100644 index 0000000..5ae1c05 --- /dev/null +++ b/internal/store/migrations/0011_registereintrag.down.sql @@ -0,0 +1 @@ +DROP TABLE registereintrag; diff --git a/internal/store/migrations/0011_registereintrag.up.sql b/internal/store/migrations/0011_registereintrag.up.sql new file mode 100644 index 0000000..75b5a91 --- /dev/null +++ b/internal/store/migrations/0011_registereintrag.up.sql @@ -0,0 +1,27 @@ +-- Registereintrag: wird automatisch bei jeder Genehmigung erzeugt +-- (genehmigt / genehmigt_mit_auflagen), siehe CLAUDE.md, Entscheidung/ +-- Register/Wiedervorlage. Bewusst denormalisiert (eigene Textspalten +-- statt Joins über bewertung/entscheidung/werkzeug) — ein Registereintrag +-- ist ein für sich lesbarer, exportierbarer Nachweis, der nicht von +-- einem späteren Katalog- oder Abteilungs-Wandel abhängen darf. +-- Append-only wie bewertung/entscheidung. +CREATE TABLE registereintrag ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + account_id UUID NOT NULL REFERENCES account (id), + antrag_id UUID NOT NULL REFERENCES antrag (id), + entscheidung_id UUID NOT NULL REFERENCES entscheidung (id), + zweck TEXT NOT NULL, + abteilung TEXT NOT NULL DEFAULT '', + werkzeug TEXT NOT NULL, + datenklasse TEXT NOT NULL, + einstufung TEXT NOT NULL, + auflagen TEXT[] NOT NULL DEFAULT '{}', + verantwortlicher TEXT NOT NULL, + entschieden_am TIMESTAMPTZ NOT NULL, + gueltig_bis TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TRIGGER registereintrag_append_only + BEFORE UPDATE OR DELETE ON registereintrag + FOR EACH ROW EXECUTE FUNCTION forbid_update_delete(); diff --git a/internal/store/registereintrag.go b/internal/store/registereintrag.go new file mode 100644 index 0000000..528dc8d --- /dev/null +++ b/internal/store/registereintrag.go @@ -0,0 +1,106 @@ +package store + +import ( + "context" + "fmt" + "time" +) + +// Registereintrag wird automatisch bei jeder Genehmigung erzeugt +// (siehe CLAUDE.md, Entscheidung/Register/Wiedervorlage). Append-only +// — siehe Migration. Bewusst denormalisiert: eigene Textspalten statt +// Joins über bewertung/entscheidung/werkzeug, damit ein einmal +// erzeugter Eintrag nicht von einem späteren Katalog- oder Abteilungs- +// Wandel abhängt. +type Registereintrag struct { + ID string + AccountID string + AntragID string + EntscheidungID string + Zweck string + Abteilung string + Werkzeug string + Datenklasse string + Einstufung string + Auflagen []string + Verantwortlicher string + EntschiedenAm time.Time + GueltigBis *time.Time + CreatedAt time.Time +} + +const registereintragColumns = `id, account_id, antrag_id, entscheidung_id, zweck, abteilung, werkzeug, + datenklasse, einstufung, auflagen, verantwortlicher, entschieden_am, gueltig_bis, created_at` + +func scanRegistereintrag(row interface { + Scan(dest ...any) error +}) (Registereintrag, error) { + var e Registereintrag + err := row.Scan( + &e.ID, &e.AccountID, &e.AntragID, &e.EntscheidungID, &e.Zweck, &e.Abteilung, &e.Werkzeug, + &e.Datenklasse, &e.Einstufung, &e.Auflagen, &e.Verantwortlicher, &e.EntschiedenAm, &e.GueltigBis, &e.CreatedAt, + ) + return e, err +} + +// RegistereintragInput bündelt die Felder eines neuen Registereintrags. +type RegistereintragInput struct { + AccountID string + AntragID string + EntscheidungID string + Zweck string + Abteilung string + Werkzeug string + Datenklasse string + Einstufung string + Auflagen []string + Verantwortlicher string + EntschiedenAm time.Time + GueltigBis *time.Time +} + +// CreateRegistereintrag speichert einen Registereintrag. +func (s *Store) CreateRegistereintrag(ctx context.Context, in RegistereintragInput) (Registereintrag, error) { + if in.Auflagen == nil { + in.Auflagen = []string{} + } + row := s.Pool.QueryRow(ctx, ` + INSERT INTO registereintrag ( + account_id, antrag_id, entscheidung_id, zweck, abteilung, werkzeug, + datenklasse, einstufung, auflagen, verantwortlicher, entschieden_am, gueltig_bis + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING `+registereintragColumns, + in.AccountID, in.AntragID, in.EntscheidungID, in.Zweck, in.Abteilung, in.Werkzeug, + in.Datenklasse, in.Einstufung, in.Auflagen, in.Verantwortlicher, in.EntschiedenAm, in.GueltigBis, + ) + e, err := scanRegistereintrag(row) + if err != nil { + return Registereintrag{}, fmt.Errorf("store: create registereintrag: %w", err) + } + return e, nil +} + +// ListRegistereintraegeForAccount liefert alle Registereinträge eines +// Mandanten, neueste zuerst. +func (s *Store) ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]Registereintrag, error) { + rows, err := s.Pool.Query(ctx, ` + SELECT `+registereintragColumns+` FROM registereintrag WHERE account_id = $1 ORDER BY entschieden_am DESC + `, accountID) + if err != nil { + return nil, fmt.Errorf("store: list registereintraege for account: %w", err) + } + defer rows.Close() + + var out []Registereintrag + for rows.Next() { + e, err := scanRegistereintrag(rows) + if err != nil { + return nil, fmt.Errorf("store: scan registereintrag: %w", err) + } + out = append(out, e) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: list registereintraege for account: %w", err) + } + return out, nil +} diff --git a/internal/store/registereintrag_test.go b/internal/store/registereintrag_test.go new file mode 100644 index 0000000..79c6f10 --- /dev/null +++ b/internal/store/registereintrag_test.go @@ -0,0 +1,100 @@ +package store_test + +import ( + "context" + "testing" + "time" + + "github.com/netcell-it/deklarix/internal/store" +) + +func testRegistereintragInput(accountID, antragID, entscheidungID string) store.RegistereintragInput { + gueltigBis := time.Now().Add(365 * 24 * time.Hour).Truncate(time.Millisecond) + return store.RegistereintragInput{ + AccountID: accountID, AntragID: antragID, EntscheidungID: entscheidungID, + Zweck: "Angebotstexte generieren", Abteilung: "Vertrieb", Werkzeug: "ChatGPT Enterprise", + Datenklasse: "personenbezogen", Einstufung: "minimal", + Auflagen: []string{"AVV erforderlich", "kein Training auf Eingaben"}, + Verantwortlicher: "verantwortlicher@example.com", EntschiedenAm: time.Now().Truncate(time.Millisecond), + GueltigBis: &gueltigBis, + } +} + +func testEntscheidungID(t *testing.T, s *store.Store, antragID, bewertungID, userID string) string { + t.Helper() + e, err := s.CreateEntscheidung(context.Background(), store.EntscheidungInput{ + AntragID: antragID, BewertungID: bewertungID, EntscheiderUserID: userID, Entscheidung: "genehmigt", + }) + if err != nil { + t.Fatalf("CreateEntscheidung: %v", err) + } + return e.ID +} + +func TestRegistereintragCreateAndList(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + accID := testAccountID(t, s) + userID := testUserID(t, s, accID) + antrag, bewertung := testAntragMitBewertung(t, s, accID, userID) + entscheidungID := testEntscheidungID(t, s, antrag.ID, bewertung.ID, userID) + + e, err := s.CreateRegistereintrag(ctx, testRegistereintragInput(accID, antrag.ID, entscheidungID)) + if err != nil { + t.Fatalf("CreateRegistereintrag: %v", err) + } + if e.Werkzeug != "ChatGPT Enterprise" || len(e.Auflagen) != 2 { + t.Fatalf("CreateRegistereintrag = %+v, unerwartete Werte", e) + } + + list, err := s.ListRegistereintraegeForAccount(ctx, accID) + if err != nil { + t.Fatalf("ListRegistereintraegeForAccount: %v", err) + } + if len(list) != 1 || list[0].ID != e.ID { + t.Fatalf("ListRegistereintraegeForAccount = %+v, want exactly one entry", list) + } +} + +func TestRegistereintragIsolatesTenants(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + accA := testAccountID(t, s) + userA := testUserID(t, s, accA) + antragA, bewertungA := testAntragMitBewertung(t, s, accA, userA) + entscheidungA := testEntscheidungID(t, s, antragA.ID, bewertungA.ID, userA) + if _, err := s.CreateRegistereintrag(ctx, testRegistereintragInput(accA, antragA.ID, entscheidungA)); err != nil { + t.Fatalf("CreateRegistereintrag (A): %v", err) + } + + accB := testAccountID(t, s) + list, err := s.ListRegistereintraegeForAccount(ctx, accB) + if err != nil { + t.Fatalf("ListRegistereintraegeForAccount (B): %v", err) + } + if len(list) != 0 { + t.Fatalf("expected no registereintraege for a different tenant, got %+v", list) + } +} + +func TestRegistereintragIsAppendOnly(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + accID := testAccountID(t, s) + userID := testUserID(t, s, accID) + antrag, bewertung := testAntragMitBewertung(t, s, accID, userID) + entscheidungID := testEntscheidungID(t, s, antrag.ID, bewertung.ID, userID) + e, err := s.CreateRegistereintrag(ctx, testRegistereintragInput(accID, antrag.ID, entscheidungID)) + if err != nil { + t.Fatalf("CreateRegistereintrag: %v", err) + } + + _, err = s.Pool.Exec(ctx, `UPDATE registereintrag SET werkzeug = 'geaendert' WHERE id = $1`, e.ID) + if err == nil { + t.Fatal("expected UPDATE on registereintrag to be rejected by the append-only trigger") + } + _, err = s.Pool.Exec(ctx, `DELETE FROM registereintrag WHERE id = $1`, e.ID) + if err == nil { + t.Fatal("expected DELETE on registereintrag to be rejected by the append-only trigger") + } +} diff --git a/internal/web/fachebene_handlers.go b/internal/web/fachebene_handlers.go index cbce60f..c8be33e 100644 --- a/internal/web/fachebene_handlers.go +++ b/internal/web/fachebene_handlers.go @@ -192,11 +192,13 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) { return } + istGenehmigung := entscheidungTyp == "genehmigt" || entscheidungTyp == "genehmigt_mit_auflagen" in := store.EntscheidungInput{ AntragID: antrag.ID, BewertungID: bewertung.ID, EntscheiderUserID: user.ID, Entscheidung: entscheidungTyp, Begruendung: begruendung, } - if entscheidungTyp == "genehmigt" || entscheidungTyp == "genehmigt_mit_auflagen" { + var werkzeugName string + if istGenehmigung { zulaessig := false for _, id := range bewertung.ZulaessigeWerkzeuge { if id == werkzeugID { @@ -213,13 +215,15 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) { http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound) return } + werkzeugName = werkzeug.Name in.WerkzeugID = &werkzeugID in.WerkzeugSnapshot = &werkzeug gueltigBis := gueltigkeitFuer(bewertung.Einstufung) in.GueltigBis = &gueltigBis } - if _, err := s.store.CreateEntscheidung(r.Context(), in); err != nil { + entscheidung, err := s.store.CreateEntscheidung(r.Context(), in) + if err != nil { http.Error(w, "Entscheidung konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError) return } @@ -232,5 +236,30 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) { return } + // Jede Genehmigung erzeugt automatisch einen Registereintrag (siehe + // CLAUDE.md) — bei "abgelehnt"/"rueckfrage" gibt es nichts + // Genehmigtes, das im Register stehen müsste. + if istGenehmigung { + abteilungName := "" + if antrag.AbteilungID != nil { + if abt, err := s.store.GetAbteilung(r.Context(), *antrag.AbteilungID); err == nil { + abteilungName = abt.Name + } + } + var auflagen []string + for _, a := range bewertung.Anforderungen { + auflagen = append(auflagen, a.Beschreibung) + } + if _, err := s.store.CreateRegistereintrag(r.Context(), store.RegistereintragInput{ + AccountID: user.AccountID, AntragID: antrag.ID, EntscheidungID: entscheidung.ID, + Zweck: antrag.Titel, Abteilung: abteilungName, Werkzeug: werkzeugName, + Datenklasse: bewertung.Datenklasse, Einstufung: bewertung.Einstufung, Auflagen: auflagen, + Verantwortlicher: user.Email, EntschiedenAm: entscheidung.CreatedAt, GueltigBis: entscheidung.GueltigBis, + }); err != nil { + http.Error(w, "Registereintrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError) + return + } + } + http.Redirect(w, r, "/faelle/"+antrag.ID, http.StatusSeeOther) } diff --git a/internal/web/register_handlers.go b/internal/web/register_handlers.go new file mode 100644 index 0000000..22b4eb6 --- /dev/null +++ b/internal/web/register_handlers.go @@ -0,0 +1,133 @@ +// Ebene 3 — Register und Export (Schritt 6 der Baureihenfolge, siehe +// CLAUDE.md). Jede Genehmigung erzeugt automatisch einen Registereintrag +// (siehe handleFallEntscheiden); diese Seiten zeigen und exportieren +// sie. Reuse des bereits vorhandenen (bisher ungenutzten) go-pdf/fpdf- +// Dependency statt einer neuen Bibliothek für den PDF-Export. +package web + +import ( + "encoding/csv" + "fmt" + "net/http" + "strings" + + "github.com/go-pdf/fpdf" +) + +type registerEintragView struct { + Zweck string + Abteilung string + Werkzeug string + Datenklasse string + Einstufung string + Auflagen string + Verantwortlicher string + EntschiedenAm string + GueltigBis string +} + +type registerListeData struct { + Title string + Nav navData + Eintraege []registerEintragView +} + +func (s *Server) registerEintraegeFuerAccount(r *http.Request) ([]registerEintragView, error) { + eintraege, err := s.store.ListRegistereintraegeForAccount(r.Context(), currentUser(r).AccountID) + if err != nil { + return nil, err + } + views := make([]registerEintragView, len(eintraege)) + for i, e := range eintraege { + gueltigBis := "" + if e.GueltigBis != nil { + gueltigBis = e.GueltigBis.Format("02.01.2006") + } + views[i] = registerEintragView{ + Zweck: e.Zweck, Abteilung: e.Abteilung, Werkzeug: e.Werkzeug, + Datenklasse: e.Datenklasse, Einstufung: e.Einstufung, Auflagen: strings.Join(e.Auflagen, "; "), + Verantwortlicher: e.Verantwortlicher, EntschiedenAm: e.EntschiedenAm.Format("02.01.2006 15:04"), GueltigBis: gueltigBis, + } + } + return views, nil +} + +// handleRegisterListe zeigt das Register (alle Genehmigungen des +// Mandanten) — Ebene 3, für verantwortlicher und pruefer gleichermaßen +// lesbar. +func (s *Server) handleRegisterListe(w http.ResponseWriter, r *http.Request) { + views, err := s.registerEintraegeFuerAccount(r) + if err != nil { + http.Error(w, "Register konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError) + return + } + data := registerListeData{Title: "Register", Nav: navFor(r), Eintraege: views} + if err := s.templates.ExecuteTemplate(w, "register-liste", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} + +var registerCSVHeader = []string{"Zweck", "Abteilung", "Werkzeug", "Datenklasse", "Einstufung", "Auflagen", "Verantwortliche/r", "Entschieden am", "Gültig bis"} + +func registerCSVRow(e registerEintragView) []string { + return []string{e.Zweck, e.Abteilung, e.Werkzeug, e.Datenklasse, e.Einstufung, e.Auflagen, e.Verantwortlicher, e.EntschiedenAm, e.GueltigBis} +} + +// handleRegisterExportCSV exportiert das Register als CSV. +func (s *Server) handleRegisterExportCSV(w http.ResponseWriter, r *http.Request) { + views, err := s.registerEintraegeFuerAccount(r) + if err != nil { + http.Error(w, "Register konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.Header().Set("Content-Disposition", `attachment; filename="register.csv"`) + cw := csv.NewWriter(w) + _ = cw.Write(registerCSVHeader) + for _, e := range views { + _ = cw.Write(registerCSVRow(e)) + } + cw.Flush() +} + +// handleRegisterExportPDF exportiert das Register als PDF — eine +// einfache Tabelle, keine Gestaltung über den Design-System-Anspruch +// des restlichen Produkts hinaus. +func (s *Server) handleRegisterExportPDF(w http.ResponseWriter, r *http.Request) { + views, err := s.registerEintraegeFuerAccount(r) + if err != nil { + http.Error(w, "Register konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError) + return + } + + pdf := fpdf.New("L", "mm", "A4", "") + // cp1252 deckt deutsche Umlaute/ß ab — die eingebauten Helvetica- + // Fonts von fpdf können sonst kein UTF-8 darstellen. + tr := pdf.UnicodeTranslatorFromDescriptor("") + pdf.AddPage() + pdf.SetFont("Helvetica", "B", 14) + pdf.CellFormat(0, 10, tr("Register"), "", 1, "L", false, 0, "") + pdf.Ln(2) + + colWidths := []float64{45, 25, 35, 30, 30, 55, 40, 30, 25} + pdf.SetFont("Helvetica", "B", 8) + for i, h := range registerCSVHeader { + pdf.CellFormat(colWidths[i], 8, tr(h), "1", 0, "L", false, 0, "") + } + pdf.Ln(-1) + + pdf.SetFont("Helvetica", "", 8) + for _, e := range views { + row := registerCSVRow(e) + for i, cell := range row { + pdf.CellFormat(colWidths[i], 8, tr(cell), "1", 0, "L", false, 0, "") + } + pdf.Ln(-1) + } + + w.Header().Set("Content-Type", "application/pdf") + w.Header().Set("Content-Disposition", `attachment; filename="register.pdf"`) + if err := pdf.Output(w); err != nil { + http.Error(w, fmt.Sprintf("PDF konnte nicht erzeugt werden: %v", err), http.StatusInternalServerError) + } +} diff --git a/internal/web/register_handlers_test.go b/internal/web/register_handlers_test.go new file mode 100644 index 0000000..6b5a2a7 --- /dev/null +++ b/internal/web/register_handlers_test.go @@ -0,0 +1,142 @@ +package web_test + +import ( + "context" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/netcell-it/deklarix/internal/store" + "github.com/netcell-it/deklarix/internal/web" +) + +// genehmigeFall reicht einen Antrag ein und genehmigt ihn mit einem +// echten, zulässigen Werkzeug — Vorbereitung für Register-Tests, die +// einen tatsächlichen Registereintrag brauchen. +func genehmigeFall(t *testing.T, fs *fakeStore, s *web.Server) (accountID string) { + t.Helper() + acc, err := fs.CreateAccount(context.Background(), "Register-Mandant") + if err != nil { + t.Fatalf("CreateAccount: %v", err) + } + fs.werkzeuge["werkzeug-ok"] = store.Werkzeug{ID: "werkzeug-ok", Name: "ChatGPT Enterprise", AVVVerfuegbar: true, TrainingStandard: true} + mitarbeiterCookie := seedUserInAccount(t, fs, acc.ID, "mitarbeiter@example.com", "mitarbeiter") + createResp := postForm(t, s, mitarbeiterCookie, "/antraege", fullAntragForm()) + if createResp.Code != http.StatusSeeOther { + t.Fatalf("Antrag anlegen: status = %d, body: %s", createResp.Code, createResp.Body.String()) + } + var antragID string + for id := range fs.antraege { + if fs.antraege[id].AccountID == acc.ID { + antragID = id + } + } + verantwortlicherCookie := seedUserInAccount(t, fs, acc.ID, "verantwortlicher@example.com", "verantwortlicher") + entscheidenResp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{ + "entscheidung": {"genehmigt"}, "werkzeug_id": {"werkzeug-ok"}, + }) + if entscheidenResp.Code != http.StatusSeeOther { + t.Fatalf("Entscheiden: status = %d, body: %s", entscheidenResp.Code, entscheidenResp.Body.String()) + } + return acc.ID +} + +func TestGenehmigungErzeugtRegistereintrag(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + accountID := genehmigeFall(t, fs, s) + + list := fs.registereintraege[accountID] + if len(list) != 1 { + t.Fatalf("registereintraege = %+v, want exactly one entry", list) + } + e := list[0] + if e.Werkzeug != "ChatGPT Enterprise" || e.Zweck != "Angebotstexte generieren" || e.Datenklasse != "personenbezogen" { + t.Errorf("Registereintrag = %+v, unerwartete Werte", e) + } + if e.Verantwortlicher != "verantwortlicher@example.com" { + t.Errorf("Verantwortlicher = %q, want verantwortlicher@example.com", e.Verantwortlicher) + } +} + +func TestAbgelehnteEntscheidungErzeugtKeinenRegistereintrag(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + antragID, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher") + + resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{ + "entscheidung": {"abgelehnt"}, "begruendung": {"Kein Werkzeug verfügbar"}, + }) + if resp.Code != http.StatusSeeOther { + t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) + } + + for _, list := range fs.registereintraege { + if len(list) != 0 { + t.Fatalf("expected no registereintrag for an abgelehnt Entscheidung, got %+v", list) + } + } +} + +func TestRegisterListeZeigtGenehmigung(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + accountID := genehmigeFall(t, fs, s) + verantwortlicherCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer") + + resp := getWithCookie(t, s, verantwortlicherCookie, "/registereintraege") + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) + } + if !strings.Contains(resp.Body.String(), "ChatGPT Enterprise") { + t.Errorf("expected the genehmigtes werkzeug in the register, got: %s", resp.Body.String()) + } +} + +func TestRegisterExportCSVEnthaeltEintrag(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + accountID := genehmigeFall(t, fs, s) + verantwortlicherCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer") + + resp := getWithCookie(t, s, verantwortlicherCookie, "/registereintraege/export.csv") + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) + } + if ct := resp.Header().Get("Content-Type"); !strings.Contains(ct, "text/csv") { + t.Errorf("Content-Type = %q, want text/csv", ct) + } + if !strings.Contains(resp.Body.String(), "ChatGPT Enterprise") { + t.Errorf("expected the werkzeug in the CSV export, got: %s", resp.Body.String()) + } +} + +func TestRegisterExportPDFReturnsPDF(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + accountID := genehmigeFall(t, fs, s) + verantwortlicherCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer") + + resp := getWithCookie(t, s, verantwortlicherCookie, "/registereintraege/export.pdf") + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) + } + if ct := resp.Header().Get("Content-Type"); ct != "application/pdf" { + t.Errorf("Content-Type = %q, want application/pdf", ct) + } + if !strings.HasPrefix(resp.Body.String(), "%PDF") { + t.Error("expected the response body to start with the PDF magic bytes") + } +} + +func TestMitarbeiterCannotAccessRegister(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter") + + resp := getWithCookie(t, s, cookie, "/registereintraege") + if resp.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 for role mitarbeiter on /registereintraege", resp.Code) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 5cd8c28..d2e3011 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -61,6 +61,10 @@ type Store interface { CreateEntscheidung(ctx context.Context, in store.EntscheidungInput) (store.Entscheidung, error) GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (store.Entscheidung, error) + + GetAbteilung(ctx context.Context, id string) (store.Abteilung, error) + CreateRegistereintrag(ctx context.Context, in store.RegistereintragInput) (store.Registereintrag, error) + ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]store.Registereintrag, error) } // Regelwerk bündelt die drei geladenen Regelwerk-Dateien, die die @@ -110,6 +114,9 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) { mux.HandleFunc("GET /faelle", s.requireFachebene(s.handleFaelleListe)) mux.HandleFunc("GET /faelle/{id}", s.requireFachebene(s.handleFallDetail)) mux.HandleFunc("POST /faelle/{id}/entscheiden", s.requireFachebene(s.handleFallEntscheiden)) + mux.HandleFunc("GET /registereintraege", s.requireFachebene(s.handleRegisterListe)) + mux.HandleFunc("GET /registereintraege/export.csv", s.requireFachebene(s.handleRegisterExportCSV)) + mux.HandleFunc("GET /registereintraege/export.pdf", s.requireFachebene(s.handleRegisterExportPDF)) mux.HandleFunc("GET /verwaltung/nutzer", s.requireAdmin(s.handleNutzerListe)) mux.HandleFunc("GET /verwaltung/nutzer/neu", s.requireAdmin(s.handleNutzerNeuForm)) mux.HandleFunc("POST /verwaltung/nutzer", s.requireAdmin(s.handleNutzerCreate)) diff --git a/internal/web/server_test.go b/internal/web/server_test.go index b83cca8..160405f 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -24,31 +24,33 @@ const testSessionCookie = "deklarix_session" // fakeStore ist eine In-Memory-Implementierung von web.Store, damit die // Handler-Tests keine echte Postgres-Instanz brauchen. type fakeStore struct { - mu sync.Mutex - nextID int - accounts map[string]store.Account - users map[string]store.User - usersByEmail map[string]string // email -> user id - sessions map[string]store.Session - auditLog []store.AuditEntry - abteilungen map[string][]store.Abteilung // accountID -> Abteilungen - antraege map[string]store.Antrag - werkzeuge map[string]store.Werkzeug - bewertungen map[string][]store.Bewertung // antragID -> Bewertungen, älteste zuerst - entscheidungen map[string][]store.Entscheidung // antragID -> Entscheidungen, älteste zuerst + mu sync.Mutex + nextID int + accounts map[string]store.Account + users map[string]store.User + usersByEmail map[string]string // email -> user id + sessions map[string]store.Session + auditLog []store.AuditEntry + abteilungen map[string][]store.Abteilung // accountID -> Abteilungen + antraege map[string]store.Antrag + werkzeuge map[string]store.Werkzeug + bewertungen map[string][]store.Bewertung // antragID -> Bewertungen, älteste zuerst + entscheidungen map[string][]store.Entscheidung // antragID -> Entscheidungen, älteste zuerst + registereintraege map[string][]store.Registereintrag // accountID -> Registereintraege } func newFakeStore() *fakeStore { return &fakeStore{ - accounts: map[string]store.Account{}, - users: map[string]store.User{}, - usersByEmail: map[string]string{}, - sessions: map[string]store.Session{}, - abteilungen: map[string][]store.Abteilung{}, - antraege: map[string]store.Antrag{}, - werkzeuge: map[string]store.Werkzeug{}, - bewertungen: map[string][]store.Bewertung{}, - entscheidungen: map[string][]store.Entscheidung{}, + accounts: map[string]store.Account{}, + users: map[string]store.User{}, + usersByEmail: map[string]string{}, + sessions: map[string]store.Session{}, + abteilungen: map[string][]store.Abteilung{}, + antraege: map[string]store.Antrag{}, + werkzeuge: map[string]store.Werkzeug{}, + bewertungen: map[string][]store.Bewertung{}, + entscheidungen: map[string][]store.Entscheidung{}, + registereintraege: map[string][]store.Registereintrag{}, } } @@ -187,6 +189,19 @@ func (f *fakeStore) ListAbteilungenForAccount(ctx context.Context, accountID str return f.abteilungen[accountID], nil } +func (f *fakeStore) GetAbteilung(ctx context.Context, id string) (store.Abteilung, error) { + f.mu.Lock() + defer f.mu.Unlock() + for _, list := range f.abteilungen { + for _, a := range list { + if a.ID == id { + return a, nil + } + } + } + return store.Abteilung{}, store.ErrNotFound +} + func (f *fakeStore) CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (store.Antrag, error) { f.mu.Lock() defer f.mu.Unlock() @@ -385,6 +400,26 @@ func (f *fakeStore) GetLatestEntscheidungForAntrag(ctx context.Context, antragID return es[len(es)-1], nil } +func (f *fakeStore) CreateRegistereintrag(ctx context.Context, in store.RegistereintragInput) (store.Registereintrag, error) { + f.mu.Lock() + defer f.mu.Unlock() + e := store.Registereintrag{ + ID: f.newID(), AccountID: in.AccountID, AntragID: in.AntragID, EntscheidungID: in.EntscheidungID, + Zweck: in.Zweck, Abteilung: in.Abteilung, Werkzeug: in.Werkzeug, + Datenklasse: in.Datenklasse, Einstufung: in.Einstufung, Auflagen: in.Auflagen, + Verantwortlicher: in.Verantwortlicher, EntschiedenAm: in.EntschiedenAm, GueltigBis: in.GueltigBis, + CreatedAt: time.Now(), + } + f.registereintraege[in.AccountID] = append(f.registereintraege[in.AccountID], e) + return e, nil +} + +func (f *fakeStore) ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]store.Registereintrag, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.registereintraege[accountID], nil +} + // ─── Test-Setup ─────────────────────────────────────────────────────── // loadTestRegelwerk lädt die echten rules/*.yaml-Dateien — dieselben, diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 72940db..c376285 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -11,6 +11,7 @@ Start {{if not .IsBetreiber}}Meine Anträge{{end}} {{if .IsFachebene}}Posteingang{{end}} + {{if .IsFachebene}}Register{{end}} {{if .IsAdmin}}Nutzerverwaltung{{end}} {{if .IsBetreiber}}Plattform{{end}}
diff --git a/internal/web/templates/register_liste.html b/internal/web/templates/register_liste.html new file mode 100644 index 0000000..12fb435 --- /dev/null +++ b/internal/web/templates/register_liste.html @@ -0,0 +1,38 @@ +{{define "register-liste"}} + +{{template "head" .}} + +{{template "nav" .Nav}} +
+

Register

+

+ Jede Genehmigung erzeugt automatisch einen Registereintrag — + abgelehnte Anträge und Rückfragen stehen nicht im Register. +

+

CSV-Export · PDF-Export

+{{if not .Eintraege}} +

Noch keine Genehmigungen.

+{{else}} + + + + + + + + + + {{range .Eintraege}} + + + + + + {{end}} + +
ZweckAbteilungWerkzeugDatenklasseEinstufungAuflagenVerantwortliche/rEntschieden amGültig bis
{{.Zweck}}{{.Abteilung}}{{.Werkzeug}}{{.Datenklasse}}{{.Einstufung}}{{.Auflagen}}{{.Verantwortlicher}}{{.EntschiedenAm}}{{.GueltigBis}}
+{{end}} +
+ + +{{end}}