feat: Registereintrag und Export (Schritt 6 der Baureihenfolge)

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 <noreply@anthropic.com>
This commit is contained in:
noroot
2026-08-29 12:55:24 +02:00
parent cff8c9eadf
commit d52b325424
12 changed files with 672 additions and 33 deletions

View File

@@ -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 **Schritt 5 (Entscheidung, Snapshot, Audit-Log) ist umgesetzt.** Ebene 3
(Fachebene) hat einen Posteingang (`GET /faelle`, alle offenen — (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 die Benachrichtigung, wenn sich im Katalog eine Eigenschaft ändert, auf
der eine aktive Genehmigung beruht (Wiedervorlage). der eine aktive Genehmigung beruht (Wiedervorlage).
**Noch nicht gebaut (Schritt 6):** ein `registereintrag` wird bislang **Schritt 6 (Registereintrag, Export) ist umgesetzt.** Jede Genehmigung
NICHT automatisch aus einer Genehmigung erzeugt, und es gibt keinen erzeugt automatisch einen `registereintrag` (append-only, Migration
PDF-/CSV-Export. Geplant: Zweck, Abteilung, Werkzeug, Datenklasse, 0011): Zweck (`antrag.titel`), Abteilung, Werkzeug, Datenklasse,
Einstufung, Auflagen, Verantwortliche/r, Datum, Gültigkeit. 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 - `entscheidung` — die Entscheidung eines/einer Verantwortlichen über
einen Antrag, mit Snapshot des gewählten Werkzeugs (`werkzeug_snapshot`, einen Antrag, mit Snapshot des gewählten Werkzeugs (`werkzeug_snapshot`,
JSONB) und Ablaufdatum (`gueltig_bis`) bei Genehmigung (Schritt 5). 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`, **Append-only:** kein UPDATE auf `audit_log` (Trigger `forbid_update_delete`,
wiederverwendet aus dem alten Produkt), ebenso `bewertung` und wiederverwendet aus dem alten Produkt), ebenso `bewertung`,
`entscheidung`. `registereintrag` wird bei seiner Einführung ebenfalls `entscheidung` und `registereintrag`.
append-only.
--- ---
@@ -391,7 +398,20 @@ append-only.
vollständigen Werkzeugdatensatz ein und braucht ein zulässiges vollständigen Werkzeugdatensatz ein und braucht ein zulässiges
Werkzeug aus der Bewertung, `entscheidung` ist append-only, jede Werkzeug aus der Bewertung, `entscheidung` ist append-only, jede
Entscheidung erzeugt einen `audit_log`-Eintrag.) 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 7. Wiedervorlage und Katalog-Benachrichtigung
Nicht bauen (v1): automatische Genehmigung ohne Mensch, Erkennung Nicht bauen (v1): automatische Genehmigung ohne Mensch, Erkennung

View File

@@ -0,0 +1 @@
DROP TABLE registereintrag;

View File

@@ -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();

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -192,11 +192,13 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
return return
} }
istGenehmigung := entscheidungTyp == "genehmigt" || entscheidungTyp == "genehmigt_mit_auflagen"
in := store.EntscheidungInput{ in := store.EntscheidungInput{
AntragID: antrag.ID, BewertungID: bewertung.ID, EntscheiderUserID: user.ID, AntragID: antrag.ID, BewertungID: bewertung.ID, EntscheiderUserID: user.ID,
Entscheidung: entscheidungTyp, Begruendung: begruendung, Entscheidung: entscheidungTyp, Begruendung: begruendung,
} }
if entscheidungTyp == "genehmigt" || entscheidungTyp == "genehmigt_mit_auflagen" { var werkzeugName string
if istGenehmigung {
zulaessig := false zulaessig := false
for _, id := range bewertung.ZulaessigeWerkzeuge { for _, id := range bewertung.ZulaessigeWerkzeuge {
if id == werkzeugID { 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) http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
return return
} }
werkzeugName = werkzeug.Name
in.WerkzeugID = &werkzeugID in.WerkzeugID = &werkzeugID
in.WerkzeugSnapshot = &werkzeug in.WerkzeugSnapshot = &werkzeug
gueltigBis := gueltigkeitFuer(bewertung.Einstufung) gueltigBis := gueltigkeitFuer(bewertung.Einstufung)
in.GueltigBis = &gueltigBis 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) http.Error(w, "Entscheidung konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
return return
} }
@@ -232,5 +236,30 @@ func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
return 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) http.Redirect(w, r, "/faelle/"+antrag.ID, http.StatusSeeOther)
} }

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -61,6 +61,10 @@ type Store interface {
CreateEntscheidung(ctx context.Context, in store.EntscheidungInput) (store.Entscheidung, error) CreateEntscheidung(ctx context.Context, in store.EntscheidungInput) (store.Entscheidung, error)
GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (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 // 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", s.requireFachebene(s.handleFaelleListe))
mux.HandleFunc("GET /faelle/{id}", s.requireFachebene(s.handleFallDetail)) mux.HandleFunc("GET /faelle/{id}", s.requireFachebene(s.handleFallDetail))
mux.HandleFunc("POST /faelle/{id}/entscheiden", s.requireFachebene(s.handleFallEntscheiden)) 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", s.requireAdmin(s.handleNutzerListe))
mux.HandleFunc("GET /verwaltung/nutzer/neu", s.requireAdmin(s.handleNutzerNeuForm)) mux.HandleFunc("GET /verwaltung/nutzer/neu", s.requireAdmin(s.handleNutzerNeuForm))
mux.HandleFunc("POST /verwaltung/nutzer", s.requireAdmin(s.handleNutzerCreate)) mux.HandleFunc("POST /verwaltung/nutzer", s.requireAdmin(s.handleNutzerCreate))

View File

@@ -24,31 +24,33 @@ const testSessionCookie = "deklarix_session"
// fakeStore ist eine In-Memory-Implementierung von web.Store, damit die // fakeStore ist eine In-Memory-Implementierung von web.Store, damit die
// Handler-Tests keine echte Postgres-Instanz brauchen. // Handler-Tests keine echte Postgres-Instanz brauchen.
type fakeStore struct { type fakeStore struct {
mu sync.Mutex mu sync.Mutex
nextID int nextID int
accounts map[string]store.Account accounts map[string]store.Account
users map[string]store.User users map[string]store.User
usersByEmail map[string]string // email -> user id usersByEmail map[string]string // email -> user id
sessions map[string]store.Session sessions map[string]store.Session
auditLog []store.AuditEntry auditLog []store.AuditEntry
abteilungen map[string][]store.Abteilung // accountID -> Abteilungen abteilungen map[string][]store.Abteilung // accountID -> Abteilungen
antraege map[string]store.Antrag antraege map[string]store.Antrag
werkzeuge map[string]store.Werkzeug werkzeuge map[string]store.Werkzeug
bewertungen map[string][]store.Bewertung // antragID -> Bewertungen, älteste zuerst bewertungen map[string][]store.Bewertung // antragID -> Bewertungen, älteste zuerst
entscheidungen map[string][]store.Entscheidung // antragID -> Entscheidungen, älteste zuerst entscheidungen map[string][]store.Entscheidung // antragID -> Entscheidungen, älteste zuerst
registereintraege map[string][]store.Registereintrag // accountID -> Registereintraege
} }
func newFakeStore() *fakeStore { func newFakeStore() *fakeStore {
return &fakeStore{ return &fakeStore{
accounts: map[string]store.Account{}, accounts: map[string]store.Account{},
users: map[string]store.User{}, users: map[string]store.User{},
usersByEmail: map[string]string{}, usersByEmail: map[string]string{},
sessions: map[string]store.Session{}, sessions: map[string]store.Session{},
abteilungen: map[string][]store.Abteilung{}, abteilungen: map[string][]store.Abteilung{},
antraege: map[string]store.Antrag{}, antraege: map[string]store.Antrag{},
werkzeuge: map[string]store.Werkzeug{}, werkzeuge: map[string]store.Werkzeug{},
bewertungen: map[string][]store.Bewertung{}, bewertungen: map[string][]store.Bewertung{},
entscheidungen: map[string][]store.Entscheidung{}, 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 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) { func (f *fakeStore) CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (store.Antrag, error) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
@@ -385,6 +400,26 @@ func (f *fakeStore) GetLatestEntscheidungForAntrag(ctx context.Context, antragID
return es[len(es)-1], nil 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 ─────────────────────────────────────────────────────── // ─── Test-Setup ───────────────────────────────────────────────────────
// loadTestRegelwerk lädt die echten rules/*.yaml-Dateien — dieselben, // loadTestRegelwerk lädt die echten rules/*.yaml-Dateien — dieselben,

View File

@@ -11,6 +11,7 @@
<a href="/">Start</a> <a href="/">Start</a>
{{if not .IsBetreiber}}<a href="/antraege">Meine Anträge</a>{{end}} {{if not .IsBetreiber}}<a href="/antraege">Meine Anträge</a>{{end}}
{{if .IsFachebene}}<a href="/faelle">Posteingang</a>{{end}} {{if .IsFachebene}}<a href="/faelle">Posteingang</a>{{end}}
{{if .IsFachebene}}<a href="/registereintraege">Register</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/nutzer">Nutzerverwaltung</a>{{end}} {{if .IsAdmin}}<a href="/verwaltung/nutzer">Nutzerverwaltung</a>{{end}}
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}} {{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
<form method="post" action="/logout" style="display:inline"> <form method="post" action="/logout" style="display:inline">

View File

@@ -0,0 +1,38 @@
{{define "register-liste"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<h1>Register</h1>
<p class="hinweis">
Jede Genehmigung erzeugt automatisch einen Registereintrag —
abgelehnte Anträge und Rückfragen stehen nicht im Register.
</p>
<p><a href="/registereintraege/export.csv">CSV-Export</a> · <a href="/registereintraege/export.pdf">PDF-Export</a></p>
{{if not .Eintraege}}
<p class="hinweis">Noch keine Genehmigungen.</p>
{{else}}
<table>
<thead>
<tr>
<th>Zweck</th><th>Abteilung</th><th>Werkzeug</th><th>Datenklasse</th>
<th>Einstufung</th><th>Auflagen</th><th>Verantwortliche/r</th>
<th>Entschieden am</th><th>Gültig bis</th>
</tr>
</thead>
<tbody>
{{range .Eintraege}}
<tr>
<td>{{.Zweck}}</td><td>{{.Abteilung}}</td><td>{{.Werkzeug}}</td><td>{{.Datenklasse}}</td>
<td>{{.Einstufung}}</td><td>{{.Auflagen}}</td><td>{{.Verantwortlicher}}</td>
<td>{{.EntschiedenAm}}</td><td>{{.GueltigBis}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
</div>
</body>
</html>
{{end}}