From db9c5b7482b7cc306b37a8e4ca22d3267fed02b0 Mon Sep 17 00:00:00 2001 From: noroot Date: Fri, 28 Aug 2026 23:09:24 +0200 Subject: [PATCH] feat: Fragebogen mit adaptiver Logik (Schritt 2 der Baureihenfolge) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ebene 2 ("Antrag stellen") als einseitiges Formular statt mehrseitigem Assistenten — jede zusätzliche Seite kostet Zeit, und laut Spezifikation wird ein Antrag umgangen, wenn er länger als fünf Minuten dauert. - GET /antraege/neu: Fragebogen-Formular (Abschnitte A-D exakt nach Spezifikation). Adaptive Folgefragen (C2: welche Art von Entscheidung, C3: welche Art von Erkennung) werden rein per CSS :has() ein-/ ausgeblendet, kein JavaScript nötig — visuell mit Chromium-Screenshots verifiziert (unchecked vs. checked). - POST /antraege: legt an und reicht direkt ein (entwurf->eingereicht in einem Schritt, kein Zwischenspeichern als Entwurf für v1). Die antworten-JSON nutzt exakt dieselben Fakten-Schlüssel wie rules/*.yaml (b1-b7, c1-c5, c2_folge, c3_art) — Schritt 3 kann sie direkt auswerten, ohne Felder umzubenennen. "Unsicher" wird bewusst NICHT zu "ja" normalisiert (das ist eine Auswertungsregel für Schritt 3, keine Speicherregel) — der Antrag hält fest, was der Mitarbeiter tatsächlich geantwortet hat. - GET /antraege: eigene Anträge (Ebene 2 sieht nur eigene, nicht die des ganzen Mandanten — dafür neue Store-Methode ListAntraegeForUser, getrennt von ListAntraegeForAccount für den späteren Ebene-3-Posteingang). - GET /antraege/{id}: Detail, fremder Antrag liefert 404 (nicht 403, gleiches Muster wie überall sonst im Projekt). - betreiber-Rolle wird von allen Antrags-Routen weggeleitet (Ebene 5 ist technisch getrennt vom Mandantenbereich). rules/OPEN.md Punkt 3 (fehlende Fragebogen-Unterscheidung für die "verboten"-Varianten) damit geklärt: c3_art existiert jetzt mit den bereits in rules/kivo_einstufung.yaml erwarteten Werten. Volle Testsuite inkl. echter Postgres-Tests grün; End-to-End gegen einen laufenden Server verifiziert (Antrag anlegen, antworten-JSON in der DB korrekt, Detail-/Listenansicht, Mandantentrennung). --- CLAUDE.md | 15 +- internal/store/antrag.go | 27 +++ internal/store/antrag_test.go | 26 +++ internal/web/antrag_handlers.go | 213 +++++++++++++++++++++ internal/web/antrag_handlers_test.go | 136 +++++++++++++ internal/web/server.go | 11 ++ internal/web/server_test.go | 68 +++++++ internal/web/static/app.css | 40 ++++ internal/web/templates/antraege_liste.html | 26 +++ internal/web/templates/antrag_detail.html | 24 +++ internal/web/templates/antrag_neu.html | 122 ++++++++++++ internal/web/templates/index.html | 8 +- internal/web/templates/layout.html | 1 + rules/OPEN.md | 27 +-- 14 files changed, 719 insertions(+), 25 deletions(-) create mode 100644 internal/web/antrag_handlers.go create mode 100644 internal/web/antrag_handlers_test.go create mode 100644 internal/web/templates/antraege_liste.html create mode 100644 internal/web/templates/antrag_detail.html create mode 100644 internal/web/templates/antrag_neu.html diff --git a/CLAUDE.md b/CLAUDE.md index 2019db5..8803702 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -311,7 +311,13 @@ wiederverwendet aus dem alten Produkt). `bewertung`/`entscheidung`/ `abteilung`/`werkzeug`/`werkzeug_sperre`/`antrag` in Postgres, `internal/rules` lädt und validiert die drei YAML-Regelwerke, Web-Layer kompiliert mit Auth + Plattform-Bereich-Gerüst). -2. Fragebogen mit adaptiver Logik +2. ~~Fragebogen mit adaptiver Logik~~ — **erledigt** (`GET /antraege/neu` + einseitiges Formular, `POST /antraege` legt an und reicht direkt ein, + `GET /antraege` eigene Anträge, `GET /antraege/{id}` Detail. Adaptive + Folgefragen C2/C3 rein per CSS `:has()` ein-/ausgeblendet, kein + JavaScript. `antworten`-JSON nutzt exakt die Fakten-Schlüssel aus + `rules/*.yaml` (b1-b7, c1-c5, c2_folge, c3_art) — das hat auch die + OPEN.md-Frage zu den "verboten"-Fragebogen-Feldern final geklärt.) 3. Ableitungen und harte Filter (Auswertung gegen echte Antworten) 4. Ergebnisdarstellung mit Herleitung 5. Entscheidung, Snapshot, Audit-Log @@ -528,9 +534,10 @@ journalctl -u deklarix -f - **Rangfolge der Datenklassen und Grenzfälle im Anforderungsprofil** (siehe `rules/OPEN.md`) — vor Schritt 3 mit dem/der fachlich Verantwortlichen (z. B. Datenschutzbeauftragte/r) bestätigen. -- **Genaue Fragebogen-Felder für die drei "verboten"-Varianten** (KI-VO - Art. 5) — Fragebogen-Spezifikation nennt nur eine C3-Frage ohne die - nötige Unterscheidung, siehe `rules/OPEN.md`. +- ~~Genaue Fragebogen-Felder für die drei "verboten"-Varianten~~ — + **erledigt in Schritt 2**, C3 hat jetzt eine Folgefrage `c3_art`. + Weiterhin offen: ob die drei Varianten fachlich vollständig sind + (siehe `rules/OPEN.md`, Punkt 3). - **Löschfristen je Datenklasse** (konkrete Tageswerte für `loeschfrist_max_tage`) — noch nicht fachlich festgelegt. - **Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)** diff --git a/internal/store/antrag.go b/internal/store/antrag.go index 6278d53..2f0baf2 100644 --- a/internal/store/antrag.go +++ b/internal/store/antrag.go @@ -109,6 +109,33 @@ func (s *Store) SetAntragStatus(ctx context.Context, id, status string) error { return nil } +// ListAntraegeForUser liefert alle Anträge, die ein bestimmter Nutzer +// gestellt hat, neueste zuerst — für Ebene 2 ("eigene Anträge +// einsehen"), im Unterschied zu ListAntraegeForAccount, das alle +// Anträge eines Mandanten liefert (Ebene 3, Posteingang). +func (s *Store) ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]Antrag, error) { + rows, err := s.Pool.Query(ctx, ` + SELECT `+antragColumns+` FROM antrag WHERE ersteller_user_id = $1 ORDER BY created_at DESC + `, erstellerUserID) + if err != nil { + return nil, fmt.Errorf("store: list antraege for user: %w", err) + } + defer rows.Close() + + var out []Antrag + for rows.Next() { + a, err := scanAntrag(rows) + if err != nil { + return nil, fmt.Errorf("store: scan antrag: %w", err) + } + out = append(out, a) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: list antraege for user: %w", err) + } + return out, nil +} + // ListAntraegeForAccount liefert alle Anträge eines Mandanten, neueste // zuerst. func (s *Store) ListAntraegeForAccount(ctx context.Context, accountID string) ([]Antrag, error) { diff --git a/internal/store/antrag_test.go b/internal/store/antrag_test.go index 2177c11..9267e0c 100644 --- a/internal/store/antrag_test.go +++ b/internal/store/antrag_test.go @@ -82,6 +82,32 @@ func TestSetAntragStatusNotFound(t *testing.T) { } } +func TestListAntraegeForUserOnlyReturnsOwnAntraege(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + accID := testAccountID(t, s) + userA := testUserID(t, s, accID) + userB, err := s.CreateUser(ctx, accID, "zweiter-mitarbeiter-"+accID+"@example.com", "hash", "mitarbeiter") + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + + if _, err := s.CreateAntrag(ctx, accID, userA, nil, "Antrag von A"); err != nil { + t.Fatalf("CreateAntrag (A): %v", err) + } + if _, err := s.CreateAntrag(ctx, accID, userB.ID, nil, "Antrag von B"); err != nil { + t.Fatalf("CreateAntrag (B): %v", err) + } + + listA, err := s.ListAntraegeForUser(ctx, userA) + if err != nil { + t.Fatalf("ListAntraegeForUser: %v", err) + } + if len(listA) != 1 || listA[0].Titel != "Antrag von A" { + t.Fatalf("ListAntraegeForUser(A) = %+v, want exactly Antrag von A", listA) + } +} + func TestListAntraegeForAccountIsolatesTenants(t *testing.T) { s := openTestStore(t) ctx := context.Background() diff --git a/internal/web/antrag_handlers.go b/internal/web/antrag_handlers.go new file mode 100644 index 0000000..d2ad1fe --- /dev/null +++ b/internal/web/antrag_handlers.go @@ -0,0 +1,213 @@ +package web + +import ( + "encoding/json" + "net/http" + + "github.com/netcell-it/deklarix/internal/store" +) + +// antwortenB/C/D-Feldnamen sind bewusst identisch zu den Fakten- +// Schlüsseln in rules/datenklasse.yaml (b1..b7) und +// rules/kivo_einstufung.yaml (c1..c5, c2_folge, c3_art) — Schritt 3 +// (Ableitungen) liest dieselben Schlüssel direkt aus antrag.antworten, +// ohne sie erst umbenennen zu müssen. +var datenfragen = []struct { + Key string + Label string +}{ + {"b1", "Namen, E-Mail-Adressen oder andere Angaben zu Personen"}, + {"b2", "Angaben zu Gesundheit, Religion, Herkunft, Gewerkschaft, Sexualleben"}, + {"b3", "Daten von Patienten, Mandanten oder Versicherten"}, + {"b4", "Bewerbungs-, Personal- oder Leistungsdaten von Beschäftigten"}, + {"b5", "Daten, die uns ein Kunde anvertraut hat"}, + {"b6", "Konstruktions-, Kalkulations- oder Vertragsunterlagen"}, + {"b7", "Nur allgemein zugängliche oder selbst erfundene Inhalte"}, +} + +type antragFormData struct { + Title string + Nav navData + Abteilungen []store.Abteilung + Datenfragen []struct { + Key string + Label string + } + Error string +} + +// handleAntragNewForm zeigt den Fragebogen (Ebene 2, "Antrag stellen"). +// Bewusst eine einzelne Seite statt eines mehrseitigen Assistenten — +// jede zusätzliche Seite kostet Zeit, und "dauert der Antrag länger als +// fünf Minuten, wird er umgangen" (siehe CLAUDE.md). Adaptive +// Folgefragen (C2/C3) werden rein clientseitig per CSS ein-/ausgeblendet, +// ohne zusätzlichen Server-Roundtrip. +func (s *Server) handleAntragNewForm(w http.ResponseWriter, r *http.Request) { + if currentUser(r).Role == "betreiber" { + http.Redirect(w, r, "/betreiber", http.StatusSeeOther) + return + } + abteilungen, err := s.store.ListAbteilungenForAccount(r.Context(), currentUser(r).AccountID) + if err != nil { + http.Error(w, "Abteilungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) + return + } + data := antragFormData{Title: "Antrag stellen", Nav: navFor(r), Abteilungen: abteilungen, Datenfragen: datenfragen} + if err := s.templates.ExecuteTemplate(w, "antrag-neu", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} + +// antworten ist die JSON-Struktur, die in antrag.antworten gespeichert +// wird — ein flaches Objekt aus den Fragebogen-Fakten-Schlüsseln. +// "Unsicher" wird für B unverändert als "unsicher" gespeichert, NICHT +// zu "ja" normalisiert: diese Normalisierung ("Unsicher zählt wie Ja") +// ist eine Auswertungsregel (Schritt 3), keine Speicherregel — was der +// Mitarbeiter tatsächlich geantwortet hat, bleibt im Antrag unverändert +// erhalten. +func antwortenFromForm(r *http.Request) []byte { + m := map[string]any{} + for _, f := range datenfragen { + v := r.FormValue(f.Key) + if v == "" { + v = "nein" + } + m[f.Key] = v + } + m["c1"] = r.FormValue("c1") == "ja" + m["c2"] = r.FormValue("c2") == "ja" + m["c2_folge"] = r.FormValue("c2_folge") + m["c3"] = r.FormValue("c3") == "ja" + m["c3_art"] = r.FormValue("c3_art") + m["c4"] = r.FormValue("c4") == "ja" + m["c5"] = r.FormValue("c5") == "ja" + m["d_werkzeug_freitext"] = r.FormValue("d_werkzeug_freitext") + m["d_zugang"] = r.FormValue("d_zugang") + m["d_geraet"] = r.FormValue("d_geraet") + + data, _ := json.Marshal(m) // map[string]any mit nur primitiven Werten kann nicht scheitern + return data +} + +// handleAntragCreate legt einen Antrag an und reicht ihn direkt ein +// (entwurf -> eingereicht in einem Schritt) — der Fragebogen ist eine +// einzelne Seite, ein Zwischenspeichern als Entwurf ist für v1 nicht +// vorgesehen. +func (s *Server) handleAntragCreate(w http.ResponseWriter, r *http.Request) { + if currentUser(r).Role == "betreiber" { + http.Redirect(w, r, "/betreiber", http.StatusSeeOther) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "ungültiges Formular", http.StatusBadRequest) + return + } + titel := r.FormValue("titel") + beschreibung := r.FormValue("beschreibung") + ergebnis := r.FormValue("ergebnis") + haeufigkeit := r.FormValue("haeufigkeit") + if titel == "" || beschreibung == "" || ergebnis == "" || haeufigkeit == "" { + http.Error(w, "Titel, Beschreibung, Ergebnis und Häufigkeit sind Pflichtfelder", http.StatusBadRequest) + return + } + var abteilungID *string + if v := r.FormValue("abteilung_id"); v != "" { + abteilungID = &v + } + + ctx := r.Context() + user := currentUser(r) + + antrag, err := s.store.CreateAntrag(ctx, user.AccountID, user.ID, abteilungID, titel) + if err != nil { + http.Error(w, "Antrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError) + return + } + if _, err := s.store.UpdateAntragFelder(ctx, antrag.ID, titel, beschreibung, ergebnis, haeufigkeit, antwortenFromForm(r)); err != nil { + http.Error(w, "Antrag konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError) + return + } + if err := s.store.SetAntragStatus(ctx, antrag.ID, "eingereicht"); err != nil { + http.Error(w, "Antrag konnte nicht eingereicht werden: "+err.Error(), http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/antraege/"+antrag.ID, http.StatusSeeOther) +} + +type antragListItem struct { + ID string + Titel string + Status string + Haeufigkeit string + CreatedAt string +} + +type antragListData struct { + Title string + Nav navData + Antraege []antragListItem +} + +// handleAntragList zeigt die eigenen Anträge (Ebene 2 — "Eigene +// Anträge einsehen, Status verfolgen"), nicht die des ganzen Mandanten. +func (s *Server) handleAntragList(w http.ResponseWriter, r *http.Request) { + if currentUser(r).Role == "betreiber" { + http.Redirect(w, r, "/betreiber", http.StatusSeeOther) + return + } + list, err := s.store.ListAntraegeForUser(r.Context(), currentUser(r).ID) + if err != nil { + http.Error(w, "Anträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) + return + } + data := antragListData{Title: "Meine Anträge", Nav: navFor(r)} + for _, a := range list { + data.Antraege = append(data.Antraege, antragListItem{ + ID: a.ID, Titel: a.Titel, Status: a.Status, Haeufigkeit: a.Haeufigkeit, + CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"), + }) + } + if err := s.templates.ExecuteTemplate(w, "antraege-liste", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} + +type antragDetailData struct { + Title string + Nav navData + ID string + Titel string + Beschreibung string + Ergebnis string + Haeufigkeit string + Status string + CreatedAt string + Antworten map[string]any +} + +// handleAntragDetail zeigt einen einzelnen Antrag mit allen Antworten. +// Ein Antrag eines fremden Nutzers wird wie ein nicht existierender +// behandelt (404), nie mit einer expliziten 403 bestätigt — dasselbe +// Muster wie schon beim alten Produkt für fremde Mandanten. +func (s *Server) handleAntragDetail(w http.ResponseWriter, r *http.Request) { + antrag, err := s.store.GetAntrag(r.Context(), r.PathValue("id")) + if err != nil || antrag.ErstellerUserID != currentUser(r).ID { + http.Error(w, "Antrag nicht gefunden", http.StatusNotFound) + return + } + + var antworten map[string]any + if len(antrag.Antworten) > 0 { + _ = json.Unmarshal(antrag.Antworten, &antworten) + } + + data := antragDetailData{ + Title: "Antrag", 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"), Antworten: antworten, + } + if err := s.templates.ExecuteTemplate(w, "antrag-detail", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} diff --git a/internal/web/antrag_handlers_test.go b/internal/web/antrag_handlers_test.go new file mode 100644 index 0000000..c437715 --- /dev/null +++ b/internal/web/antrag_handlers_test.go @@ -0,0 +1,136 @@ +package web_test + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "strings" + "testing" +) + +func fullAntragForm() url.Values { + return url.Values{ + "titel": {"Angebotstexte generieren"}, "beschreibung": {"KI schreibt Angebotstexte"}, + "ergebnis": {"Fertiger Text zur Freigabe"}, "haeufigkeit": {"gelegentlich"}, + "b1": {"ja"}, "b2": {"nein"}, "b3": {"nein"}, "b4": {"nein"}, "b5": {"nein"}, "b6": {"nein"}, "b7": {"nein"}, + "c1": {"nein"}, "c2": {"nein"}, "c2_folge": {"keine"}, "c3": {"nein"}, "c3_art": {"keine"}, + "c4": {"nein"}, "c5": {"ja"}, + "d_werkzeug_freitext": {"ChatGPT"}, "d_zugang": {"firma"}, "d_geraet": {"firma"}, + } +} + +func TestAntragNewFormRendersFragebogen(t *testing.T) { + s, _, cookie := newAuthedTestServer(t) + + resp := getWithCookie(t, s, cookie, "/antraege/neu") + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) + } + body := resp.Body.String() + for _, want := range []string{`name="titel"`, `name="b1"`, `name="c2_folge"`, `name="d_zugang"`} { + if !strings.Contains(body, want) { + t.Errorf("expected %q in the Fragebogen, got: %s", want, body) + } + } +} + +func TestAntragCreateThenDetailShowsAnswers(t *testing.T) { + s, fs, cookie := newAuthedTestServer(t) + + resp := postForm(t, s, cookie, "/antraege", fullAntragForm()) + if resp.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String()) + } + loc := resp.Header().Get("Location") + if !strings.HasPrefix(loc, "/antraege/") { + t.Fatalf("Location = %q, want a redirect to the new antrag", loc) + } + + var antragID string + for id := range fs.antraege { + antragID = id + } + antrag, err := fs.GetAntrag(context.Background(), antragID) + if err != nil { + t.Fatalf("GetAntrag: %v", err) + } + if antrag.Status != "eingereicht" { + t.Fatalf("Status = %q, want eingereicht", antrag.Status) + } + var antworten map[string]any + if err := json.Unmarshal(antrag.Antworten, &antworten); err != nil { + t.Fatalf("Antworten unmarshal: %v", err) + } + if antworten["b1"] != "ja" { + t.Errorf("b1 = %v, want ja", antworten["b1"]) + } + if antworten["c5"] != true { + t.Errorf("c5 = %v, want true", antworten["c5"]) + } + + detailResp := getWithCookie(t, s, cookie, loc) + if detailResp.Code != http.StatusOK { + t.Fatalf("detail status = %d, body: %s", detailResp.Code, detailResp.Body.String()) + } + if !strings.Contains(detailResp.Body.String(), "Angebotstexte generieren") { + t.Errorf("expected the antrag title on the detail page, got: %s", detailResp.Body.String()) + } +} + +func TestAntragCreateRejectsMissingFields(t *testing.T) { + s, _, cookie := newAuthedTestServer(t) + + resp := postForm(t, s, cookie, "/antraege", url.Values{"titel": {"Nur Titel"}}) + if resp.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 for missing required fields", resp.Code) + } +} + +func TestAntragListShowsOnlyOwnAntraege(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + cookieA := seedAccount(t, fs, "Mandant A", "a@example.com") + cookieB := seedAccount(t, fs, "Mandant B", "b@example.com") + + if resp := postForm(t, s, cookieA, "/antraege", fullAntragForm()); resp.Code != http.StatusSeeOther { + t.Fatalf("create (A) status = %d, body: %s", resp.Code, resp.Body.String()) + } + + listA := getWithCookie(t, s, cookieA, "/antraege") + if !strings.Contains(listA.Body.String(), "Angebotstexte generieren") { + t.Errorf("expected Mandant A to see their own antrag, got: %s", listA.Body.String()) + } + listB := getWithCookie(t, s, cookieB, "/antraege") + if strings.Contains(listB.Body.String(), "Angebotstexte generieren") { + t.Errorf("expected Mandant B to see no antrag, got: %s", listB.Body.String()) + } +} + +func TestAntragDetailRejectsForeignUser(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + cookieA := seedAccount(t, fs, "Mandant A", "a@example.com") + cookieB := seedAccount(t, fs, "Mandant B", "b@example.com") + + createResp := postForm(t, s, cookieA, "/antraege", fullAntragForm()) + antragURL := createResp.Header().Get("Location") + + resp := getWithCookie(t, s, cookieB, antragURL) + if resp.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 for a foreign user's antrag", resp.Code) + } +} + +func TestBetreiberRedirectedAwayFromAntragPages(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber") + + for _, path := range []string{"/antraege", "/antraege/neu"} { + resp := getWithCookie(t, s, betreiberCookie, path) + if resp.Code != http.StatusSeeOther { + t.Errorf("GET %s status = %d, want 303 redirect for a betreiber", path, resp.Code) + } + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 9b9e380..af28070 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -39,6 +39,13 @@ type Store interface { DeleteSession(ctx context.Context, token string) error CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error) ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error) + + ListAbteilungenForAccount(ctx context.Context, accountID string) ([]store.Abteilung, error) + CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (store.Antrag, error) + GetAntrag(ctx context.Context, id string) (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 + ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]store.Antrag, error) } // Server bündelt Routing und Abhängigkeiten der Web-Schicht. @@ -68,6 +75,10 @@ func NewServer(st Store) (*Server, error) { mux.HandleFunc("POST /login", s.handleLogin) mux.HandleFunc("POST /logout", s.handleLogout) mux.HandleFunc("GET /{$}", s.requirePage(s.handleIndex)) + mux.HandleFunc("GET /antraege", s.requirePage(s.handleAntragList)) + mux.HandleFunc("GET /antraege/neu", s.requirePage(s.handleAntragNewForm)) + mux.HandleFunc("POST /antraege", s.requirePage(s.handleAntragCreate)) + mux.HandleFunc("GET /antraege/{id}", s.requirePage(s.handleAntragDetail)) mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard)) mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList)) mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail)) diff --git a/internal/web/server_test.go b/internal/web/server_test.go index e53515c..649571b 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -29,6 +29,8 @@ type fakeStore struct { 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 } func newFakeStore() *fakeStore { @@ -37,6 +39,8 @@ func newFakeStore() *fakeStore { users: map[string]store.User{}, usersByEmail: map[string]string{}, sessions: map[string]store.Session{}, + abteilungen: map[string][]store.Abteilung{}, + antraege: map[string]store.Antrag{}, } } @@ -169,6 +173,70 @@ func (f *fakeStore) ListAuditLog(ctx context.Context, limit int) ([]store.AuditE return out, nil } +func (f *fakeStore) ListAbteilungenForAccount(ctx context.Context, accountID string) ([]store.Abteilung, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.abteilungen[accountID], nil +} + +func (f *fakeStore) CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (store.Antrag, error) { + f.mu.Lock() + defer f.mu.Unlock() + a := store.Antrag{ + ID: f.newID(), AccountID: accountID, ErstellerUserID: erstellerUserID, AbteilungID: abteilungID, + Titel: titel, Status: "entwurf", Antworten: []byte(`{}`), CreatedAt: time.Now(), UpdatedAt: time.Now(), + } + f.antraege[a.ID] = a + return a, nil +} + +func (f *fakeStore) GetAntrag(ctx context.Context, id string) (store.Antrag, error) { + f.mu.Lock() + defer f.mu.Unlock() + a, ok := f.antraege[id] + if !ok { + return store.Antrag{}, store.ErrNotFound + } + return a, nil +} + +func (f *fakeStore) UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (store.Antrag, error) { + f.mu.Lock() + defer f.mu.Unlock() + a, ok := f.antraege[id] + if !ok { + return store.Antrag{}, store.ErrNotFound + } + a.Titel, a.Beschreibung, a.Ergebnis, a.Haeufigkeit, a.Antworten = titel, beschreibung, ergebnis, haeufigkeit, antworten + a.UpdatedAt = time.Now() + f.antraege[id] = a + return a, nil +} + +func (f *fakeStore) SetAntragStatus(ctx context.Context, id, status string) error { + f.mu.Lock() + defer f.mu.Unlock() + a, ok := f.antraege[id] + if !ok { + return store.ErrNotFound + } + a.Status = status + f.antraege[id] = a + return nil +} + +func (f *fakeStore) ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]store.Antrag, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []store.Antrag + for _, a := range f.antraege { + if a.ErstellerUserID == erstellerUserID { + out = append(out, a) + } + } + return out, nil +} + // ─── Test-Setup ─────────────────────────────────────────────────────── func newServer(t *testing.T, fs *fakeStore) *web.Server { diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 35bb398..14d2935 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -384,6 +384,46 @@ button.entfernen { background: #fff; } +.fragebogen fieldset { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: 12px 14px; + margin: 12px 0; +} + +.fragebogen legend { + font-weight: 500; + font-size: 0.9375rem; + padding: 0 4px; +} + +.fragebogen fieldset label { + display: inline-flex; + align-items: center; + gap: 4px; + font-weight: 400; + margin: 4px 12px 4px 0; +} + +.fragebogen fieldset input[type="radio"] { + width: auto; + min-height: 0; +} + +/* Adaptive Folgefragen: rein clientseitig per :has() ein-/ausgeblendet, + kein JavaScript nötig. Siehe templates/antrag_neu.html — die "Ja"- + Antwort trägt eine eigene Klasse (c2-ja/c3-ja), damit sich die + jeweilige Folgefrage gezielt zuordnen lässt. */ +.mit-folgefrage .folgefrage { + display: none; + margin-top: 8px; +} + +.mit-folgefrage:has(.c2-ja input:checked) .folgefrage, +.mit-folgefrage:has(.c3-ja input:checked) .folgefrage { + display: block; +} + /* Ab hier mehr Platz (Tablet/Desktop) — der Container bekommt spürbaren Rand statt volle Breite, sonst bleibt alles identisch. */ @media (min-width: 640px) { diff --git a/internal/web/templates/antraege_liste.html b/internal/web/templates/antraege_liste.html new file mode 100644 index 0000000..3d067c3 --- /dev/null +++ b/internal/web/templates/antraege_liste.html @@ -0,0 +1,26 @@ +{{define "antraege-liste"}} + +{{template "head" .}} + +{{template "nav" .Nav}} +
+

Meine Anträge

+{{if not .Antraege}} +

Noch keine Anträge gestellt.

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

Neuen Antrag stellen

+
+ + +{{end}} diff --git a/internal/web/templates/antrag_detail.html b/internal/web/templates/antrag_detail.html new file mode 100644 index 0000000..5bbf7f3 --- /dev/null +++ b/internal/web/templates/antrag_detail.html @@ -0,0 +1,24 @@ +{{define "antrag-detail"}} + +{{template "head" .}} + +{{template "nav" .Nav}} +
+

← Meine Anträge

+

{{.Titel}}

+

Eingereicht am {{.CreatedAt}} — Status: {{.Status}}

+ +

Vorhaben

+

Was soll die KI tun?
{{.Beschreibung}}

+

Was soll herauskommen?
{{.Ergebnis}}

+

Häufigkeit: {{.Haeufigkeit}}

+ +

+ Die Bewertung (Datenklasse, KI-VO-Einstufung, Werkzeugvorschlag) folgt + in einer späteren Ausbaustufe — dieser Antrag ist gespeichert und + eingereicht. +

+
+ + +{{end}} diff --git a/internal/web/templates/antrag_neu.html b/internal/web/templates/antrag_neu.html new file mode 100644 index 0000000..dd2100a --- /dev/null +++ b/internal/web/templates/antrag_neu.html @@ -0,0 +1,122 @@ +{{define "antrag-neu"}} + +{{template "head" .}} + +{{template "nav" .Nav}} +
+

Antrag stellen

+{{if .Error}}

{{.Error}}

{{end}} + +
+

Vorhaben

+ + + + + + + + + + + + + {{if .Abteilungen}} + + + {{end}} + +

Daten

+

Bei Unsicherheit "Unsicher" wählen — das wird wie "Ja" behandelt.

+ {{range .Datenfragen}} +
+ {{.Label}} + + + +
+ {{end}} + +

Verwendung des Ergebnisses

+
+ Wird das Ergebnis unverändert nach außen gegeben? + + +
+ +
+ Beeinflusst es eine Entscheidung über einen Menschen? + + +
+ + +
+
+ +
+ Erkennt oder bewertet es Emotionen, Verhalten oder biometrische Merkmale? + + +
+ + +
+
+ +
+ Läuft es ohne dass ein Mensch das Ergebnis prüft? + + +
+ +
+ Merkt der Empfänger, dass ein Ergebnis von einer KI stammt? + + +
+ +

Werkzeug

+ + + +
+ Firmenkonto oder privater Zugang? + + +
+ +
+ Auf Firmengerät oder Privatgerät? + + +
+ + +
+
+ + +{{end}} diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index cd56c53..aa50fc2 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -5,10 +5,10 @@ {{template "nav" .Nav}}

Willkommen bei Deklarix

-

- Der geführte Fragebogen für die Antragsprüfung folgt in der nächsten - Ausbaustufe. Datenmodell, Regelwerk und Werkzeugkatalog stehen bereits. -

+
diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 2c4106f..d50e930 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -9,6 +9,7 @@ {{define "nav"}}