Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db9c5b7482 |
15
CLAUDE.md
15
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)**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
213
internal/web/antrag_handlers.go
Normal file
213
internal/web/antrag_handlers.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
136
internal/web/antrag_handlers_test.go
Normal file
136
internal/web/antrag_handlers_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
26
internal/web/templates/antraege_liste.html
Normal file
26
internal/web/templates/antraege_liste.html
Normal file
@@ -0,0 +1,26 @@
|
||||
{{define "antraege-liste"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Meine Anträge</h1>
|
||||
{{if not .Antraege}}
|
||||
<p class="hinweis">Noch keine Anträge gestellt.</p>
|
||||
{{else}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Antraege}}
|
||||
<li>
|
||||
<a href="/antraege/{{.ID}}">
|
||||
<strong>{{.Titel}}</strong> · {{.Haeufigkeit}} · {{.CreatedAt}}
|
||||
<span class="status status-{{.Status}}">{{.Status}}</span>
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
<p><a href="/antraege/neu">Neuen Antrag stellen</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
24
internal/web/templates/antrag_detail.html
Normal file
24
internal/web/templates/antrag_detail.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{{define "antrag-detail"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<p><a href="/antraege">← Meine Anträge</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>
|
||||
|
||||
<p class="hinweis">
|
||||
Die Bewertung (Datenklasse, KI-VO-Einstufung, Werkzeugvorschlag) folgt
|
||||
in einer späteren Ausbaustufe — dieser Antrag ist gespeichert und
|
||||
eingereicht.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
122
internal/web/templates/antrag_neu.html
Normal file
122
internal/web/templates/antrag_neu.html
Normal file
@@ -0,0 +1,122 @@
|
||||
{{define "antrag-neu"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Antrag stellen</h1>
|
||||
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||
|
||||
<form method="post" action="/antraege" class="fragebogen">
|
||||
<h2>Vorhaben</h2>
|
||||
<label for="titel">Kurzbezeichnung</label>
|
||||
<input type="text" id="titel" name="titel" required>
|
||||
|
||||
<label for="beschreibung">Was soll die KI tun?</label>
|
||||
<textarea id="beschreibung" name="beschreibung" rows="3" required></textarea>
|
||||
|
||||
<label for="ergebnis">Was soll herauskommen?</label>
|
||||
<textarea id="ergebnis" name="ergebnis" rows="2" required></textarea>
|
||||
|
||||
<label for="haeufigkeit">Häufigkeit</label>
|
||||
<select id="haeufigkeit" name="haeufigkeit" required>
|
||||
<option value="einmalig">einmalig</option>
|
||||
<option value="gelegentlich">gelegentlich</option>
|
||||
<option value="taeglich">täglich</option>
|
||||
<option value="automatisiert">automatisiert</option>
|
||||
</select>
|
||||
|
||||
{{if .Abteilungen}}
|
||||
<label for="abteilung_id">Abteilung</label>
|
||||
<select id="abteilung_id" name="abteilung_id">
|
||||
<option value="">— keine Angabe —</option>
|
||||
{{range .Abteilungen}}
|
||||
<option value="{{.ID}}">{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
{{end}}
|
||||
|
||||
<h2>Daten</h2>
|
||||
<p class="hinweis">Bei Unsicherheit "Unsicher" wählen — das wird wie "Ja" behandelt.</p>
|
||||
{{range .Datenfragen}}
|
||||
<fieldset class="datenfrage">
|
||||
<legend>{{.Label}}</legend>
|
||||
<label><input type="radio" name="{{.Key}}" value="ja" required> Ja</label>
|
||||
<label><input type="radio" name="{{.Key}}" value="nein"> Nein</label>
|
||||
<label><input type="radio" name="{{.Key}}" value="unsicher"> Unsicher</label>
|
||||
</fieldset>
|
||||
{{end}}
|
||||
|
||||
<h2>Verwendung des Ergebnisses</h2>
|
||||
<fieldset>
|
||||
<legend>Wird das Ergebnis unverändert nach außen gegeben?</legend>
|
||||
<label><input type="radio" name="c1" value="ja" required> Ja</label>
|
||||
<label><input type="radio" name="c1" value="nein"> Nein</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mit-folgefrage">
|
||||
<legend>Beeinflusst es eine Entscheidung über einen Menschen?</legend>
|
||||
<label class="c2-ja"><input type="radio" name="c2" value="ja" required> Ja</label>
|
||||
<label><input type="radio" name="c2" value="nein"> Nein</label>
|
||||
<div class="folgefrage">
|
||||
<label for="c2_folge">Welche Art von Entscheidung?</label>
|
||||
<select id="c2_folge" name="c2_folge">
|
||||
<option value="beschaeftigung">Einstellung, Beförderung, Kündigung</option>
|
||||
<option value="kreditwuerdigkeit">Kreditwürdigkeit</option>
|
||||
<option value="leistungsbewilligung">Leistungsbewilligung</option>
|
||||
<option value="bildung">Zugang zu Bildung</option>
|
||||
<option value="gesundheit">medizinische Beurteilung</option>
|
||||
<option value="keine">keins davon</option>
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mit-folgefrage">
|
||||
<legend>Erkennt oder bewertet es Emotionen, Verhalten oder biometrische Merkmale?</legend>
|
||||
<label class="c3-ja"><input type="radio" name="c3" value="ja" required> Ja</label>
|
||||
<label><input type="radio" name="c3" value="nein"> Nein</label>
|
||||
<div class="folgefrage">
|
||||
<label for="c3_art">Welche Art?</label>
|
||||
<select id="c3_art" name="c3_art">
|
||||
<option value="emotionserkennung_arbeitsplatz">Emotionserkennung am Arbeitsplatz</option>
|
||||
<option value="social_scoring">Social Scoring</option>
|
||||
<option value="biometrische_kategorisierung">Biometrische Kategorisierung</option>
|
||||
<option value="keine">etwas anderes</option>
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Läuft es ohne dass ein Mensch das Ergebnis prüft?</legend>
|
||||
<label><input type="radio" name="c4" value="ja" required> Ja</label>
|
||||
<label><input type="radio" name="c4" value="nein"> Nein</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Merkt der Empfänger, dass ein Ergebnis von einer KI stammt?</legend>
|
||||
<label><input type="radio" name="c5" value="ja" required> Ja</label>
|
||||
<label><input type="radio" name="c5" value="nein"> Nein</label>
|
||||
</fieldset>
|
||||
|
||||
<h2>Werkzeug</h2>
|
||||
<label for="d_werkzeug_freitext">Haben Sie schon ein Werkzeug im Sinn? (optional)</label>
|
||||
<input type="text" id="d_werkzeug_freitext" name="d_werkzeug_freitext">
|
||||
|
||||
<fieldset>
|
||||
<legend>Firmenkonto oder privater Zugang?</legend>
|
||||
<label><input type="radio" name="d_zugang" value="firma" required> Firmenkonto</label>
|
||||
<label><input type="radio" name="d_zugang" value="privat"> Privater Zugang</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Auf Firmengerät oder Privatgerät?</legend>
|
||||
<label><input type="radio" name="d_geraet" value="firma" required> Firmengerät</label>
|
||||
<label><input type="radio" name="d_geraet" value="privat"> Privatgerät</label>
|
||||
</fieldset>
|
||||
|
||||
<button type="submit">Antrag einreichen</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -5,10 +5,10 @@
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Willkommen bei Deklarix</h1>
|
||||
<p class="hinweis">
|
||||
Der geführte Fragebogen für die Antragsprüfung folgt in der nächsten
|
||||
Ausbaustufe. Datenmodell, Regelwerk und Werkzeugkatalog stehen bereits.
|
||||
</p>
|
||||
<ul class="admin-kacheln">
|
||||
<li><a href="/antraege/neu">Antrag stellen</a></li>
|
||||
<li><a href="/antraege">Meine Anträge</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
{{define "nav"}}
|
||||
<nav>
|
||||
<a href="/">Start</a>
|
||||
{{if not .IsBetreiber}}<a href="/antraege">Meine Anträge</a>{{end}}
|
||||
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
|
||||
<form method="post" action="/logout" style="display:inline">
|
||||
<button type="submit">Abmelden</button>
|
||||
|
||||
@@ -35,24 +35,17 @@ das lässt offen, ob `auftragsdaten` (zwischen `intern` und
|
||||
ein (Kundendaten sollten aus denselben Gründen wie Geschäftsgeheimnisse
|
||||
nicht zum Training verwendet werden) — **Annahme, nicht bestätigt.**
|
||||
|
||||
## 3. Genaue Fragebogen-Fakten für "verboten" (KI-VO Art. 5)
|
||||
## 3. Genaue Fragebogen-Fakten für "verboten" (KI-VO Art. 5) — ERLEDIGT
|
||||
|
||||
Fragebogen-Abschnitt C stellt nur eine einzelne Frage (C3: "Erkennt
|
||||
oder bewertet es Emotionen, Verhalten oder biometrische Merkmale?")
|
||||
ohne Folgefragen, die zwischen den drei in der Bewertungslogik
|
||||
genannten Auslösern für "verboten" unterscheiden:
|
||||
|
||||
- Emotionserkennung am Arbeitsplatz
|
||||
- Social Scoring
|
||||
- Biometrische Kategorisierung
|
||||
|
||||
`rules/kivo_einstufung.yaml` geht davon aus, dass Phase 2 (Fragebogen)
|
||||
für C3 eine Folgefrage ergänzt, die zwischen diesen dreien (und "keins
|
||||
davon") unterscheidet — analog zur bereits spezifizierten Folgefrage
|
||||
bei C2. Die Feldnamen `c3_art` und dessen Werte
|
||||
(`emotionserkennung_arbeitsplatz`, `social_scoring`,
|
||||
`biometrische_kategorisierung`) sind Platzhalter, bis Phase 2 das
|
||||
festlegt.
|
||||
~~Fragebogen-Abschnitt C stellt nur eine einzelne Frage...~~ Mit Phase 2
|
||||
(Fragebogen, `internal/web/templates/antrag_neu.html`) gelöst: C3 hat
|
||||
jetzt eine Folgefrage `c3_art` mit den Werten
|
||||
`emotionserkennung_arbeitsplatz`, `social_scoring`,
|
||||
`biometrische_kategorisierung` und `keine` — exakt die Werte, die
|
||||
`rules/kivo_einstufung.yaml` bereits erwartete. Feldnamen sind damit
|
||||
final, nicht mehr Platzhalter. **Weiterhin offen:** ob diese drei
|
||||
Varianten fachlich vollständig sind (deckt das wirklich alle "verboten"-
|
||||
Fälle aus Art. 5 KI-VO ab?) — das war nie Teil dieser Klärung.
|
||||
|
||||
## 4. `loeschfrist_max_tage` — konkrete Fristen je Datenklasse
|
||||
|
||||
|
||||
Reference in New Issue
Block a user