feat: Posteingang und Entscheiden für die Fachebene (Schritt 5 der Baureihenfolge)

Ebene 3 (Rollen verantwortlicher/pruefer) bekommt GET /faelle
(Posteingang aller offenen Anträge des Mandanten) und GET/POST
/faelle/{id} zum Entscheiden. Eine Entscheidung friert Regelwerk-,
Katalog- und den vollständigen Werkzeugdatensatz ein, verlangt eine
Begründung bei Abweichung vom abgeleiteten Vorschlag, erlaubt bei
Genehmigung nur ein durch die Bewertung zulässiges Werkzeug und
protokolliert die Entscheidung im Audit-Log. entscheidung ist
append-only wie bewertung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
noroot
2026-08-29 12:26:51 +02:00
parent e968cf9761
commit d1e80dd19f
14 changed files with 1017 additions and 92 deletions

View File

@@ -205,23 +205,45 @@ mehrfacher Neubewertung eines Antrags erhalten bleibt.
--- ---
## Entscheidung, Register, Wiedervorlage (geplant, Schritt 5-7) ## Entscheidung, Register, Wiedervorlage (Schritt 5 erledigt, Schritt 6-7 geplant)
**Schritt 5 (Entscheidung, Snapshot, Audit-Log) ist umgesetzt.** Ebene 3
(Fachebene) hat einen Posteingang (`GET /faelle`, alle offenen —
Status "eingereicht" — Anträge des Mandanten, nicht nur die eigenen)
und eine Entscheiden-Seite (`GET /faelle/{id}`, `POST
/faelle/{id}/entscheiden`, siehe `internal/web/fachebene_handlers.go`).
Rollen `verantwortlicher` und `pruefer` sehen dieselbe Sicht
(`requireFachebene`), nur `verantwortlicher` hat Entscheidungsrecht —
ein `pruefer`-POST auf `/entscheiden` bekommt 403 (er darf wissen, dass
der Fall existiert, nur nicht entscheiden; anders als bei
`requireFachebene`/`requireBetreiber`, die bei falscher Rolle 404
liefern, um die Existenz der Seite selbst zu verbergen).
Der/die Verantwortliche wählt: genehmigt / genehmigt mit Auflagen / Der/die Verantwortliche wählt: genehmigt / genehmigt mit Auflagen /
abgelehnt / Rückfrage. Begründungsfeld ist bei Abweichung vom Vorschlag abgelehnt / Rückfrage. **Vorschlag-Vergleich:** `vorschlagFuer` leitet
Pflicht. **Snapshot bei Entscheidung:** Regelwerk-Version, Katalog- aus der Bewertung ab, was das System vorschlagen würde (`abgelehnt` bei
Version und der vollständige Werkzeugdatensatz werden eingefroren — ein `verboten`, sonst `genehmigt` wenn mindestens ein zulässiges Werkzeug
späterer Katalog-Wandel darf nicht rückwirkend verändern, worauf eine existiert, sonst `rueckfrage`) — weicht die tatsächliche Entscheidung
Entscheidung beruhte. davon ab, ist das Begründungsfeld Pflicht (sonst 400). **Snapshot bei
Entscheidung:** `entscheidung.werkzeug_snapshot` (JSONB) friert den
vollständigen `store.Werkzeug`-Datensatz zum Entscheidungszeitpunkt ein
— ein späterer Katalog-Wandel darf nicht rückwirkend verändern, worauf
eine Entscheidung beruhte. Bei "genehmigt"/"genehmigt mit Auflagen" ist
ein Werkzeug aus der zulässigen Liste der Bewertung Pflicht (hart
geprüft, kein Override eines ausgeschlossenen Werkzeugs). `entscheidung`
ist append-only (Migration 0010) wie `bewertung`; jede Entscheidung
erzeugt zusätzlich einen `audit_log`-Eintrag (Action
`antrag_entschieden`).
Genehmigungen erhalten ein Ablaufdatum (Vorschlag: 12 Monate, bei Genehmigungen erhalten ein Ablaufdatum (`gueltig_bis`: 12 Monate, bei
`hochrisiko` 6). Ändert sich im Katalog eine Eigenschaft, auf der eine `hochrisiko` 6`gueltigkeitFuer`). **Noch nicht gebaut (Schritt 7):**
aktive Genehmigung beruht, wird der/die Verantwortliche benachrichtigt die Benachrichtigung, wenn sich im Katalog eine Eigenschaft ändert, auf
(Wiedervorlage). der eine aktive Genehmigung beruht (Wiedervorlage).
Jede Genehmigung erzeugt automatisch einen Registereintrag: Zweck, **Noch nicht gebaut (Schritt 6):** ein `registereintrag` wird bislang
Abteilung, Werkzeug, Datenklasse, Einstufung, Auflagen, Verantwortliche/ NICHT automatisch aus einer Genehmigung erzeugt, und es gibt keinen
r, Datum, Gültigkeit. Export als PDF und CSV. PDF-/CSV-Export. Geplant: Zweck, Abteilung, Werkzeug, Datenklasse,
Einstufung, Auflagen, Verantwortliche/r, Datum, Gültigkeit.
--- ---
@@ -305,12 +327,16 @@ nicht anzulegen. Noch nicht gebaut (kein Abo-System).
NICHT append-only (normale Zustandsänderung `entwurf` NICHT append-only (normale Zustandsänderung `entwurf`
`eingereicht``entschieden`, wie `submission` es im alten Produkt `eingereicht``entschieden`, wie `submission` es im alten Produkt
war). war).
- `bewertung`, `entscheidung`, `registereintrag` — **noch nicht - `bewertung` — der berechnete Vorschlag (Schritt 4).
gebaut**, geplant für Schritt 5/6 der Baureihenfolge. - `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.
**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). `bewertung`/`entscheidung`/ wiederverwendet aus dem alten Produkt), ebenso `bewertung` und
`registereintrag` werden bei ihrer Einführung ebenfalls append-only. `entscheidung`. `registereintrag` wird bei seiner Einführung ebenfalls
append-only.
--- ---
@@ -341,7 +367,15 @@ wiederverwendet aus dem alten Produkt). `bewertung`/`entscheidung`/
`bewertung` ist append-only. Getestet inkl. K.-o.-Prüfung `bewertung` ist append-only. Getestet inkl. K.-o.-Prüfung
(`verboten` überspringt die Werkzeugsuche) und hartem Filter (`verboten` überspringt die Werkzeugsuche) und hartem Filter
gegen den Katalog.) gegen den Katalog.)
5. Entscheidung, Snapshot, Audit-Log 5. ~~Entscheidung, Snapshot, Audit-Log~~**erledigt** (Ebene 3:
`GET /faelle` Posteingang, `GET /faelle/{id}` + `POST
/faelle/{id}/entscheiden`. `verantwortlicher` entscheidet,
`pruefer` sieht dieselbe Seite ohne Entscheidungsrecht [403 bei
Entscheidungsversuch]. Begründung ist Pflicht bei Abweichung vom
abgeleiteten Vorschlag (`vorschlagFuer`), Genehmigung friert den
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
7. Wiedervorlage und Katalog-Benachrichtigung 7. Wiedervorlage und Katalog-Benachrichtigung

View File

@@ -0,0 +1,111 @@
package store
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Entscheidung ist die Entscheidung eines/einer Verantwortlichen über
// einen Antrag, auf Basis einer Bewertung (Vorschlag). Append-only —
// siehe Migration. WerkzeugSnapshot ist der eingefrorene, vollständige
// Werkzeug-Datensatz zum Entscheidungszeitpunkt (nil, wenn kein
// Werkzeug gewählt wurde, z. B. bei "abgelehnt") — ein späterer
// Katalog-Wandel darf nicht rückwirkend verändern, worauf die
// Entscheidung beruhte.
type Entscheidung struct {
ID string
AntragID string
BewertungID string
EntscheiderUserID string
Entscheidung string
WerkzeugID *string
WerkzeugSnapshot *Werkzeug
Begruendung string
GueltigBis *time.Time
CreatedAt time.Time
}
const entscheidungColumns = `id, antrag_id, bewertung_id, entscheider_user_id, entscheidung,
werkzeug_id, werkzeug_snapshot, begruendung, gueltig_bis, created_at`
func scanEntscheidung(row interface {
Scan(dest ...any) error
}) (Entscheidung, error) {
var e Entscheidung
var snapshotRaw []byte
err := row.Scan(
&e.ID, &e.AntragID, &e.BewertungID, &e.EntscheiderUserID, &e.Entscheidung,
&e.WerkzeugID, &snapshotRaw, &e.Begruendung, &e.GueltigBis, &e.CreatedAt,
)
if err != nil {
return Entscheidung{}, err
}
if snapshotRaw != nil {
var w Werkzeug
if err := json.Unmarshal(snapshotRaw, &w); err != nil {
return Entscheidung{}, fmt.Errorf("store: werkzeug_snapshot unmarshal: %w", err)
}
e.WerkzeugSnapshot = &w
}
return e, nil
}
// EntscheidungInput bündelt die Felder einer neuen Entscheidung.
type EntscheidungInput struct {
AntragID string
BewertungID string
EntscheiderUserID string
Entscheidung string
WerkzeugID *string
WerkzeugSnapshot *Werkzeug
Begruendung string
GueltigBis *time.Time
}
// CreateEntscheidung speichert eine Entscheidung über einen Antrag.
func (s *Store) CreateEntscheidung(ctx context.Context, in EntscheidungInput) (Entscheidung, error) {
var snapshotJSON []byte
if in.WerkzeugSnapshot != nil {
var err error
snapshotJSON, err = json.Marshal(in.WerkzeugSnapshot)
if err != nil {
return Entscheidung{}, fmt.Errorf("store: werkzeug_snapshot marshal: %w", err)
}
}
row := s.Pool.QueryRow(ctx, `
INSERT INTO entscheidung (
antrag_id, bewertung_id, entscheider_user_id, entscheidung, werkzeug_id, werkzeug_snapshot, begruendung, gueltig_bis
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING `+entscheidungColumns,
in.AntragID, in.BewertungID, in.EntscheiderUserID, in.Entscheidung, in.WerkzeugID, snapshotJSON, in.Begruendung, in.GueltigBis,
)
e, err := scanEntscheidung(row)
if err != nil {
return Entscheidung{}, fmt.Errorf("store: create entscheidung: %w", err)
}
return e, nil
}
// GetLatestEntscheidungForAntrag liefert die zuletzt getroffene
// Entscheidung eines Antrags. Liefert ErrNotFound, wenn noch keine
// Entscheidung existiert.
func (s *Store) GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (Entscheidung, error) {
row := s.Pool.QueryRow(ctx, `
SELECT `+entscheidungColumns+`
FROM entscheidung WHERE antrag_id = $1 ORDER BY created_at DESC LIMIT 1
`, antragID)
e, err := scanEntscheidung(row)
if errors.Is(err, pgx.ErrNoRows) {
return Entscheidung{}, ErrNotFound
}
if err != nil {
return Entscheidung{}, fmt.Errorf("store: get latest entscheidung: %w", err)
}
return e, nil
}

View File

@@ -0,0 +1,115 @@
package store_test
import (
"context"
"errors"
"testing"
"time"
"github.com/netcell-it/deklarix/internal/store"
)
func testAntragMitBewertung(t *testing.T, s *store.Store, accountID, userID string) (store.Antrag, store.Bewertung) {
t.Helper()
ctx := context.Background()
antrag, err := s.CreateAntrag(ctx, accountID, userID, nil, "Testantrag")
if err != nil {
t.Fatalf("CreateAntrag: %v", err)
}
b, err := s.CreateBewertung(ctx, testBewertungInput(antrag.ID))
if err != nil {
t.Fatalf("CreateBewertung: %v", err)
}
return antrag, b
}
func TestEntscheidungCreateAndGetLatest(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
userID := testUserID(t, s, accID)
antrag, b := testAntragMitBewertung(t, s, accID, userID)
w, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "ChatGPT Enterprise"))
if err != nil {
t.Fatalf("CreateWerkzeug: %v", err)
}
gueltigBis := time.Now().Add(365 * 24 * time.Hour).Truncate(time.Millisecond)
e, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
AntragID: antrag.ID, BewertungID: b.ID, EntscheiderUserID: userID,
Entscheidung: "genehmigt", WerkzeugID: &w.ID, WerkzeugSnapshot: &w,
Begruendung: "", GueltigBis: &gueltigBis,
})
if err != nil {
t.Fatalf("CreateEntscheidung: %v", err)
}
if e.Entscheidung != "genehmigt" || e.WerkzeugSnapshot == nil || e.WerkzeugSnapshot.Name != "ChatGPT Enterprise" {
t.Fatalf("CreateEntscheidung = %+v, unerwartete Werte", e)
}
got, err := s.GetLatestEntscheidungForAntrag(ctx, antrag.ID)
if err != nil {
t.Fatalf("GetLatestEntscheidungForAntrag: %v", err)
}
if got.ID != e.ID {
t.Fatalf("GetLatestEntscheidungForAntrag returned a different row than CreateEntscheidung")
}
}
func TestEntscheidungOhneWerkzeugHatKeinenSnapshot(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
userID := testUserID(t, s, accID)
antrag, b := testAntragMitBewertung(t, s, accID, userID)
e, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
AntragID: antrag.ID, BewertungID: b.ID, EntscheiderUserID: userID,
Entscheidung: "abgelehnt", Begruendung: "Kein geeignetes Werkzeug im Katalog",
})
if err != nil {
t.Fatalf("CreateEntscheidung: %v", err)
}
if e.WerkzeugID != nil || e.WerkzeugSnapshot != nil {
t.Fatalf("expected no werkzeug/snapshot for an abgelehnt Entscheidung, got %+v", e)
}
}
func TestGetLatestEntscheidungForAntragNotFound(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
userID := testUserID(t, s, accID)
antrag, err := s.CreateAntrag(ctx, accID, userID, nil, "Ohne Entscheidung")
if err != nil {
t.Fatalf("CreateAntrag: %v", err)
}
_, err = s.GetLatestEntscheidungForAntrag(ctx, antrag.ID)
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound", err)
}
}
func TestEntscheidungIsAppendOnly(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
userID := testUserID(t, s, accID)
antrag, b := testAntragMitBewertung(t, s, accID, userID)
e, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
AntragID: antrag.ID, BewertungID: b.ID, EntscheiderUserID: userID, Entscheidung: "rueckfrage",
})
if err != nil {
t.Fatalf("CreateEntscheidung: %v", err)
}
_, err = s.Pool.Exec(ctx, `UPDATE entscheidung SET entscheidung = 'genehmigt' WHERE id = $1`, e.ID)
if err == nil {
t.Fatal("expected UPDATE on entscheidung to be rejected by the append-only trigger")
}
_, err = s.Pool.Exec(ctx, `DELETE FROM entscheidung WHERE id = $1`, e.ID)
if err == nil {
t.Fatal("expected DELETE on entscheidung to be rejected by the append-only trigger")
}
}

View File

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

View File

@@ -0,0 +1,25 @@
-- Entscheidung: der Mensch entscheidet, das System bereitet nur vor
-- (siehe CLAUDE.md, Grundregel). Eine Entscheidung bezieht sich auf
-- genau eine Bewertung (den Vorschlag, auf dessen Basis entschieden
-- wurde) und friert optional ein gewähltes Werkzeug als vollständigen
-- Datensatz ein (werkzeug_snapshot) — ein späterer Katalog-Wandel darf
-- nicht rückwirkend verändern, worauf eine Entscheidung beruhte.
-- Append-only wie bewertung/audit_log: eine Entscheidung wird nicht
-- korrigiert, sondern durch eine neue Entscheidung auf Basis einer
-- neuen Bewertung ersetzt (Neubewertung + erneute Entscheidung).
CREATE TABLE entscheidung (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
antrag_id UUID NOT NULL REFERENCES antrag (id),
bewertung_id UUID NOT NULL REFERENCES bewertung (id),
entscheider_user_id UUID NOT NULL REFERENCES app_user (id),
entscheidung TEXT NOT NULL CHECK (entscheidung IN ('genehmigt', 'genehmigt_mit_auflagen', 'abgelehnt', 'rueckfrage')),
werkzeug_id UUID REFERENCES werkzeug (id),
werkzeug_snapshot JSONB,
begruendung TEXT NOT NULL DEFAULT '',
gueltig_bis TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TRIGGER entscheidung_append_only
BEFORE UPDATE OR DELETE ON entscheidung
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();

View File

@@ -0,0 +1,236 @@
// Ebene 3 (Fachebene) — Posteingang offener Anträge und Entscheiden.
// Rollen "verantwortlicher" (Entscheidungsrecht) und "pruefer"
// (identische Sicht, kein Entscheidungsrecht) sehen dieselben Seiten;
// siehe requireFachebene in middleware.go und CLAUDE.md, Fünf Ebenen.
package web
import (
"net/http"
"time"
"github.com/netcell-it/deklarix/internal/store"
)
type fallListItem struct {
ID string
Titel string
Haeufigkeit string
CreatedAt string
}
type fallListData struct {
Title string
Nav navData
Faelle []fallListItem
}
// handleFaelleListe zeigt den Posteingang: alle noch offenen (Status
// "eingereicht") Anträge des ganzen Mandanten — im Unterschied zu
// GET /antraege, das nur die eigenen Anträge eines Nutzers zeigt.
func (s *Server) handleFaelleListe(w http.ResponseWriter, r *http.Request) {
antraege, err := s.store.ListAntraegeForAccount(r.Context(), currentUser(r).AccountID)
if err != nil {
http.Error(w, "Anträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := fallListData{Title: "Posteingang", Nav: navFor(r)}
for _, a := range antraege {
if a.Status != "eingereicht" {
continue
}
data.Faelle = append(data.Faelle, fallListItem{
ID: a.ID, Titel: a.Titel, Haeufigkeit: a.Haeufigkeit,
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
})
}
if err := s.templates.ExecuteTemplate(w, "faelle-liste", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type entscheidungView struct {
Entscheidung string
Begruendung string
WerkzeugName string
GueltigBis string
CreatedAt string
}
type werkzeugOption struct {
ID string
Name string
}
type fallDetailData struct {
Title string
Nav navData
ID string
Titel string
Beschreibung string
Ergebnis string
Haeufigkeit string
Status string
CreatedAt string
Bewertung *bewertungView
Entscheidung *entscheidungView
CanEntscheiden bool
WerkzeugOptionen []werkzeugOption
Error string
}
// vorschlagFuer leitet aus einer Bewertung ab, welche Entscheidung das
// System vorschlagen würde — nicht gespeichert, nur zur Bestimmung, ob
// eine tatsächliche Entscheidung davon abweicht (dann ist die
// Begründung Pflicht, siehe CLAUDE.md, Grundregel).
func vorschlagFuer(b store.Bewertung) string {
if b.Verboten {
return "abgelehnt"
}
if len(b.ZulaessigeWerkzeuge) > 0 {
return "genehmigt"
}
return "rueckfrage"
}
// handleFallDetail zeigt einen Antrag mit Bewertung für die Fachebene
// — anders als GET /antraege/{id} (nur der eigene Antrag) ist hier
// jeder Antrag des Mandanten sichtbar, unabhängig vom Ersteller.
func (s *Server) handleFallDetail(w http.ResponseWriter, r *http.Request) {
antrag, err := s.store.GetAntrag(r.Context(), r.PathValue("id"))
if err != nil || antrag.AccountID != currentUser(r).AccountID {
http.Error(w, "Antrag nicht gefunden", http.StatusNotFound)
return
}
data := fallDetailData{
Title: "Fall", Nav: navFor(r), ID: antrag.ID, Titel: antrag.Titel,
Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit,
Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"),
}
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
if err == nil {
data.Bewertung = s.toBewertungView(r.Context(), bewertung)
for _, id := range bewertung.ZulaessigeWerkzeuge {
data.WerkzeugOptionen = append(data.WerkzeugOptionen, werkzeugOption{ID: id, Name: s.werkzeugName(r.Context(), id)})
}
}
data.CanEntscheiden = currentUser(r).Role == "verantwortlicher" && antrag.Status == "eingereicht" && data.Bewertung != nil
entscheidung, err := s.store.GetLatestEntscheidungForAntrag(r.Context(), antrag.ID)
if err == nil {
v := &entscheidungView{
Entscheidung: entscheidung.Entscheidung, Begruendung: entscheidung.Begruendung,
CreatedAt: entscheidung.CreatedAt.Format("02.01.2006 15:04"),
}
if entscheidung.WerkzeugSnapshot != nil {
v.WerkzeugName = entscheidung.WerkzeugSnapshot.Name
}
if entscheidung.GueltigBis != nil {
v.GueltigBis = entscheidung.GueltigBis.Format("02.01.2006")
}
data.Entscheidung = v
}
if err := s.templates.ExecuteTemplate(w, "fall-detail", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// gueltigkeitFuer liefert das Ablaufdatum einer Genehmigung — 12 Monate,
// bei hochrisiko 6 (siehe CLAUDE.md, Entscheidung/Register/Wiedervorlage).
func gueltigkeitFuer(einstufung string) time.Time {
if einstufung == "hochrisiko" {
return time.Now().AddDate(0, 6, 0)
}
return time.Now().AddDate(1, 0, 0)
}
// handleFallEntscheiden speichert die Entscheidung eines/einer
// Verantwortlichen. Prüfer haben dieselbe Sicht, aber kein
// Entscheidungsrecht — anders als bei requireFachebene (404, weil die
// Seite für sie gar nicht existieren soll) ist das hier ein 403: ein
// Prüfer darf wissen, dass der Fall existiert, nur nicht entscheiden.
func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
if user.Role != "verantwortlicher" {
http.Error(w, "kein Entscheidungsrecht", http.StatusForbidden)
return
}
antrag, err := s.store.GetAntrag(r.Context(), r.PathValue("id"))
if err != nil || antrag.AccountID != user.AccountID {
http.Error(w, "Antrag nicht gefunden", http.StatusNotFound)
return
}
if antrag.Status != "eingereicht" {
http.Error(w, "Antrag ist bereits entschieden", http.StatusBadRequest)
return
}
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
if err != nil {
http.Error(w, "Bewertung nicht gefunden", http.StatusNotFound)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
entscheidungTyp := r.FormValue("entscheidung")
begruendung := r.FormValue("begruendung")
werkzeugID := r.FormValue("werkzeug_id")
switch entscheidungTyp {
case "genehmigt", "genehmigt_mit_auflagen", "abgelehnt", "rueckfrage":
default:
http.Error(w, "ungültige Entscheidung", http.StatusBadRequest)
return
}
if vorschlagFuer(bewertung) != entscheidungTyp && begruendung == "" {
http.Error(w, "Begründung erforderlich, wenn die Entscheidung vom Vorschlag abweicht", http.StatusBadRequest)
return
}
in := store.EntscheidungInput{
AntragID: antrag.ID, BewertungID: bewertung.ID, EntscheiderUserID: user.ID,
Entscheidung: entscheidungTyp, Begruendung: begruendung,
}
if entscheidungTyp == "genehmigt" || entscheidungTyp == "genehmigt_mit_auflagen" {
zulaessig := false
for _, id := range bewertung.ZulaessigeWerkzeuge {
if id == werkzeugID {
zulaessig = true
break
}
}
if werkzeugID == "" || !zulaessig {
http.Error(w, "bei Genehmigung muss ein zulässiges Werkzeug gewählt werden", http.StatusBadRequest)
return
}
werkzeug, err := s.store.GetWerkzeug(r.Context(), werkzeugID)
if err != nil {
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
return
}
in.WerkzeugID = &werkzeugID
in.WerkzeugSnapshot = &werkzeug
gueltigBis := gueltigkeitFuer(bewertung.Einstufung)
in.GueltigBis = &gueltigBis
}
if _, err := s.store.CreateEntscheidung(r.Context(), in); err != nil {
http.Error(w, "Entscheidung konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
return
}
if err := s.store.SetAntragStatus(r.Context(), antrag.ID, "entschieden"); err != nil {
http.Error(w, "Antrag konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
return
}
if _, err := s.store.CreateAuditEntry(r.Context(), user.ID, "antrag_entschieden", "antrag", antrag.ID, entscheidungTyp); err != nil {
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/faelle/"+antrag.ID, http.StatusSeeOther)
}

View File

@@ -0,0 +1,216 @@
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"
)
// seedFallImAccount legt einen Mandanten mit einem Mitarbeiter an, der
// einen Antrag einreicht (mit personenbezogener Datenklasse, Einstufung
// minimal — siehe fullAntragForm), und liefert dessen ID sowie eine
// zweite Cookie für einen weiteren Nutzer der Rolle role im selben
// Mandanten (verantwortlicher/pruefer).
func seedFallImAccount(t *testing.T, fs *fakeStore, s *web.Server, role string) (antragID string, fachebeneCookie *http.Cookie) {
t.Helper()
ctx := context.Background()
acc, err := fs.CreateAccount(ctx, "Fachebene-Mandant")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
mitarbeiterCookie := seedUserInAccount(t, fs, acc.ID, "mitarbeiter@example.com", "mitarbeiter")
resp := postForm(t, s, mitarbeiterCookie, "/antraege", fullAntragForm())
if resp.Code != http.StatusSeeOther {
t.Fatalf("Antrag anlegen: status = %d, body: %s", resp.Code, resp.Body.String())
}
for id := range fs.antraege {
if fs.antraege[id].AccountID == acc.ID {
antragID = id
}
}
fachebeneCookie = seedUserInAccount(t, fs, acc.ID, role+"@example.com", role)
return antragID, fachebeneCookie
}
func TestMitarbeiterCannotAccessFaelle(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookie := seedAccount(t, fs, "Test-Mandant", "mitarbeiter@example.com")
resp := getWithCookie(t, s, cookie, "/faelle")
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for role mitarbeiter on /faelle", resp.Code)
}
}
func TestFaelleListeShowsOpenAntraegeForAccount(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
_, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
resp := getWithCookie(t, s, verantwortlicherCookie, "/faelle")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "Angebotstexte generieren") {
t.Errorf("expected the open antrag in the Posteingang, got: %s", resp.Body.String())
}
// Ein Verantwortlicher eines anderen Mandanten darf ihn nicht sehen.
otherAcc, err := fs.CreateAccount(context.Background(), "Anderer Mandant")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
otherCookie := seedUserInAccount(t, fs, otherAcc.ID, "other-verantwortlicher@example.com", "verantwortlicher")
otherResp := getWithCookie(t, s, otherCookie, "/faelle")
if strings.Contains(otherResp.Body.String(), "Angebotstexte generieren") {
t.Errorf("expected no cross-tenant antrag in the Posteingang, got: %s", otherResp.Body.String())
}
}
func TestFallDetailShowsDecideFormOnlyForVerantwortlicher(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
antragID, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
resp := getWithCookie(t, s, verantwortlicherCookie, "/faelle/"+antragID)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), `name="entscheidung"`) {
t.Errorf("expected the Entscheiden form for verantwortlicher, got: %s", resp.Body.String())
}
}
func TestFallDetailHidesDecideFormForPruefer(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
antragID, prueferCookie := seedFallImAccount(t, fs, s, "pruefer")
resp := getWithCookie(t, s, prueferCookie, "/faelle/"+antragID)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if strings.Contains(resp.Body.String(), `name="entscheidung"`) {
t.Errorf("expected no Entscheiden form for pruefer, got: %s", resp.Body.String())
}
}
func TestFallEntscheidenRejectsPruefer(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
antragID, prueferCookie := seedFallImAccount(t, fs, s, "pruefer")
resp := postForm(t, s, prueferCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"rueckfrage"}})
if resp.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 for pruefer", resp.Code)
}
}
// TestFallEntscheidenRequiresBegruendungOnAbweichung: fullAntragForm
// liefert keine passenden Werkzeuge im (leeren) Katalog, der Vorschlag
// ist also "rueckfrage" (siehe vorschlagFuer). "abgelehnt" weicht davon
// ab und braucht eine Begründung.
func TestFallEntscheidenRequiresBegruendungOnAbweichung(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"}})
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 ohne Begründung bei Abweichung, body: %s", resp.Code, resp.Body.String())
}
resp = postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
"entscheidung": {"abgelehnt"}, "begruendung": {"Kein Werkzeug im Katalog verfügbar, trotzdem vertretbar"},
})
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303 mit Begründung, body: %s", resp.Code, resp.Body.String())
}
}
func TestFallEntscheidenRueckfrageOhneAbweichungBrauchtKeineBegruendung(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": {"rueckfrage"}})
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303 (rueckfrage entspricht dem Vorschlag bei leerem Katalog), body: %s", resp.Code, resp.Body.String())
}
detail := getWithCookie(t, s, verantwortlicherCookie, "/faelle/"+antragID)
if !strings.Contains(detail.Body.String(), "rueckfrage") {
t.Errorf("expected the Entscheidung on the fall-detail page, got: %s", detail.Body.String())
}
}
func TestFallEntscheidenGenehmigtBrauchtZulaessigesWerkzeug(t *testing.T) {
fs := newFakeStore()
acc, err := fs.CreateAccount(context.Background(), "Mit Katalog")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
mitarbeiterCookie := seedUserInAccount(t, fs, acc.ID, "mitarbeiter@example.com", "mitarbeiter")
fs.werkzeuge["werkzeug-ok"] = store.Werkzeug{ID: "werkzeug-ok", Name: "ChatGPT Enterprise", AVVVerfuegbar: true, TrainingStandard: true}
s := newServer(t, fs)
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")
// Ohne werkzeug_id: abgelehnt.
resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"genehmigt"}})
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 ohne Werkzeug bei Genehmigung", resp.Code)
}
// Mit zulässigem Werkzeug: erfolgreich, Antrag wird "entschieden".
resp = postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
"entscheidung": {"genehmigt"}, "werkzeug_id": {"werkzeug-ok"},
})
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
antrag, err := fs.GetAntrag(context.Background(), antragID)
if err != nil {
t.Fatalf("GetAntrag: %v", err)
}
if antrag.Status != "entschieden" {
t.Errorf("Status = %q, want entschieden", antrag.Status)
}
detail := getWithCookie(t, s, verantwortlicherCookie, "/faelle/"+antragID)
if !strings.Contains(detail.Body.String(), "ChatGPT Enterprise") {
t.Errorf("expected the chosen werkzeug name on the fall-detail page, got: %s", detail.Body.String())
}
}
func TestFallDetailRejectsForeignAccount(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
antragID, _ := seedFallImAccount(t, fs, s, "verantwortlicher")
otherAcc, err := fs.CreateAccount(context.Background(), "Fremder Mandant")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
otherCookie := seedUserInAccount(t, fs, otherAcc.ID, "fremd@example.com", "verantwortlicher")
resp := getWithCookie(t, s, otherCookie, "/faelle/"+antragID)
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for a foreign account's fall", resp.Code)
}
}

View File

@@ -89,19 +89,44 @@ func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
} }
} }
// requireFachebene schützt die Fachebene (Ebene 3 — Posteingang und
// Entscheiden, Rollen "verantwortlicher" und "pruefer", siehe
// CLAUDE.md). Beide Rollen sehen dieselbe Sicht; ob innerhalb der
// Seite tatsächlich entschieden werden darf, prüft der einzelne
// Handler (nur "verantwortlicher" hat Entscheidungsrecht). Wie bei
// requireBetreiber: 404 statt 403 bei falscher Rolle, damit ein
// angemeldeter, aber unprivilegierter Nutzer (z. B. "mitarbeiter")
// nicht erfährt, dass es die Seite überhaupt gibt.
func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := s.authenticate(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if user.Role != "verantwortlicher" && user.Role != "pruefer" {
http.Error(w, "nicht gefunden", http.StatusNotFound)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
}
}
// navData steuert die gemeinsame Navigation (layout.html, "nav"-Block). // navData steuert die gemeinsame Navigation (layout.html, "nav"-Block).
// Eigenes, kleines Struct statt jeder Seite Zugriff auf den vollen // Eigenes, kleines Struct statt jeder Seite Zugriff auf den vollen
// currentUser zu geben — die Navigation braucht nur, ob ein // currentUser zu geben — die Navigation braucht nur, ob ein
// Plattform-Link gezeigt werden soll. // Plattform-Link gezeigt werden soll.
type navData struct { type navData struct {
IsBetreiber bool IsBetreiber bool
IsFachebene bool
} }
// navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage. // navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage.
// Nur für Seiten hinter requirePage/requireBetreiber aufrufbar (braucht // Nur für Seiten hinter requirePage/requireBetreiber aufrufbar (braucht
// currentUser). // currentUser).
func navFor(r *http.Request) navData { func navFor(r *http.Request) navData {
return navData{IsBetreiber: currentUser(r).Role == "betreiber"} role := currentUser(r).Role
return navData{IsBetreiber: role == "betreiber", IsFachebene: role == "verantwortlicher" || role == "pruefer"}
} }
// currentUser liest den Nutzer, den requirePage/requireAPI in den // currentUser liest den Nutzer, den requirePage/requireAPI in den

View File

@@ -47,12 +47,16 @@ type Store interface {
UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (store.Antrag, error) UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (store.Antrag, error)
SetAntragStatus(ctx context.Context, id, status string) error SetAntragStatus(ctx context.Context, id, status string) error
ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]store.Antrag, error) ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]store.Antrag, error)
ListAntraegeForAccount(ctx context.Context, accountID string) ([]store.Antrag, error)
ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]store.Werkzeug, error) ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]store.Werkzeug, error)
GetWerkzeug(ctx context.Context, id string) (store.Werkzeug, error) GetWerkzeug(ctx context.Context, id string) (store.Werkzeug, error)
CurrentKatalogVersion(ctx context.Context) (string, error) CurrentKatalogVersion(ctx context.Context) (string, error)
CreateBewertung(ctx context.Context, in store.BewertungInput) (store.Bewertung, error) CreateBewertung(ctx context.Context, in store.BewertungInput) (store.Bewertung, error)
GetLatestBewertungForAntrag(ctx context.Context, antragID string) (store.Bewertung, error) GetLatestBewertungForAntrag(ctx context.Context, antragID string) (store.Bewertung, error)
CreateEntscheidung(ctx context.Context, in store.EntscheidungInput) (store.Entscheidung, error)
GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (store.Entscheidung, error)
} }
// Regelwerk bündelt die drei geladenen Regelwerk-Dateien, die die // Regelwerk bündelt die drei geladenen Regelwerk-Dateien, die die
@@ -99,6 +103,9 @@ func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
mux.HandleFunc("GET /antraege/neu", s.requirePage(s.handleAntragNewForm)) mux.HandleFunc("GET /antraege/neu", s.requirePage(s.handleAntragNewForm))
mux.HandleFunc("POST /antraege", s.requirePage(s.handleAntragCreate)) mux.HandleFunc("POST /antraege", s.requirePage(s.handleAntragCreate))
mux.HandleFunc("GET /antraege/{id}", s.requirePage(s.handleAntragDetail)) mux.HandleFunc("GET /antraege/{id}", s.requirePage(s.handleAntragDetail))
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 /betreiber", s.requireBetreiber(s.handleBetreiberDashboard)) mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard))
mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList)) mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList))
mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail)) mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail))

View File

@@ -24,29 +24,31 @@ 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
} }
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{},
} }
} }
@@ -243,6 +245,18 @@ func (f *fakeStore) ListAntraegeForUser(ctx context.Context, erstellerUserID str
return out, nil return out, nil
} }
func (f *fakeStore) ListAntraegeForAccount(ctx context.Context, accountID string) ([]store.Antrag, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.Antrag
for _, a := range f.antraege {
if a.AccountID == accountID {
out = append(out, a)
}
}
return out, nil
}
func (f *fakeStore) ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]store.Werkzeug, error) { func (f *fakeStore) ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]store.Werkzeug, error) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
@@ -297,6 +311,28 @@ func (f *fakeStore) GetLatestBewertungForAntrag(ctx context.Context, antragID st
return bs[len(bs)-1], nil return bs[len(bs)-1], nil
} }
func (f *fakeStore) CreateEntscheidung(ctx context.Context, in store.EntscheidungInput) (store.Entscheidung, error) {
f.mu.Lock()
defer f.mu.Unlock()
e := store.Entscheidung{
ID: f.newID(), AntragID: in.AntragID, BewertungID: in.BewertungID, EntscheiderUserID: in.EntscheiderUserID,
Entscheidung: in.Entscheidung, WerkzeugID: in.WerkzeugID, WerkzeugSnapshot: in.WerkzeugSnapshot,
Begruendung: in.Begruendung, GueltigBis: in.GueltigBis, CreatedAt: time.Now(),
}
f.entscheidungen[in.AntragID] = append(f.entscheidungen[in.AntragID], e)
return e, nil
}
func (f *fakeStore) GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (store.Entscheidung, error) {
f.mu.Lock()
defer f.mu.Unlock()
es := f.entscheidungen[antragID]
if len(es) == 0 {
return store.Entscheidung{}, store.ErrNotFound
}
return es[len(es)-1], nil
}
// ─── Test-Setup ─────────────────────────────────────────────────────── // ─── Test-Setup ───────────────────────────────────────────────────────
// loadTestRegelwerk lädt die echten rules/*.yaml-Dateien — dieselben, // loadTestRegelwerk lädt die echten rules/*.yaml-Dateien — dieselben,
@@ -358,6 +394,31 @@ func seedAccountWithRole(t *testing.T, fs *fakeStore, accountName, email, role s
return &http.Cookie{Name: testSessionCookie, Value: token} return &http.Cookie{Name: testSessionCookie, Value: token}
} }
// seedUserInAccount legt einen weiteren Nutzer in einem bereits
// existierenden Account an — für Tests, die mehrere Rollen im selben
// Mandanten brauchen (z. B. mitarbeiter stellt einen Antrag,
// verantwortlicher entscheidet darüber).
func seedUserInAccount(t *testing.T, fs *fakeStore, accountID, email, role string) *http.Cookie {
t.Helper()
ctx := context.Background()
hash, err := auth.HashPassword("test-passwort-123")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
user, err := fs.CreateUser(ctx, accountID, email, hash, role)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
token, err := auth.NewSessionToken()
if err != nil {
t.Fatalf("NewSessionToken: %v", err)
}
if _, err := fs.CreateSession(ctx, token, user.ID, time.Now().Add(time.Hour)); err != nil {
t.Fatalf("CreateSession: %v", err)
}
return &http.Cookie{Name: testSessionCookie, Value: token}
}
func seedAccount(t *testing.T, fs *fakeStore, accountName, email string) *http.Cookie { func seedAccount(t *testing.T, fs *fakeStore, accountName, email string) *http.Cookie {
t.Helper() t.Helper()
return seedAccountWithRole(t, fs, accountName, email, "mitarbeiter") return seedAccountWithRole(t, fs, accountName, email, "mitarbeiter")

View File

@@ -14,60 +14,7 @@
<p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</p> <p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</p>
{{if .Bewertung}} {{if .Bewertung}}
<h2>Bewertung</h2> {{template "bewertung-block" .Bewertung}}
{{if .Bewertung.Verboten}}
<p class="fehler"><strong>Verboten.</strong> Diese Nutzung fällt laut Einstufung unter ein verbotenes KI-Praktiken-Muster der KI-VO ({{.Bewertung.Einstufung}}). {{.Bewertung.EinstufungHerleitung}}</p>
{{end}}
<p><strong>Datenklasse:</strong> {{.Bewertung.Datenklasse}}<br>
<span class="hinweis">{{.Bewertung.DatenklasseHerleitung}}</span></p>
<p><strong>KI-VO-Einstufung:</strong> {{.Bewertung.Einstufung}}<br>
<span class="hinweis">{{.Bewertung.EinstufungHerleitung}}</span></p>
{{if .Bewertung.Anforderungen}}
<h3>Anforderungen</h3>
<ul class="findings">
{{range .Bewertung.Anforderungen}}
<li class="finding finding-mittel">
<strong>{{.Beschreibung}}</strong>
<p>{{.Herleitung}}</p>
</li>
{{end}}
</ul>
{{end}}
{{if not .Bewertung.Verboten}}
<h3>Zulässige Werkzeuge</h3>
{{if .Bewertung.ZulaessigeWerkzeuge}}
<ul>
{{range .Bewertung.ZulaessigeWerkzeuge}}<li>{{.}}</li>{{end}}
</ul>
{{else}}
<p class="rueckfrage">Kein Werkzeug im Katalog erfüllt aktuell alle Anforderungen dieses Antrags.</p>
{{end}}
{{if .Bewertung.AusgeschlosseneWerkzeuge}}
<h3>Ausgeschlossene Werkzeuge</h3>
<ul class="findings">
{{range .Bewertung.AusgeschlosseneWerkzeuge}}
<li class="finding finding-niedrig">
<strong>{{.Name}}</strong>
<p>Nicht erfüllt: {{range $i, $a := .NichtErfuellt}}{{if $i}}, {{end}}{{$a}}{{end}}</p>
</li>
{{end}}
</ul>
{{end}}
{{end}}
<p class="disclaimer">
Das System entscheidet nicht. Es bereitet vor. Diese Einstufung ist ein
Vorschlag zur Herleitung von Datenklasse, KI-VO-Einstufung und
Anforderungen (Regelwerk {{.Bewertung.RegelwerkVersion}}, Katalog
{{.Bewertung.KatalogVersion}}, berechnet am {{.Bewertung.CreatedAt}}) —
die Entscheidung trifft ein Mensch.
</p>
{{else}} {{else}}
<p class="hinweis">Die Bewertung wird berechnet, sobald der Antrag eingereicht ist.</p> <p class="hinweis">Die Bewertung wird berechnet, sobald der Antrag eingereicht ist.</p>
{{end}} {{end}}

View File

@@ -0,0 +1,24 @@
{{define "faelle-liste"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<h1>Posteingang</h1>
{{if not .Faelle}}
<p class="keine-findings">Keine offenen Anträge — alles bearbeitet.</p>
{{else}}
<ul class="beitraege-liste">
{{range .Faelle}}
<li>
<a href="/faelle/{{.ID}}">
<strong>{{.Titel}}</strong> · {{.Haeufigkeit}} · eingereicht am {{.CreatedAt}}
</a>
</li>
{{end}}
</ul>
{{end}}
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,60 @@
{{define "fall-detail"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<p><a href="/faelle">&larr; Posteingang</a></p>
<h1>{{.Titel}}</h1>
<p class="hinweis">Eingereicht am {{.CreatedAt}} — Status: <span class="status status-{{.Status}}">{{.Status}}</span></p>
<h2>Vorhaben</h2>
<p><strong>Was soll die KI tun?</strong><br>{{.Beschreibung}}</p>
<p><strong>Was soll herauskommen?</strong><br>{{.Ergebnis}}</p>
<p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</p>
{{if .Bewertung}}
{{template "bewertung-block" .Bewertung}}
{{else}}
<p class="hinweis">Für diesen Antrag liegt noch keine Bewertung vor.</p>
{{end}}
{{if .Entscheidung}}
<h2>Entscheidung</h2>
<p><strong>{{.Entscheidung.Entscheidung}}</strong>{{if .Entscheidung.WerkzeugName}} — {{.Entscheidung.WerkzeugName}}{{end}}<br>
<span class="hinweis">am {{.Entscheidung.CreatedAt}}{{if .Entscheidung.GueltigBis}}, gültig bis {{.Entscheidung.GueltigBis}}{{end}}</span></p>
{{if .Entscheidung.Begruendung}}<p>{{.Entscheidung.Begruendung}}</p>{{end}}
{{else if .CanEntscheiden}}
<h2>Entscheiden</h2>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<form method="post" action="/faelle/{{.ID}}/entscheiden">
<fieldset>
<legend>Entscheidung</legend>
<label><input type="radio" name="entscheidung" value="genehmigt" required> Genehmigt</label>
<label><input type="radio" name="entscheidung" value="genehmigt_mit_auflagen"> Genehmigt mit Auflagen</label>
<label><input type="radio" name="entscheidung" value="abgelehnt"> Abgelehnt</label>
<label><input type="radio" name="entscheidung" value="rueckfrage"> Rückfrage</label>
</fieldset>
{{if .WerkzeugOptionen}}
<label>Werkzeug (bei Genehmigung erforderlich)
<select name="werkzeug_id">
<option value="">— auswählen —</option>
{{range .WerkzeugOptionen}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
</label>
{{end}}
<label>Begründung (Pflicht bei Abweichung vom Vorschlag)
<textarea name="begruendung" rows="3"></textarea>
</label>
<button type="submit">Entscheidung speichern</button>
</form>
{{else}}
<p class="hinweis">Noch keine Entscheidung getroffen. Nur die Rolle "Verantwortlicher" kann entscheiden.</p>
{{end}}
</div>
</body>
</html>
{{end}}

View File

@@ -10,9 +10,72 @@
<nav> <nav>
<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 .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">
<button type="submit">Abmelden</button> <button type="submit">Abmelden</button>
</form> </form>
</nav> </nav>
{{end}} {{end}}
{{/* bewertung-block rendert eine Bewertung (Datenklasse, KI-VO-
Einstufung, Anforderungen, Werkzeuge, Pflicht-Disclaimer) — gemeinsam
genutzt von antrag_detail.html (Ebene 2, nur Ansicht) und
fall_detail.html (Ebene 3, mit Entscheiden-Formular). Erwartet eine
*bewertungView als . */}}
{{define "bewertung-block"}}
<h2>Bewertung</h2>
{{if .Verboten}}
<p class="fehler"><strong>Verboten.</strong> Diese Nutzung fällt laut Einstufung unter ein verbotenes KI-Praktiken-Muster der KI-VO ({{.Einstufung}}). {{.EinstufungHerleitung}}</p>
{{end}}
<p><strong>Datenklasse:</strong> {{.Datenklasse}}<br>
<span class="hinweis">{{.DatenklasseHerleitung}}</span></p>
<p><strong>KI-VO-Einstufung:</strong> {{.Einstufung}}<br>
<span class="hinweis">{{.EinstufungHerleitung}}</span></p>
{{if .Anforderungen}}
<h3>Anforderungen</h3>
<ul class="findings">
{{range .Anforderungen}}
<li class="finding finding-mittel">
<strong>{{.Beschreibung}}</strong>
<p>{{.Herleitung}}</p>
</li>
{{end}}
</ul>
{{end}}
{{if not .Verboten}}
<h3>Zulässige Werkzeuge</h3>
{{if .ZulaessigeWerkzeuge}}
<ul>
{{range .ZulaessigeWerkzeuge}}<li>{{.}}</li>{{end}}
</ul>
{{else}}
<p class="rueckfrage">Kein Werkzeug im Katalog erfüllt aktuell alle Anforderungen dieses Antrags.</p>
{{end}}
{{if .AusgeschlosseneWerkzeuge}}
<h3>Ausgeschlossene Werkzeuge</h3>
<ul class="findings">
{{range .AusgeschlosseneWerkzeuge}}
<li class="finding finding-niedrig">
<strong>{{.Name}}</strong>
<p>Nicht erfüllt: {{range $i, $a := .NichtErfuellt}}{{if $i}}, {{end}}{{$a}}{{end}}</p>
</li>
{{end}}
</ul>
{{end}}
{{end}}
<p class="disclaimer">
Das System entscheidet nicht. Es bereitet vor. Diese Einstufung ist ein
Vorschlag zur Herleitung von Datenklasse, KI-VO-Einstufung und
Anforderungen (Regelwerk {{.RegelwerkVersion}}, Katalog
{{.KatalogVersion}}, berechnet am {{.CreatedAt}}) —
die Entscheidung trifft ein Mensch.
</p>
{{end}}