Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aad517ce23 | ||
|
|
d1e80dd19f | ||
|
|
e968cf9761 | ||
|
|
b1b121bb23 | ||
|
|
db9c5b7482 | ||
|
|
344a33805a |
142
CLAUDE.md
142
CLAUDE.md
@@ -72,6 +72,21 @@ ist der Nachfolger dessen, was früher (vor dem Produktwechsel)
|
|||||||
Rechte werden **als Prüfung an jeder Aktion** durchgesetzt (Middleware
|
Rechte werden **als Prüfung an jeder Aktion** durchgesetzt (Middleware
|
||||||
je Handler), nicht als grob unterschiedene Seitenbereiche.
|
je Handler), nicht als grob unterschiedene Seitenbereiche.
|
||||||
|
|
||||||
|
**Ebene 4 — Nutzerverwaltung ist umgesetzt** (`internal/web/admin_handlers.go`,
|
||||||
|
Middleware `requireAdmin`): `GET /verwaltung/nutzer` listet alle Logins
|
||||||
|
des eigenen Mandanten, `GET/POST /verwaltung/nutzer/neu` legt einen
|
||||||
|
weiteren Login mit einer der vier Mandanten-Rollen an (`mitarbeiter`,
|
||||||
|
`verantwortlicher`, `pruefer`, `admin` — `betreiber` kann kein
|
||||||
|
Mandanten-Admin vergeben, das ist Ebene 5). Es gibt noch **keine**
|
||||||
|
Einladungsmail — der Admin setzt das Initialpasswort direkt im
|
||||||
|
Formular und gibt es auf einem anderen Weg weiter (konsistent mit dem
|
||||||
|
Onboarding-Stand unten: Einladungslink/CSV-Import/SSO sind noch nicht
|
||||||
|
gebaut). Das schließt die Lücke, dass Ebene 3 (Fachebene) bisher nur
|
||||||
|
über einen manuellen SQL-Insert nutzbar war, weil die Firma-
|
||||||
|
Registrierung ausschließlich einen `admin`-Nutzer erzeugt. Abteilungen-
|
||||||
|
Verwaltung, Anmeldeverfahren, eigene Werkzeug-Freigaben/-Sperrungen und
|
||||||
|
Rechnungsdaten (Ebene 4 laut Tabelle oben) sind weiterhin nicht gebaut.
|
||||||
|
|
||||||
**Mandantenfähigkeit:** jede Tabelle trägt `account_id`. Aktuell wird
|
**Mandantenfähigkeit:** jede Tabelle trägt `account_id`. Aktuell wird
|
||||||
Isolation in der Anwendungsschicht erzwungen (Handler vergleichen
|
Isolation in der Anwendungsschicht erzwungen (Handler vergleichen
|
||||||
`AccountID` gegen den angemeldeten Account, wie bisher) — die
|
`AccountID` gegen den angemeldeten Account, wie bisher) — die
|
||||||
@@ -139,11 +154,21 @@ Werkzeug: `avv_erforderlich`, `eu_verarbeitung`,
|
|||||||
`menschliche_aufsicht`, `kennzeichnungspflicht`, `dsfa_erforderlich`.
|
`menschliche_aufsicht`, `kennzeichnungspflicht`, `dsfa_erforderlich`.
|
||||||
|
|
||||||
`internal/rules` lädt und validiert diese drei Dateien (eindeutige IDs,
|
`internal/rules` lädt und validiert diese drei Dateien (eindeutige IDs,
|
||||||
eindeutige Ränge, jede Anforderung braucht mindestens einen Auslöser).
|
eindeutige Ränge, jede Anforderung braucht mindestens einen Auslöser)
|
||||||
**Die Auswertung gegen echte Fragebogen-Antworten ist noch nicht
|
und wertet sie seit Schritt 3 auch aus: `EvaluateDatenklasse`,
|
||||||
gebaut** — das ist Schritt 3 der Baureihenfolge (siehe unten), bewusst
|
`EvaluateEinstufung`, `IstVerboten`, `DeriveAnforderungen` operieren auf
|
||||||
erst, wenn der Fragebogen (Schritt 2) die exakten Fakten-Feldnamen
|
`rules.Antworten` (geparst aus `antrag.antworten` via `ParseAntworten`)
|
||||||
festlegt.
|
— reine, für sich getestete Funktionen ohne DB-/Web-Zugriff.
|
||||||
|
`FilterWerkzeuge`/`ErfuelltAnforderung` filtern einen Werkzeugkatalog
|
||||||
|
hart gegen die abgeleiteten Anforderungen (`WerkzeugEigenschaften` ist
|
||||||
|
ein schlanker, von `store.Werkzeug` unabhängiger Typ, damit
|
||||||
|
`internal/rules` weiterhin ohne `internal/store` auskommt). Seit
|
||||||
|
Schritt 4 wird das auch tatsächlich aufgerufen: `POST /antraege`
|
||||||
|
berechnet nach dem Einreichen sofort eine `bewertung` (append-only,
|
||||||
|
`internal/web/antrag_handlers.go:bewerteAntrag`) — bei `verboten`
|
||||||
|
(K.-o.-Prüfung) ohne Werkzeugsuche — und `GET /antraege/{id}` zeigt sie
|
||||||
|
inklusive Herleitung, RegelwerkVersion/KatalogVersion und dem
|
||||||
|
Pflicht-Hinweis "Das System entscheidet nicht. Es bereitet vor."
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -195,23 +220,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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -295,12 +342,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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -311,10 +362,35 @@ wiederverwendet aus dem alten Produkt). `bewertung`/`entscheidung`/
|
|||||||
`abteilung`/`werkzeug`/`werkzeug_sperre`/`antrag` in Postgres,
|
`abteilung`/`werkzeug`/`werkzeug_sperre`/`antrag` in Postgres,
|
||||||
`internal/rules` lädt und validiert die drei YAML-Regelwerke,
|
`internal/rules` lädt und validiert die drei YAML-Regelwerke,
|
||||||
Web-Layer kompiliert mit Auth + Plattform-Bereich-Gerüst).
|
Web-Layer kompiliert mit Auth + Plattform-Bereich-Gerüst).
|
||||||
2. Fragebogen mit adaptiver Logik
|
2. ~~Fragebogen mit adaptiver Logik~~ — **erledigt** (`GET /antraege/neu`
|
||||||
3. Ableitungen und harte Filter (Auswertung gegen echte Antworten)
|
einseitiges Formular, `POST /antraege` legt an und reicht direkt ein,
|
||||||
4. Ergebnisdarstellung mit Herleitung
|
`GET /antraege` eigene Anträge, `GET /antraege/{id}` Detail. Adaptive
|
||||||
5. Entscheidung, Snapshot, Audit-Log
|
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~~ — **erledigt** (`internal/rules`:
|
||||||
|
`EvaluateDatenklasse`/`EvaluateEinstufung`/`DeriveAnforderungen`/
|
||||||
|
`IstVerboten`/`FilterWerkzeuge`, vollständig getestet gegen die
|
||||||
|
echten `rules/*.yaml`-Dateien.)
|
||||||
|
4. ~~Ergebnisdarstellung mit Herleitung~~ — **erledigt** (`POST /antraege`
|
||||||
|
berechnet und speichert eine `bewertung` sofort beim Einreichen,
|
||||||
|
`GET /antraege/{id}` zeigt Datenklasse, KI-VO-Einstufung,
|
||||||
|
Anforderungen, zulässige/ausgeschlossene Werkzeuge — jeweils mit
|
||||||
|
Herleitung — sowie RegelwerkVersion/KatalogVersion und den
|
||||||
|
Pflicht-Hinweis "Das System entscheidet nicht. Es bereitet vor.".
|
||||||
|
`bewertung` ist append-only. Getestet inkl. K.-o.-Prüfung
|
||||||
|
(`verboten` überspringt die Werkzeugsuche) und hartem Filter
|
||||||
|
gegen den Katalog.)
|
||||||
|
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
|
||||||
|
|
||||||
@@ -526,11 +602,15 @@ journalctl -u deklarix -f
|
|||||||
## Offene Punkte
|
## Offene Punkte
|
||||||
|
|
||||||
- **Rangfolge der Datenklassen und Grenzfälle im Anforderungsprofil**
|
- **Rangfolge der Datenklassen und Grenzfälle im Anforderungsprofil**
|
||||||
(siehe `rules/OPEN.md`) — vor Schritt 3 mit dem/der fachlich
|
(siehe `rules/OPEN.md`, Punkte 1/2/5/6) — mit dem/der fachlich
|
||||||
Verantwortlichen (z. B. Datenschutzbeauftragte/r) bestätigen.
|
Verantwortlichen (z. B. Datenschutzbeauftragte/r) bestätigen. Schritt
|
||||||
- **Genaue Fragebogen-Felder für die drei "verboten"-Varianten** (KI-VO
|
3 ist trotzdem schon umgesetzt (auf Basis dieser dokumentierten
|
||||||
Art. 5) — Fragebogen-Spezifikation nennt nur eine C3-Frage ohne die
|
Annahmen) — nicht auf die Klärung gewartet, um nicht blockiert zu
|
||||||
nötige Unterscheidung, siehe `rules/OPEN.md`.
|
bleiben, aber die Ableitung kann sich noch ändern.
|
||||||
|
- ~~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
|
- **Löschfristen je Datenklasse** (konkrete Tageswerte für
|
||||||
`loeschfrist_max_tage`) — noch nicht fachlich festgelegt.
|
`loeschfrist_max_tage`) — noch nicht fachlich festgelegt.
|
||||||
- **Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)**
|
- **Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)**
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ func main() {
|
|||||||
log.Printf("Regelwerk geladen: %d Datenklassen, %d KI-VO-Stufen, %d Anforderungen (aus %s)",
|
log.Printf("Regelwerk geladen: %d Datenklassen, %d KI-VO-Stufen, %d Anforderungen (aus %s)",
|
||||||
len(datenklasse.Stufen), len(einstufung.Stufen), len(anforderungen.Anforderungen), rulesDir)
|
len(datenklasse.Stufen), len(einstufung.Stufen), len(anforderungen.Anforderungen), rulesDir)
|
||||||
|
|
||||||
server, err := web.NewServer(db)
|
server, err := web.NewServer(db, web.Regelwerk{
|
||||||
|
Datenklasse: datenklasse, Einstufung: einstufung, Anforderungen: anforderungen,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("web server: %v", err)
|
log.Fatalf("web server: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
264
internal/rules/evaluate.go
Normal file
264
internal/rules/evaluate.go
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
package rules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Antworten ist die im Antrag gespeicherte Fragebogen-Antwort-Menge —
|
||||||
|
// dieselbe Struktur, die internal/web/antrag_handlers.go erzeugt
|
||||||
|
// (antwortenFromForm): ein flaches JSON-Objekt mit denselben
|
||||||
|
// Fakten-Schlüsseln wie in den Regelwerk-YAML-Dateien (b1..b7, c1..c5,
|
||||||
|
// c2_folge, c3_art).
|
||||||
|
type Antworten map[string]any
|
||||||
|
|
||||||
|
// ParseAntworten liest antrag.antworten (rohes JSON aus der Datenbank).
|
||||||
|
func ParseAntworten(raw []byte) (Antworten, error) {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return Antworten{}, nil
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
return nil, fmt.Errorf("rules: antworten parsen: %w", err)
|
||||||
|
}
|
||||||
|
return Antworten(m), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// istJaOderUnsicher prüft eine B-Frage. "Unsicher zählt wie Ja" ist eine
|
||||||
|
// Auswertungsregel (siehe CLAUDE.md, Fragebogen-Abschnitt B) — deshalb
|
||||||
|
// hier und nicht schon beim Speichern des Antrags angewendet.
|
||||||
|
func (a Antworten) istJaOderUnsicher(key string) bool {
|
||||||
|
v, _ := a[key].(string)
|
||||||
|
return v == "ja" || v == "unsicher"
|
||||||
|
}
|
||||||
|
|
||||||
|
// DatenklasseErgebnis ist die abgeleitete Datenklasse mit Herleitung —
|
||||||
|
// "Jede Anforderung/Ableitung trägt ihre Herleitung, sonst ist das
|
||||||
|
// Ergebnis im Audit wertlos" (siehe CLAUDE.md).
|
||||||
|
type DatenklasseErgebnis struct {
|
||||||
|
ID string
|
||||||
|
Herleitung string
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvaluateDatenklasse leitet die Datenklasse aus den B-Antworten ab.
|
||||||
|
// "Höchste zutreffende Stufe gewinnt" (höherer Rang gewinnt). Trifft
|
||||||
|
// keine Stufe zu (z. B. wenn versehentlich auch B7 mit "nein"
|
||||||
|
// beantwortet wurde, obwohl keine andere Kategorie zutrifft — ein
|
||||||
|
// eigentlich widersprüchlicher Fragebogen-Zustand), wird konservativ
|
||||||
|
// "intern" angenommen statt "oeffentlich": im Zweifel mehr Schutz, nicht
|
||||||
|
// weniger. Siehe rules/OPEN.md, Punkt 5.
|
||||||
|
func EvaluateDatenklasse(regelwerk DatenklasseRegelwerk, antworten Antworten) DatenklasseErgebnis {
|
||||||
|
const fallback = "intern"
|
||||||
|
var gewinner *DatenklasseStufe
|
||||||
|
var treffer []string
|
||||||
|
for i := range regelwerk.Stufen {
|
||||||
|
st := ®elwerk.Stufen[i]
|
||||||
|
var stTreffer []string
|
||||||
|
for _, ausloeser := range st.Ausloeser {
|
||||||
|
if antworten.istJaOderUnsicher(ausloeser) {
|
||||||
|
stTreffer = append(stTreffer, ausloeser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(stTreffer) > 0 && (gewinner == nil || st.Rang > gewinner.Rang) {
|
||||||
|
gewinner = st
|
||||||
|
treffer = stTreffer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gewinner == nil {
|
||||||
|
return DatenklasseErgebnis{ID: fallback, Herleitung: "keine Kategorie aus Abschnitt B traf eindeutig zu (konservativer Standardwert)"}
|
||||||
|
}
|
||||||
|
return DatenklasseErgebnis{ID: gewinner.ID, Herleitung: "ausgelöst durch " + strings.Join(treffer, ", ")}
|
||||||
|
}
|
||||||
|
|
||||||
|
// variantePasst prüft, ob alle Schlüssel-Werte-Paare einer Variante zu
|
||||||
|
// den Antworten passen (UND-Verknüpfung innerhalb der Variante).
|
||||||
|
func variantePasst(variante EinstufungVariante, antworten Antworten) bool {
|
||||||
|
for key, want := range variante {
|
||||||
|
got, ok := antworten[key]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch wantVal := want.(type) {
|
||||||
|
case bool:
|
||||||
|
gotBool, ok := got.(bool)
|
||||||
|
if !ok || gotBool != wantVal {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
gotStr, ok := got.(string)
|
||||||
|
if !ok || gotStr != wantVal {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// EinstufungErgebnis ist die abgeleitete KI-VO-Einstufung mit Herleitung.
|
||||||
|
type EinstufungErgebnis struct {
|
||||||
|
ID string
|
||||||
|
Quelle string // z. B. "Art. 5 KI-VO", leer wenn die Stufe keine Quelle nennt
|
||||||
|
Herleitung string
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatVariante liefert eine deterministische, lesbare Darstellung
|
||||||
|
// einer zutreffenden Variante, z. B. "c2=true, c2_folge=kreditwuerdigkeit".
|
||||||
|
func formatVariante(v EinstufungVariante) string {
|
||||||
|
keys := make([]string, 0, len(v))
|
||||||
|
for k := range v {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
parts := make([]string, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s=%v", k, v[k]))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvaluateEinstufung leitet die KI-VO-Einstufung aus den C-Antworten ab.
|
||||||
|
// Prüfreihenfolge: die erste zutreffende Stufe gewinnt (siehe
|
||||||
|
// Bewertungslogik, K.-o.-Prüfung) — deshalb steht "verboten" in
|
||||||
|
// rules/kivo_einstufung.yaml an erster Stelle. Liefert einen Fehler nur,
|
||||||
|
// wenn das Regelwerk selbst keine Auffangregel definiert (sollte durch
|
||||||
|
// LoadEinstufung bereits verhindert sein).
|
||||||
|
func EvaluateEinstufung(regelwerk EinstufungRegelwerk, antworten Antworten) (EinstufungErgebnis, error) {
|
||||||
|
for _, st := range regelwerk.Stufen {
|
||||||
|
if len(st.Varianten) == 0 {
|
||||||
|
return EinstufungErgebnis{ID: st.ID, Quelle: st.Quelle, Herleitung: "Auffangregel (keine speziellere Stufe traf zu)"}, nil
|
||||||
|
}
|
||||||
|
for _, variante := range st.Varianten {
|
||||||
|
if variantePasst(variante, antworten) {
|
||||||
|
return EinstufungErgebnis{ID: st.ID, Quelle: st.Quelle, Herleitung: formatVariante(variante)}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return EinstufungErgebnis{}, fmt.Errorf("rules: keine einstufung trifft zu und keine auffangregel definiert")
|
||||||
|
}
|
||||||
|
|
||||||
|
// IstVerboten prüft die K.-o.-Bedingung (Art. 5 KI-VO): bei "verboten"
|
||||||
|
// erfolgt sofortige Ablehnung, keine Werkzeugsuche.
|
||||||
|
func IstVerboten(einstufungID string) bool {
|
||||||
|
return einstufungID == "verboten"
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnforderungErgebnis ist eine abgeleitete Anforderung mit Herleitung.
|
||||||
|
type AnforderungErgebnis struct {
|
||||||
|
ID string
|
||||||
|
Beschreibung string
|
||||||
|
Herleitung string // z. B. "aus Datenklasse personenbezogen"
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeriveAnforderungen leitet aus Datenklasse und Einstufung die Menge
|
||||||
|
// der Anforderungen ab, die ein Werkzeug erfüllen muss.
|
||||||
|
func DeriveAnforderungen(regelwerk AnforderungsRegelwerk, datenklasseID, einstufungID string) []AnforderungErgebnis {
|
||||||
|
var out []AnforderungErgebnis
|
||||||
|
for _, a := range regelwerk.Anforderungen {
|
||||||
|
var gruende []string
|
||||||
|
if containsString(a.AusDatenklassen, datenklasseID) {
|
||||||
|
gruende = append(gruende, "Datenklasse "+datenklasseID)
|
||||||
|
}
|
||||||
|
if containsString(a.AusEinstufungen, einstufungID) {
|
||||||
|
gruende = append(gruende, "Einstufung "+einstufungID)
|
||||||
|
}
|
||||||
|
if len(gruende) > 0 {
|
||||||
|
out = append(out, AnforderungErgebnis{ID: a.ID, Beschreibung: a.Beschreibung, Herleitung: "aus " + strings.Join(gruende, " und ")})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsString(list []string, v string) bool {
|
||||||
|
for _, x := range list {
|
||||||
|
if x == v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// WerkzeugEigenschaften sind die für die harte Filterung relevanten
|
||||||
|
// Felder eines Katalogeintrags — bewusst ein eigener, schlanker Typ
|
||||||
|
// statt store.Werkzeug direkt zu verwenden: internal/rules bleibt so
|
||||||
|
// unabhängig von internal/store und für sich allein testbar.
|
||||||
|
type WerkzeugEigenschaften struct {
|
||||||
|
AVVVerfuegbar bool
|
||||||
|
Verarbeitungsort string
|
||||||
|
TrainingOptOut bool
|
||||||
|
TrainingStandard bool
|
||||||
|
AufbewahrungTage int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErfuelltAnforderung prüft, ob ein Werkzeug eine einzelne Anforderung
|
||||||
|
// erfüllt. Anforderungen, die einen Prozess statt eine technische
|
||||||
|
// Werkzeug-Eigenschaft betreffen (menschliche Aufsicht, Kennzeichnung,
|
||||||
|
// DSFA) werden hier nicht hart gefiltert — sie werden als Auflage
|
||||||
|
// vermerkt (spätere Ausbaustufe), nicht als Ausschlussgrund für das
|
||||||
|
// Werkzeug selbst. loeschfrist_max_tage hat noch keine fachlich
|
||||||
|
// festgelegten Tageswerte (rules/OPEN.md, Punkt 4) und wird deshalb
|
||||||
|
// ebenfalls noch nicht hart gefiltert.
|
||||||
|
func ErfuelltAnforderung(anforderungID string, w WerkzeugEigenschaften) bool {
|
||||||
|
switch anforderungID {
|
||||||
|
case "avv_erforderlich":
|
||||||
|
return w.AVVVerfuegbar
|
||||||
|
case "eu_verarbeitung":
|
||||||
|
return w.Verarbeitungsort == "EU"
|
||||||
|
case "kein_training_auf_eingabe":
|
||||||
|
return w.TrainingStandard
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AusschlussGrund hält fest, warum ein Werkzeug aussortiert wurde —
|
||||||
|
// auch aussortierte Werkzeuge werden im Ergebnis gezeigt (siehe
|
||||||
|
// Bewertungslogik), nie stillschweigend weggelassen.
|
||||||
|
type AusschlussGrund struct {
|
||||||
|
WerkzeugID string
|
||||||
|
NichtErfuellt []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// WerkzeugKandidat ist ein Katalogeintrag im harten Filter.
|
||||||
|
type WerkzeugKandidat struct {
|
||||||
|
ID string
|
||||||
|
Eigenschaften WerkzeugEigenschaften
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterWerkzeuge sortiert Werkzeuge aus, die mindestens eine
|
||||||
|
// Pflichtanforderung nicht erfüllen, und hält für jedes ausgeschlossene
|
||||||
|
// Werkzeug fest, welche Anforderungen fehlten.
|
||||||
|
func FilterWerkzeuge(kandidaten []WerkzeugKandidat, anforderungIDs []string) (zulaessig []string, ausgeschlossen []AusschlussGrund) {
|
||||||
|
for _, k := range kandidaten {
|
||||||
|
var fehlend []string
|
||||||
|
for _, reqID := range anforderungIDs {
|
||||||
|
if !ErfuelltAnforderung(reqID, k.Eigenschaften) {
|
||||||
|
fehlend = append(fehlend, reqID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(fehlend) == 0 {
|
||||||
|
zulaessig = append(zulaessig, k.ID)
|
||||||
|
} else {
|
||||||
|
ausgeschlossen = append(ausgeschlossen, AusschlussGrund{WerkzeugID: k.ID, NichtErfuellt: fehlend})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return zulaessig, ausgeschlossen
|
||||||
|
}
|
||||||
|
|
||||||
|
// String liefert eine lesbare Begründung, z. B. für die
|
||||||
|
// Ergebnisdarstellung.
|
||||||
|
func (a AusschlussGrund) String() string {
|
||||||
|
return fmt.Sprintf("%s: erfüllt nicht %s", a.WerkzeugID, strings.Join(a.NichtErfuellt, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegelwerkVersion liefert eine reproduzierbare Kennung des geladenen
|
||||||
|
// Regelwerk-Stands — wird mit jeder Bewertung eingefroren, damit im
|
||||||
|
// Audit nachvollziehbar bleibt, mit welcher Regelwerk-Version ein
|
||||||
|
// Vorschlag erzeugt wurde (analog zu store.CurrentKatalogVersion für
|
||||||
|
// den Werkzeugkatalog).
|
||||||
|
func RegelwerkVersion(dk DatenklasseRegelwerk, ei EinstufungRegelwerk, an AnforderungsRegelwerk) string {
|
||||||
|
return fmt.Sprintf("dk%d.ei%d.an%d", dk.Version, ei.Version, an.Version)
|
||||||
|
}
|
||||||
211
internal/rules/evaluate_test.go
Normal file
211
internal/rules/evaluate_test.go
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
package rules_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadRealRegelwerke(t *testing.T) (rules.DatenklasseRegelwerk, rules.EinstufungRegelwerk, rules.AnforderungsRegelwerk) {
|
||||||
|
t.Helper()
|
||||||
|
fsys := realRulesFS(t)
|
||||||
|
dk, err := rules.LoadDatenklasse(fsys, "datenklasse.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadDatenklasse: %v", err)
|
||||||
|
}
|
||||||
|
ei, err := rules.LoadEinstufung(fsys, "kivo_einstufung.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadEinstufung: %v", err)
|
||||||
|
}
|
||||||
|
an, err := rules.LoadAnforderungen(fsys, "anforderungen.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadAnforderungen: %v", err)
|
||||||
|
}
|
||||||
|
return dk, ei, an
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateDatenklasseOnlyOeffentlich(t *testing.T) {
|
||||||
|
dk, _, _ := loadRealRegelwerke(t)
|
||||||
|
antworten := rules.Antworten{"b1": "nein", "b2": "nein", "b3": "nein", "b4": "nein", "b5": "nein", "b6": "nein", "b7": "ja"}
|
||||||
|
got := rules.EvaluateDatenklasse(dk, antworten)
|
||||||
|
if got.ID != "oeffentlich" {
|
||||||
|
t.Fatalf("EvaluateDatenklasse.ID = %q, want oeffentlich", got.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateDatenklasseHighestWins(t *testing.T) {
|
||||||
|
dk, _, _ := loadRealRegelwerke(t)
|
||||||
|
// b1 (personenbezogen) UND b2 (besondere_kategorie) beide ja ->
|
||||||
|
// besondere_kategorie hat den hoeheren Rang und muss gewinnen.
|
||||||
|
antworten := rules.Antworten{"b1": "ja", "b2": "ja", "b7": "nein"}
|
||||||
|
got := rules.EvaluateDatenklasse(dk, antworten)
|
||||||
|
if got.ID != "besondere_kategorie" {
|
||||||
|
t.Fatalf("EvaluateDatenklasse.ID = %q, want besondere_kategorie", got.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateDatenklasseUnsicherZaehltWieJa(t *testing.T) {
|
||||||
|
dk, _, _ := loadRealRegelwerke(t)
|
||||||
|
antworten := rules.Antworten{"b3": "unsicher"}
|
||||||
|
got := rules.EvaluateDatenklasse(dk, antworten)
|
||||||
|
if got.ID != "berufsgeheimnis" {
|
||||||
|
t.Fatalf("EvaluateDatenklasse.ID = %q, want berufsgeheimnis (unsicher zaehlt wie ja)", got.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateDatenklasseFallsBackToInternWhenNothingMatches(t *testing.T) {
|
||||||
|
dk, _, _ := loadRealRegelwerke(t)
|
||||||
|
antworten := rules.Antworten{"b1": "nein", "b2": "nein", "b3": "nein", "b4": "nein", "b5": "nein", "b6": "nein", "b7": "nein"}
|
||||||
|
got := rules.EvaluateDatenklasse(dk, antworten)
|
||||||
|
if got.ID != "intern" {
|
||||||
|
t.Fatalf("EvaluateDatenklasse.ID = %q, want intern (konservativer Fallback)", got.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateEinstufungVerboten(t *testing.T) {
|
||||||
|
_, ei, _ := loadRealRegelwerke(t)
|
||||||
|
antworten := rules.Antworten{"c3": true, "c3_art": "social_scoring"}
|
||||||
|
got, err := rules.EvaluateEinstufung(ei, antworten)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EvaluateEinstufung: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != "verboten" {
|
||||||
|
t.Fatalf("EvaluateEinstufung.ID = %q, want verboten", got.ID)
|
||||||
|
}
|
||||||
|
if !rules.IstVerboten(got.ID) {
|
||||||
|
t.Error("IstVerboten sollte true liefern")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateEinstufungHochrisiko(t *testing.T) {
|
||||||
|
_, ei, _ := loadRealRegelwerke(t)
|
||||||
|
antworten := rules.Antworten{"c2": true, "c2_folge": "kreditwuerdigkeit", "c3": false}
|
||||||
|
got, err := rules.EvaluateEinstufung(ei, antworten)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EvaluateEinstufung: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != "hochrisiko" {
|
||||||
|
t.Fatalf("EvaluateEinstufung.ID = %q, want hochrisiko", got.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateEinstufungTransparenzpflicht(t *testing.T) {
|
||||||
|
_, ei, _ := loadRealRegelwerke(t)
|
||||||
|
antworten := rules.Antworten{"c1": true, "c2": false, "c3": false, "c5": false}
|
||||||
|
got, err := rules.EvaluateEinstufung(ei, antworten)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EvaluateEinstufung: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != "transparenzpflicht" {
|
||||||
|
t.Fatalf("EvaluateEinstufung.ID = %q, want transparenzpflicht", got.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateEinstufungMinimalFallback(t *testing.T) {
|
||||||
|
_, ei, _ := loadRealRegelwerke(t)
|
||||||
|
antworten := rules.Antworten{"c1": false, "c2": false, "c3": false, "c4": false, "c5": true}
|
||||||
|
got, err := rules.EvaluateEinstufung(ei, antworten)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EvaluateEinstufung: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != "minimal" {
|
||||||
|
t.Fatalf("EvaluateEinstufung.ID = %q, want minimal", got.ID)
|
||||||
|
}
|
||||||
|
if rules.IstVerboten(got.ID) {
|
||||||
|
t.Error("IstVerboten sollte fuer minimal false liefern")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeriveAnforderungenPersonenbezogen(t *testing.T) {
|
||||||
|
_, _, an := loadRealRegelwerke(t)
|
||||||
|
got := rules.DeriveAnforderungen(an, "personenbezogen", "minimal")
|
||||||
|
want := map[string]bool{"avv_erforderlich": true, "kein_training_auf_eingabe": true, "loeschfrist_max_tage": true}
|
||||||
|
gotSet := map[string]bool{}
|
||||||
|
for _, a := range got {
|
||||||
|
gotSet[a.ID] = true
|
||||||
|
if a.Herleitung == "" {
|
||||||
|
t.Errorf("Anforderung %q hat keine Herleitung", a.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id := range want {
|
||||||
|
if !gotSet[id] {
|
||||||
|
t.Errorf("expected Anforderung %q for personenbezogen/minimal, got %v", id, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gotSet["eu_verarbeitung"] {
|
||||||
|
t.Error("eu_verarbeitung sollte fuer personenbezogen (ohne berufsgeheimnis/besondere_kategorie) nicht ausgeloest werden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeriveAnforderungenHochrisikoAddsAufsichtUndDsfa(t *testing.T) {
|
||||||
|
_, _, an := loadRealRegelwerke(t)
|
||||||
|
got := rules.DeriveAnforderungen(an, "oeffentlich", "hochrisiko")
|
||||||
|
gotSet := map[string]bool{}
|
||||||
|
for _, a := range got {
|
||||||
|
gotSet[a.ID] = true
|
||||||
|
}
|
||||||
|
if !gotSet["menschliche_aufsicht"] || !gotSet["dsfa_erforderlich"] {
|
||||||
|
t.Errorf("expected menschliche_aufsicht und dsfa_erforderlich fuer hochrisiko, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErfuelltAnforderungAVV(t *testing.T) {
|
||||||
|
if rules.ErfuelltAnforderung("avv_erforderlich", rules.WerkzeugEigenschaften{AVVVerfuegbar: false}) {
|
||||||
|
t.Error("erwartet: nicht erfuellt ohne AVV")
|
||||||
|
}
|
||||||
|
if !rules.ErfuelltAnforderung("avv_erforderlich", rules.WerkzeugEigenschaften{AVVVerfuegbar: true}) {
|
||||||
|
t.Error("erwartet: erfuellt mit AVV")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErfuelltAnforderungUnbekannteAnforderungIstUnkritisch(t *testing.T) {
|
||||||
|
if !rules.ErfuelltAnforderung("menschliche_aufsicht", rules.WerkzeugEigenschaften{}) {
|
||||||
|
t.Error("Prozess-Anforderungen duerfen ein Werkzeug nicht hart aussortieren")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterWerkzeugeSortsOutMissingAVV(t *testing.T) {
|
||||||
|
kandidaten := []rules.WerkzeugKandidat{
|
||||||
|
{ID: "mit-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: true, Verarbeitungsort: "EU", TrainingStandard: true}},
|
||||||
|
{ID: "ohne-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: false, Verarbeitungsort: "EU", TrainingStandard: true}},
|
||||||
|
}
|
||||||
|
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, []string{"avv_erforderlich"})
|
||||||
|
if len(zulaessig) != 1 || zulaessig[0] != "mit-avv" {
|
||||||
|
t.Fatalf("zulaessig = %v, want [mit-avv]", zulaessig)
|
||||||
|
}
|
||||||
|
if len(ausgeschlossen) != 1 || ausgeschlossen[0].WerkzeugID != "ohne-avv" {
|
||||||
|
t.Fatalf("ausgeschlossen = %v, want genau ohne-avv", ausgeschlossen)
|
||||||
|
}
|
||||||
|
if len(ausgeschlossen[0].NichtErfuellt) != 1 || ausgeschlossen[0].NichtErfuellt[0] != "avv_erforderlich" {
|
||||||
|
t.Fatalf("NichtErfuellt = %v, want [avv_erforderlich]", ausgeschlossen[0].NichtErfuellt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterWerkzeugeNoRequirementsAllowsEverything(t *testing.T) {
|
||||||
|
kandidaten := []rules.WerkzeugKandidat{{ID: "x"}, {ID: "y"}}
|
||||||
|
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, nil)
|
||||||
|
if len(zulaessig) != 2 || len(ausgeschlossen) != 0 {
|
||||||
|
t.Fatalf("zulaessig=%v ausgeschlossen=%v, want beide zulaessig", zulaessig, ausgeschlossen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAntwortenRoundTrip(t *testing.T) {
|
||||||
|
raw := []byte(`{"b1":"ja","c2":true,"c2_folge":"bildung"}`)
|
||||||
|
a, err := rules.ParseAntworten(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseAntworten: %v", err)
|
||||||
|
}
|
||||||
|
if a["b1"] != "ja" || a["c2_folge"] != "bildung" {
|
||||||
|
t.Fatalf("ParseAntworten = %+v, unerwartete Werte", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAntwortenEmpty(t *testing.T) {
|
||||||
|
a, err := rules.ParseAntworten(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseAntworten: %v", err)
|
||||||
|
}
|
||||||
|
if len(a) != 0 {
|
||||||
|
t.Fatalf("ParseAntworten(nil) = %+v, want empty", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,6 +109,33 @@ func (s *Store) SetAntragStatus(ctx context.Context, id, status string) error {
|
|||||||
return nil
|
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
|
// ListAntraegeForAccount liefert alle Anträge eines Mandanten, neueste
|
||||||
// zuerst.
|
// zuerst.
|
||||||
func (s *Store) ListAntraegeForAccount(ctx context.Context, accountID string) ([]Antrag, error) {
|
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) {
|
func TestListAntraegeForAccountIsolatesTenants(t *testing.T) {
|
||||||
s := openTestStore(t)
|
s := openTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
139
internal/store/bewertung.go
Normal file
139
internal/store/bewertung.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BewertungAnforderung ist eine abgeleitete Anforderung mit Herleitung,
|
||||||
|
// wie sie in bewertung.anforderungen (JSONB) gespeichert wird.
|
||||||
|
type BewertungAnforderung struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Beschreibung string `json:"beschreibung"`
|
||||||
|
Herleitung string `json:"herleitung"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BewertungAusschluss hält fest, warum ein Werkzeug für eine Bewertung
|
||||||
|
// ausgeschlossen wurde.
|
||||||
|
type BewertungAusschluss struct {
|
||||||
|
WerkzeugID string `json:"werkzeug_id"`
|
||||||
|
NichtErfuellt []string `json:"nicht_erfuellt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bewertung ist der berechnete Vorschlag für einen Antrag. Append-only
|
||||||
|
// — siehe Migration.
|
||||||
|
type Bewertung struct {
|
||||||
|
ID string
|
||||||
|
AntragID string
|
||||||
|
Datenklasse string
|
||||||
|
DatenklasseHerleitung string
|
||||||
|
Einstufung string
|
||||||
|
EinstufungHerleitung string
|
||||||
|
Verboten bool
|
||||||
|
Anforderungen []BewertungAnforderung
|
||||||
|
ZulaessigeWerkzeuge []string
|
||||||
|
AusgeschlosseneWerkzeuge []BewertungAusschluss
|
||||||
|
RegelwerkVersion string
|
||||||
|
KatalogVersion string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const bewertungColumns = `id, antrag_id, datenklasse, datenklasse_herleitung, einstufung, einstufung_herleitung,
|
||||||
|
verboten, anforderungen, zulaessige_werkzeuge, ausgeschlossene_werkzeuge, regelwerk_version, katalog_version, created_at`
|
||||||
|
|
||||||
|
func scanBewertung(row interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}) (Bewertung, error) {
|
||||||
|
var b Bewertung
|
||||||
|
var anforderungenRaw, ausschlussRaw []byte
|
||||||
|
err := row.Scan(
|
||||||
|
&b.ID, &b.AntragID, &b.Datenklasse, &b.DatenklasseHerleitung, &b.Einstufung, &b.EinstufungHerleitung,
|
||||||
|
&b.Verboten, &anforderungenRaw, &b.ZulaessigeWerkzeuge, &ausschlussRaw, &b.RegelwerkVersion, &b.KatalogVersion, &b.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return Bewertung{}, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(anforderungenRaw, &b.Anforderungen); err != nil {
|
||||||
|
return Bewertung{}, fmt.Errorf("store: anforderungen unmarshal: %w", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(ausschlussRaw, &b.AusgeschlosseneWerkzeuge); err != nil {
|
||||||
|
return Bewertung{}, fmt.Errorf("store: ausgeschlossene_werkzeuge unmarshal: %w", err)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BewertungInput bündelt die Felder einer neuen Bewertung — bei elf
|
||||||
|
// Werten lesbarer als eine positionale Parameterliste (gleiches Muster
|
||||||
|
// wie WerkzeugInput).
|
||||||
|
type BewertungInput struct {
|
||||||
|
AntragID string
|
||||||
|
Datenklasse string
|
||||||
|
DatenklasseHerleitung string
|
||||||
|
Einstufung string
|
||||||
|
EinstufungHerleitung string
|
||||||
|
Verboten bool
|
||||||
|
Anforderungen []BewertungAnforderung
|
||||||
|
ZulaessigeWerkzeuge []string
|
||||||
|
AusgeschlosseneWerkzeuge []BewertungAusschluss
|
||||||
|
RegelwerkVersion string
|
||||||
|
KatalogVersion string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBewertung speichert eine berechnete Bewertung für einen Antrag.
|
||||||
|
func (s *Store) CreateBewertung(ctx context.Context, in BewertungInput) (Bewertung, error) {
|
||||||
|
if in.ZulaessigeWerkzeuge == nil {
|
||||||
|
in.ZulaessigeWerkzeuge = []string{}
|
||||||
|
}
|
||||||
|
if in.Anforderungen == nil {
|
||||||
|
in.Anforderungen = []BewertungAnforderung{}
|
||||||
|
}
|
||||||
|
if in.AusgeschlosseneWerkzeuge == nil {
|
||||||
|
in.AusgeschlosseneWerkzeuge = []BewertungAusschluss{}
|
||||||
|
}
|
||||||
|
anforderungenJSON, err := json.Marshal(in.Anforderungen)
|
||||||
|
if err != nil {
|
||||||
|
return Bewertung{}, fmt.Errorf("store: anforderungen marshal: %w", err)
|
||||||
|
}
|
||||||
|
ausschlussJSON, err := json.Marshal(in.AusgeschlosseneWerkzeuge)
|
||||||
|
if err != nil {
|
||||||
|
return Bewertung{}, fmt.Errorf("store: ausgeschlossene_werkzeuge marshal: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
row := s.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO bewertung (
|
||||||
|
antrag_id, datenklasse, datenklasse_herleitung, einstufung, einstufung_herleitung,
|
||||||
|
verboten, anforderungen, zulaessige_werkzeuge, ausgeschlossene_werkzeuge, regelwerk_version, katalog_version
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
RETURNING `+bewertungColumns,
|
||||||
|
in.AntragID, in.Datenklasse, in.DatenklasseHerleitung, in.Einstufung, in.EinstufungHerleitung,
|
||||||
|
in.Verboten, anforderungenJSON, in.ZulaessigeWerkzeuge, ausschlussJSON, in.RegelwerkVersion, in.KatalogVersion,
|
||||||
|
)
|
||||||
|
b, err := scanBewertung(row)
|
||||||
|
if err != nil {
|
||||||
|
return Bewertung{}, fmt.Errorf("store: create bewertung: %w", err)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestBewertungForAntrag liefert die zuletzt berechnete Bewertung
|
||||||
|
// eines Antrags. Liefert ErrNotFound, wenn noch keine Bewertung
|
||||||
|
// existiert.
|
||||||
|
func (s *Store) GetLatestBewertungForAntrag(ctx context.Context, antragID string) (Bewertung, error) {
|
||||||
|
row := s.Pool.QueryRow(ctx, `
|
||||||
|
SELECT `+bewertungColumns+`
|
||||||
|
FROM bewertung WHERE antrag_id = $1 ORDER BY created_at DESC LIMIT 1
|
||||||
|
`, antragID)
|
||||||
|
b, err := scanBewertung(row)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Bewertung{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Bewertung{}, fmt.Errorf("store: get latest bewertung: %w", err)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
126
internal/store/bewertung_test.go
Normal file
126
internal/store/bewertung_test.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testBewertungInput(antragID string) store.BewertungInput {
|
||||||
|
return store.BewertungInput{
|
||||||
|
AntragID: antragID, Datenklasse: "personenbezogen", DatenklasseHerleitung: "ausgelöst durch b1",
|
||||||
|
Einstufung: "minimal", EinstufungHerleitung: "Auffangregel", Verboten: false,
|
||||||
|
Anforderungen: []store.BewertungAnforderung{
|
||||||
|
{ID: "avv_erforderlich", Beschreibung: "AVV nötig", Herleitung: "aus Datenklasse personenbezogen"},
|
||||||
|
},
|
||||||
|
ZulaessigeWerkzeuge: []string{"werkzeug-a"},
|
||||||
|
AusgeschlosseneWerkzeuge: []store.BewertungAusschluss{
|
||||||
|
{WerkzeugID: "werkzeug-b", NichtErfuellt: []string{"avv_erforderlich"}},
|
||||||
|
},
|
||||||
|
RegelwerkVersion: "dk1.ei1.an1", KatalogVersion: "3-1735300000",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBewertungCreateAndGetLatest(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, "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)
|
||||||
|
}
|
||||||
|
if b.Datenklasse != "personenbezogen" || len(b.Anforderungen) != 1 {
|
||||||
|
t.Fatalf("CreateBewertung = %+v, unerwartete Werte", b)
|
||||||
|
}
|
||||||
|
if b.Anforderungen[0].ID != "avv_erforderlich" {
|
||||||
|
t.Fatalf("Anforderungen[0] = %+v, unerwartet", b.Anforderungen[0])
|
||||||
|
}
|
||||||
|
if len(b.AusgeschlosseneWerkzeuge) != 1 || b.AusgeschlosseneWerkzeuge[0].WerkzeugID != "werkzeug-b" {
|
||||||
|
t.Fatalf("AusgeschlosseneWerkzeuge = %+v, unerwartet", b.AusgeschlosseneWerkzeuge)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetLatestBewertungForAntrag(ctx, antrag.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != b.ID {
|
||||||
|
t.Fatalf("GetLatestBewertungForAntrag returned a different row than CreateBewertung")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLatestBewertungForAntragNotFound(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 Bewertung")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAntrag: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.GetLatestBewertungForAntrag(ctx, antrag.ID)
|
||||||
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBewertungReturnsNewestWhenMultiple(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, "Mehrfach bewertet")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAntrag: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.CreateBewertung(ctx, testBewertungInput(antrag.ID)); err != nil {
|
||||||
|
t.Fatalf("CreateBewertung (1): %v", err)
|
||||||
|
}
|
||||||
|
second := testBewertungInput(antrag.ID)
|
||||||
|
second.Datenklasse = "besondere_kategorie"
|
||||||
|
newest, err := s.CreateBewertung(ctx, second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBewertung (2): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetLatestBewertungForAntrag(ctx, antrag.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != newest.ID || got.Datenklasse != "besondere_kategorie" {
|
||||||
|
t.Fatalf("expected the newest bewertung, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBewertungIsAppendOnly(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, "Append-only Test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAntrag: %v", err)
|
||||||
|
}
|
||||||
|
b, err := s.CreateBewertung(ctx, testBewertungInput(antrag.ID))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBewertung: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.Pool.Exec(ctx, `UPDATE bewertung SET datenklasse = 'geaendert' WHERE id = $1`, b.ID)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected UPDATE on bewertung to be rejected by the append-only trigger")
|
||||||
|
}
|
||||||
|
_, err = s.Pool.Exec(ctx, `DELETE FROM bewertung WHERE id = $1`, b.ID)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected DELETE on bewertung to be rejected by the append-only trigger")
|
||||||
|
}
|
||||||
|
}
|
||||||
111
internal/store/entscheidung.go
Normal file
111
internal/store/entscheidung.go
Normal 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
|
||||||
|
}
|
||||||
115
internal/store/entscheidung_test.go
Normal file
115
internal/store/entscheidung_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,9 +29,22 @@ ALTER TABLE account DROP COLUMN IF EXISTS verified;
|
|||||||
-- betreiber Ebene 5 — Netcell-IT-Personal, plattformweit
|
-- betreiber Ebene 5 — Netcell-IT-Personal, plattformweit
|
||||||
-- (Werkzeugkatalog, Regelwerk, Mandantenverwaltung);
|
-- (Werkzeugkatalog, Regelwerk, Mandantenverwaltung);
|
||||||
-- entspricht der alten "admin"-Rolle vor dieser Migration
|
-- entspricht der alten "admin"-Rolle vor dieser Migration
|
||||||
-- Die alten Rollen (creator/agentur/marke/kanzlei) haben im neuen
|
--
|
||||||
-- Produkt keine Bedeutung mehr.
|
-- Die alte CHECK-Constraint muss zuerst weg, sonst lehnt sie die
|
||||||
|
-- Datenmigration unten (die neue Rollenwerte wie "betreiber" schreibt)
|
||||||
|
-- sofort ab — die alte Constraint kennt diese Werte ja noch nicht.
|
||||||
ALTER TABLE app_user DROP CONSTRAINT app_user_role_check;
|
ALTER TABLE app_user DROP CONSTRAINT app_user_role_check;
|
||||||
|
|
||||||
|
-- Bestehende Zeilen auf gültige neue Werte umstellen, bevor die neue
|
||||||
|
-- Constraint das erzwingt — sonst schlägt sie auf jeder Installation
|
||||||
|
-- mit echten Nutzern fehl. Die alte Rolle "admin" war der Plattform-
|
||||||
|
-- Betreiber (Netcell-IT) — wird zu "betreiber", nicht zum neuen,
|
||||||
|
-- mandantenbezogenen "admin". Alle anderen alten Rollen (creator/agentur/
|
||||||
|
-- marke/kanzlei) waren gewöhnliche Mandanten-Logins ohne Sonderrechte —
|
||||||
|
-- werden zu "mitarbeiter", der schlankesten neuen Rolle.
|
||||||
|
UPDATE app_user SET role = 'betreiber' WHERE role = 'admin';
|
||||||
|
UPDATE app_user SET role = 'mitarbeiter' WHERE role IN ('creator', 'agentur', 'marke', 'kanzlei');
|
||||||
|
|
||||||
ALTER TABLE app_user ADD CONSTRAINT app_user_role_check
|
ALTER TABLE app_user ADD CONSTRAINT app_user_role_check
|
||||||
CHECK (role IN ('mitarbeiter', 'verantwortlicher', 'pruefer', 'admin', 'betreiber'));
|
CHECK (role IN ('mitarbeiter', 'verantwortlicher', 'pruefer', 'admin', 'betreiber'));
|
||||||
|
|
||||||
|
|||||||
1
internal/store/migrations/0009_bewertung.down.sql
Normal file
1
internal/store/migrations/0009_bewertung.down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE bewertung;
|
||||||
26
internal/store/migrations/0009_bewertung.up.sql
Normal file
26
internal/store/migrations/0009_bewertung.up.sql
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
-- Bewertung: der berechnete Vorschlag für einen Antrag (Datenklasse,
|
||||||
|
-- KI-VO-Einstufung, Anforderungsprofil, zulässige/ausgeschlossene
|
||||||
|
-- Werkzeuge mit Herleitung). Append-only wie finding/extraction im
|
||||||
|
-- alten Produkt — eine neue Bewertung (z. B. nach Änderung der
|
||||||
|
-- Antworten oder des Katalogs) ergänzt die alte, ersetzt sie nicht;
|
||||||
|
-- die Historie bleibt für den Audit erhalten. regelwerk_version und
|
||||||
|
-- katalog_version werden zum Berechnungszeitpunkt eingefroren.
|
||||||
|
CREATE TABLE bewertung (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
antrag_id UUID NOT NULL REFERENCES antrag (id),
|
||||||
|
datenklasse TEXT NOT NULL,
|
||||||
|
datenklasse_herleitung TEXT NOT NULL,
|
||||||
|
einstufung TEXT NOT NULL,
|
||||||
|
einstufung_herleitung TEXT NOT NULL,
|
||||||
|
verboten BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
anforderungen JSONB NOT NULL DEFAULT '[]',
|
||||||
|
zulaessige_werkzeuge TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
ausgeschlossene_werkzeuge JSONB NOT NULL DEFAULT '[]',
|
||||||
|
regelwerk_version TEXT NOT NULL,
|
||||||
|
katalog_version TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER bewertung_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON bewertung
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||||
1
internal/store/migrations/0010_entscheidung.down.sql
Normal file
1
internal/store/migrations/0010_entscheidung.down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE entscheidung;
|
||||||
25
internal/store/migrations/0010_entscheidung.up.sql
Normal file
25
internal/store/migrations/0010_entscheidung.up.sql
Normal 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();
|
||||||
108
internal/web/admin_handlers.go
Normal file
108
internal/web/admin_handlers.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
// Ebene 4 (Mandanten-Verwaltung) — Nutzer/Rollen für GENAU EINEN
|
||||||
|
// Mandanten, siehe CLAUDE.md, Fünf Ebenen. Aktuell nur Nutzerverwaltung
|
||||||
|
// (Abteilungen, Anmeldeverfahren, Werkzeug-Freigaben, Rechnungsdaten
|
||||||
|
// sind spätere Ausbaustufen). Es gibt noch keinen Einladungslink/CSV-
|
||||||
|
// Import/SSO (siehe CLAUDE.md, Onboarding) — ein Admin setzt hier
|
||||||
|
// direkt ein Initialpasswort, wie schon bei der Firma-Registrierung.
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mandantenRollen sind die Rollen, die ein Admin innerhalb des eigenen
|
||||||
|
// Mandanten vergeben darf — "betreiber" ist Ebene 5 (Plattform) und
|
||||||
|
// kann von keinem Mandanten-Admin vergeben werden.
|
||||||
|
var mandantenRollen = []string{"mitarbeiter", "verantwortlicher", "pruefer", "admin"}
|
||||||
|
|
||||||
|
func istMandantenRolle(role string) bool {
|
||||||
|
for _, r := range mandantenRollen {
|
||||||
|
if r == role {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type nutzerListItem struct {
|
||||||
|
Email string
|
||||||
|
Role string
|
||||||
|
}
|
||||||
|
|
||||||
|
type nutzerListeData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Nutzer []nutzerListItem
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNutzerListe zeigt alle Logins des eigenen Mandanten.
|
||||||
|
func (s *Server) handleNutzerListe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
users, err := s.store.ListUsersForAccount(r.Context(), currentUser(r).AccountID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := nutzerListeData{Title: "Nutzerverwaltung", Nav: navFor(r)}
|
||||||
|
for _, u := range users {
|
||||||
|
data.Nutzer = append(data.Nutzer, nutzerListItem{Email: u.Email, Role: u.Role})
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "nutzer-liste", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type nutzerNeuData struct {
|
||||||
|
Title string
|
||||||
|
Nav navData
|
||||||
|
Rollen []string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleNutzerNeuForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := nutzerNeuData{Title: "Nutzer anlegen", Nav: navFor(r), Rollen: mandantenRollen}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "nutzer-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNutzerCreate legt einen weiteren Login im eigenen Mandanten an.
|
||||||
|
// Es gibt keine Einladungsmail (siehe CLAUDE.md, Onboarding — noch
|
||||||
|
// nicht gebaut) — der Admin setzt das Initialpasswort direkt und gibt
|
||||||
|
// es dem/der neuen Nutzer:in auf einem anderen Weg weiter.
|
||||||
|
func (s *Server) handleNutzerCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
email := r.FormValue("email")
|
||||||
|
password := r.FormValue("password")
|
||||||
|
role := r.FormValue("role")
|
||||||
|
if email == "" || password == "" || !istMandantenRolle(role) {
|
||||||
|
data := nutzerNeuData{Title: "Nutzer anlegen", Nav: navFor(r), Rollen: mandantenRollen, Error: "E-Mail, Passwort und eine gültige Rolle sind Pflicht"}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "nutzer-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordHash, err := auth.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
data := nutzerNeuData{Title: "Nutzer anlegen", Nav: navFor(r), Rollen: mandantenRollen, Error: err.Error()}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "nutzer-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.store.CreateUser(r.Context(), currentUser(r).AccountID, email, passwordHash, role); err != nil {
|
||||||
|
data := nutzerNeuData{Title: "Nutzer anlegen", Nav: navFor(r), Rollen: mandantenRollen, Error: "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "nutzer-neu", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/verwaltung/nutzer", http.StatusSeeOther)
|
||||||
|
}
|
||||||
75
internal/web/admin_handlers_test.go
Normal file
75
internal/web/admin_handlers_test.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMitarbeiterCannotAccessNutzerverwaltung(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, cookie, "/verwaltung/nutzer")
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for role mitarbeiter on /verwaltung/nutzer", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminCanCreateUserWithMandantenRolle(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
|
||||||
|
resp := postForm(t, s, adminCookie, "/verwaltung/nutzer", url.Values{
|
||||||
|
"email": {"neu@example.com"}, "password": {"ein-langes-passwort"}, "role": {"verantwortlicher"},
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := fs.GetUserByEmail(context.Background(), "neu@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
if u.Role != "verantwortlicher" {
|
||||||
|
t.Errorf("Role = %q, want verantwortlicher", u.Role)
|
||||||
|
}
|
||||||
|
|
||||||
|
list := getWithCookie(t, s, adminCookie, "/verwaltung/nutzer")
|
||||||
|
if !strings.Contains(list.Body.String(), "neu@example.com") {
|
||||||
|
t.Errorf("expected the new user in the Nutzerliste, got: %s", list.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminCannotCreateBetreiberUser(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
|
||||||
|
resp := postForm(t, s, adminCookie, "/verwaltung/nutzer", url.Values{
|
||||||
|
"email": {"versuch@example.com"}, "password": {"ein-langes-passwort"}, "role": {"betreiber"},
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (Formular mit Fehler) for role betreiber", resp.Code)
|
||||||
|
}
|
||||||
|
if _, err := fs.GetUserByEmail(context.Background(), "versuch@example.com"); err == nil {
|
||||||
|
t.Error("expected no user to be created for an invalid role")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminCreateUserRejectsDuplicateEmail(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||||
|
|
||||||
|
resp := postForm(t, s, adminCookie, "/verwaltung/nutzer", url.Values{
|
||||||
|
"email": {"admin@example.com"}, "password": {"ein-langes-passwort"}, "role": {"mitarbeiter"},
|
||||||
|
})
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (Formular mit Fehler) for duplicate email", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
355
internal/web/antrag_handlers.go
Normal file
355
internal/web/antrag_handlers.go
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
|
"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)
|
||||||
|
|
||||||
|
antworten := antwortenFromForm(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, antworten); 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
|
||||||
|
}
|
||||||
|
if err := s.bewerteAntrag(ctx, antrag.ID, user.AccountID, antworten); err != nil {
|
||||||
|
http.Error(w, "Bewertung konnte nicht berechnet werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/antraege/"+antrag.ID, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bewerteAntrag berechnet die Bewertung für einen eingereichten Antrag
|
||||||
|
// (Schritt 3: Ableitungen und harte Filter) und speichert sie. Bei
|
||||||
|
// "verboten" erfolgt die K.-o.-Prüfung — keine Werkzeugsuche, siehe
|
||||||
|
// CLAUDE.md, Bewertungslogik.
|
||||||
|
func (s *Server) bewerteAntrag(ctx context.Context, antragID, accountID string, antwortenJSON []byte) error {
|
||||||
|
antworten, err := rules.ParseAntworten(antwortenJSON)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
datenklasse := rules.EvaluateDatenklasse(s.regelwerk.Datenklasse, antworten)
|
||||||
|
einstufung, err := rules.EvaluateEinstufung(s.regelwerk.Einstufung, antworten)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
verboten := rules.IstVerboten(einstufung.ID)
|
||||||
|
|
||||||
|
in := store.BewertungInput{
|
||||||
|
AntragID: antragID, Datenklasse: datenklasse.ID, DatenklasseHerleitung: datenklasse.Herleitung,
|
||||||
|
Einstufung: einstufung.ID, EinstufungHerleitung: einstufungHerleitungText(einstufung), Verboten: verboten,
|
||||||
|
KatalogVersion: "n/a (K.-o.-Prüfung, keine Werkzeugsuche)",
|
||||||
|
}
|
||||||
|
|
||||||
|
if !verboten {
|
||||||
|
anforderungen := rules.DeriveAnforderungen(s.regelwerk.Anforderungen, datenklasse.ID, einstufung.ID)
|
||||||
|
anforderungIDs := make([]string, len(anforderungen))
|
||||||
|
for i, a := range anforderungen {
|
||||||
|
in.Anforderungen = append(in.Anforderungen, store.BewertungAnforderung{ID: a.ID, Beschreibung: a.Beschreibung, Herleitung: a.Herleitung})
|
||||||
|
anforderungIDs[i] = a.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
werkzeuge, err := s.store.ListWerkzeugeForAccount(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
kandidaten := make([]rules.WerkzeugKandidat, len(werkzeuge))
|
||||||
|
for i, w := range werkzeuge {
|
||||||
|
kandidaten[i] = rules.WerkzeugKandidat{ID: w.ID, Eigenschaften: rules.WerkzeugEigenschaften{
|
||||||
|
AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungsort: w.Verarbeitungsort,
|
||||||
|
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: w.AufbewahrungTage,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, anforderungIDs)
|
||||||
|
in.ZulaessigeWerkzeuge = zulaessig
|
||||||
|
for _, a := range ausgeschlossen {
|
||||||
|
in.AusgeschlosseneWerkzeuge = append(in.AusgeschlosseneWerkzeuge, store.BewertungAusschluss{WerkzeugID: a.WerkzeugID, NichtErfuellt: a.NichtErfuellt})
|
||||||
|
}
|
||||||
|
|
||||||
|
katalogVersion, err := s.store.CurrentKatalogVersion(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
in.KatalogVersion = katalogVersion
|
||||||
|
}
|
||||||
|
in.RegelwerkVersion = rules.RegelwerkVersion(s.regelwerk.Datenklasse, s.regelwerk.Einstufung, s.regelwerk.Anforderungen)
|
||||||
|
|
||||||
|
_, err = s.store.CreateBewertung(ctx, in)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// einstufungHerleitungText fügt die Quelle (z. B. "Art. 5 KI-VO") mit
|
||||||
|
// an, falls das Regelwerk eine nennt.
|
||||||
|
func einstufungHerleitungText(e rules.EinstufungErgebnis) string {
|
||||||
|
if e.Quelle == "" {
|
||||||
|
return e.Herleitung
|
||||||
|
}
|
||||||
|
return e.Herleitung + " (" + e.Quelle + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
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 anforderungView struct {
|
||||||
|
ID string
|
||||||
|
Beschreibung string
|
||||||
|
Herleitung string
|
||||||
|
}
|
||||||
|
|
||||||
|
type werkzeugAusschlussView struct {
|
||||||
|
Name string
|
||||||
|
NichtErfuellt []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type bewertungView struct {
|
||||||
|
Datenklasse string
|
||||||
|
DatenklasseHerleitung string
|
||||||
|
Einstufung string
|
||||||
|
EinstufungHerleitung string
|
||||||
|
Verboten bool
|
||||||
|
Anforderungen []anforderungView
|
||||||
|
ZulaessigeWerkzeuge []string // Namen, nicht IDs
|
||||||
|
AusgeschlosseneWerkzeuge []werkzeugAusschlussView
|
||||||
|
RegelwerkVersion string
|
||||||
|
KatalogVersion string
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
Bewertung *bewertungView
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
|
||||||
|
if err == nil {
|
||||||
|
data.Bewertung = s.toBewertungView(r.Context(), bewertung)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "antrag-detail", data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toBewertungView löst die in der Bewertung nur als ID gespeicherten
|
||||||
|
// Werkzeuge zu Anzeigenamen auf. Ein Werkzeug, das seit der Berechnung
|
||||||
|
// gelöscht wurde, wird mit seiner ID statt eines Namens angezeigt,
|
||||||
|
// statt den ganzen Antrag unanzeigbar zu machen.
|
||||||
|
func (s *Server) toBewertungView(ctx context.Context, b store.Bewertung) *bewertungView {
|
||||||
|
v := &bewertungView{
|
||||||
|
Datenklasse: b.Datenklasse, DatenklasseHerleitung: b.DatenklasseHerleitung,
|
||||||
|
Einstufung: b.Einstufung, EinstufungHerleitung: b.EinstufungHerleitung, Verboten: b.Verboten,
|
||||||
|
RegelwerkVersion: b.RegelwerkVersion, KatalogVersion: b.KatalogVersion,
|
||||||
|
CreatedAt: b.CreatedAt.Format("02.01.2006 15:04"),
|
||||||
|
}
|
||||||
|
for _, a := range b.Anforderungen {
|
||||||
|
v.Anforderungen = append(v.Anforderungen, anforderungView{ID: a.ID, Beschreibung: a.Beschreibung, Herleitung: a.Herleitung})
|
||||||
|
}
|
||||||
|
for _, id := range b.ZulaessigeWerkzeuge {
|
||||||
|
v.ZulaessigeWerkzeuge = append(v.ZulaessigeWerkzeuge, s.werkzeugName(ctx, id))
|
||||||
|
}
|
||||||
|
for _, a := range b.AusgeschlosseneWerkzeuge {
|
||||||
|
v.AusgeschlosseneWerkzeuge = append(v.AusgeschlosseneWerkzeuge, werkzeugAusschlussView{
|
||||||
|
Name: s.werkzeugName(ctx, a.WerkzeugID), NichtErfuellt: a.NichtErfuellt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) werkzeugName(ctx context.Context, id string) string {
|
||||||
|
w, err := s.store.GetWerkzeug(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return w.Name
|
||||||
|
}
|
||||||
255
internal/web/antrag_handlers_test.go
Normal file
255
internal/web/antrag_handlers_test.go
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fullAntragForm liefert b1=ja (Datenklasse personenbezogen) und
|
||||||
|
// c1..c3=nein (keine der verboten/hochrisiko/transparenzpflicht-
|
||||||
|
// Varianten trifft zu) — die Einstufung fällt damit auf die
|
||||||
|
// Auffangregel "minimal" zurück (siehe rules/kivo_einstufung.yaml).
|
||||||
|
func TestAntragCreateComputesBewertung(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
|
||||||
|
|
||||||
|
resp := postForm(t, s, cookie, "/antraege", fullAntragForm())
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
var antragID string
|
||||||
|
for id := range fs.antraege {
|
||||||
|
antragID = id
|
||||||
|
}
|
||||||
|
b, err := fs.GetLatestBewertungForAntrag(context.Background(), antragID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
|
||||||
|
}
|
||||||
|
if b.Datenklasse != "personenbezogen" {
|
||||||
|
t.Errorf("Datenklasse = %q, want personenbezogen", b.Datenklasse)
|
||||||
|
}
|
||||||
|
if b.Einstufung != "minimal" {
|
||||||
|
t.Errorf("Einstufung = %q, want minimal", b.Einstufung)
|
||||||
|
}
|
||||||
|
if b.Verboten {
|
||||||
|
t.Error("Verboten = true, want false")
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := getWithCookie(t, s, cookie, resp.Header().Get("Location"))
|
||||||
|
if !strings.Contains(detail.Body.String(), "Das System entscheidet nicht") {
|
||||||
|
t.Errorf("expected the mandatory disclaimer on the detail page, got: %s", detail.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAntragCreateVerbotenSkipsWerkzeugSearch prüft die K.-o.-Prüfung:
|
||||||
|
// c3=ja mit c3_art=social_scoring löst "verboten" aus (Art. 5 KI-VO) —
|
||||||
|
// dann darf keine Werkzeugsuche stattfinden (siehe CLAUDE.md,
|
||||||
|
// Bewertungslogik).
|
||||||
|
func TestAntragCreateVerbotenSkipsWerkzeugSearch(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
|
||||||
|
|
||||||
|
form := fullAntragForm()
|
||||||
|
form.Set("c3", "ja")
|
||||||
|
form.Set("c3_art", "social_scoring")
|
||||||
|
|
||||||
|
resp := postForm(t, s, cookie, "/antraege", form)
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
var antragID string
|
||||||
|
for id := range fs.antraege {
|
||||||
|
antragID = id
|
||||||
|
}
|
||||||
|
b, err := fs.GetLatestBewertungForAntrag(context.Background(), antragID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
|
||||||
|
}
|
||||||
|
if !b.Verboten || b.Einstufung != "verboten" {
|
||||||
|
t.Fatalf("Bewertung = %+v, want Verboten=true, Einstufung=verboten", b)
|
||||||
|
}
|
||||||
|
if len(b.ZulaessigeWerkzeuge) != 0 || len(b.AusgeschlosseneWerkzeuge) != 0 {
|
||||||
|
t.Errorf("expected no werkzeug search for a verboten Bewertung, got %+v", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAntragCreateFiltersWerkzeugeByAnforderungen prüft den harten
|
||||||
|
// Filter: personenbezogen verlangt avv_erforderlich — ein Werkzeug ohne
|
||||||
|
// AVV wird ausgeschlossen und im Ergebnis mit Begründung genannt statt
|
||||||
|
// stillschweigend wegzufallen, ein Werkzeug mit AVV bleibt zulässig.
|
||||||
|
func TestAntragCreateFiltersWerkzeugeByAnforderungen(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fs)
|
||||||
|
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
|
||||||
|
|
||||||
|
fs.werkzeuge["werkzeug-ok"] = store.Werkzeug{
|
||||||
|
ID: "werkzeug-ok", Name: "ChatGPT Enterprise",
|
||||||
|
AVVVerfuegbar: true, TrainingStandard: true,
|
||||||
|
}
|
||||||
|
fs.werkzeuge["werkzeug-ohne-avv"] = store.Werkzeug{
|
||||||
|
ID: "werkzeug-ohne-avv", Name: "Freies KI-Tool",
|
||||||
|
AVVVerfuegbar: false, TrainingStandard: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, cookie, "/antraege", fullAntragForm())
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
var antragID string
|
||||||
|
for id := range fs.antraege {
|
||||||
|
antragID = id
|
||||||
|
}
|
||||||
|
b, err := fs.GetLatestBewertungForAntrag(context.Background(), antragID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
|
||||||
|
}
|
||||||
|
if len(b.ZulaessigeWerkzeuge) != 1 || b.ZulaessigeWerkzeuge[0] != "werkzeug-ok" {
|
||||||
|
t.Errorf("ZulaessigeWerkzeuge = %v, want [werkzeug-ok]", b.ZulaessigeWerkzeuge)
|
||||||
|
}
|
||||||
|
if len(b.AusgeschlosseneWerkzeuge) != 1 || b.AusgeschlosseneWerkzeuge[0].WerkzeugID != "werkzeug-ohne-avv" {
|
||||||
|
t.Fatalf("AusgeschlosseneWerkzeuge = %+v, want [werkzeug-ohne-avv]", b.AusgeschlosseneWerkzeuge)
|
||||||
|
}
|
||||||
|
if got := b.AusgeschlosseneWerkzeuge[0].NichtErfuellt; len(got) != 1 || got[0] != "avv_erforderlich" {
|
||||||
|
t.Errorf("NichtErfuellt = %v, want [avv_erforderlich]", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := getWithCookie(t, s, cookie, resp.Header().Get("Location"))
|
||||||
|
body := detail.Body.String()
|
||||||
|
if !strings.Contains(body, "ChatGPT Enterprise") || !strings.Contains(body, "Freies KI-Tool") {
|
||||||
|
t.Errorf("expected both werkzeuge (by name) on the detail page, got: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
236
internal/web/fachebene_handlers.go
Normal file
236
internal/web/fachebene_handlers.go
Normal 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)
|
||||||
|
}
|
||||||
216
internal/web/fachebene_handlers_test.go
Normal file
216
internal/web/fachebene_handlers_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,19 +89,67 @@ 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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireAdmin schützt die Mandanten-Verwaltung (Ebene 4 — Nutzer/
|
||||||
|
// Rollen für GENAU EINEN Mandanten, siehe CLAUDE.md). Wie bei
|
||||||
|
// requireBetreiber/requireFachebene: 404 statt 403 bei falscher Rolle.
|
||||||
|
func (s *Server) requireAdmin(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 != "admin" {
|
||||||
|
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
|
||||||
|
IsAdmin 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",
|
||||||
|
IsAdmin: role == "admin",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// currentUser liest den Nutzer, den requirePage/requireAPI in den
|
// currentUser liest den Nutzer, den requirePage/requireAPI in den
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
"github.com/netcell-it/deklarix/internal/store"
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,17 +40,46 @@ type Store interface {
|
|||||||
DeleteSession(ctx context.Context, token string) error
|
DeleteSession(ctx context.Context, token string) error
|
||||||
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
|
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
|
||||||
ListAuditLog(ctx context.Context, limit int) ([]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)
|
||||||
|
ListAntraegeForAccount(ctx context.Context, accountID string) ([]store.Antrag, error)
|
||||||
|
|
||||||
|
ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]store.Werkzeug, error)
|
||||||
|
GetWerkzeug(ctx context.Context, id string) (store.Werkzeug, error)
|
||||||
|
CurrentKatalogVersion(ctx context.Context) (string, error)
|
||||||
|
CreateBewertung(ctx context.Context, in store.BewertungInput) (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
|
||||||
|
// Bewertung eines Antrags braucht (siehe internal/rules). Ein eigener
|
||||||
|
// Typ statt drei einzelne Server-Felder, damit main.go sie an einer
|
||||||
|
// Stelle zusammenstellt.
|
||||||
|
type Regelwerk struct {
|
||||||
|
Datenklasse rules.DatenklasseRegelwerk
|
||||||
|
Einstufung rules.EinstufungRegelwerk
|
||||||
|
Anforderungen rules.AnforderungsRegelwerk
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server bündelt Routing und Abhängigkeiten der Web-Schicht.
|
// Server bündelt Routing und Abhängigkeiten der Web-Schicht.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
store Store
|
store Store
|
||||||
|
regelwerk Regelwerk
|
||||||
templates *template.Template
|
templates *template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer erstellt den Server.
|
// NewServer erstellt den Server. regelwerk kommt von rules.Load* und
|
||||||
func NewServer(st Store) (*Server, error) {
|
// wird einmal beim Start geladen, nicht pro Request.
|
||||||
|
func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
||||||
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
|
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("web: templates parsen: %w", err)
|
return nil, fmt.Errorf("web: templates parsen: %w", err)
|
||||||
@@ -57,6 +87,7 @@ func NewServer(st Store) (*Server, error) {
|
|||||||
|
|
||||||
s := &Server{
|
s := &Server{
|
||||||
store: st,
|
store: st,
|
||||||
|
regelwerk: regelwerk,
|
||||||
templates: tmpl,
|
templates: tmpl,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +99,16 @@ func NewServer(st Store) (*Server, error) {
|
|||||||
mux.HandleFunc("POST /login", s.handleLogin)
|
mux.HandleFunc("POST /login", s.handleLogin)
|
||||||
mux.HandleFunc("POST /logout", s.handleLogout)
|
mux.HandleFunc("POST /logout", s.handleLogout)
|
||||||
mux.HandleFunc("GET /{$}", s.requirePage(s.handleIndex))
|
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 /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 /verwaltung/nutzer", s.requireAdmin(s.handleNutzerListe))
|
||||||
|
mux.HandleFunc("GET /verwaltung/nutzer/neu", s.requireAdmin(s.handleNutzerNeuForm))
|
||||||
|
mux.HandleFunc("POST /verwaltung/nutzer", s.requireAdmin(s.handleNutzerCreate))
|
||||||
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))
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/auth"
|
"github.com/netcell-it/deklarix/internal/auth"
|
||||||
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
"github.com/netcell-it/deklarix/internal/store"
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
"github.com/netcell-it/deklarix/internal/web"
|
"github.com/netcell-it/deklarix/internal/web"
|
||||||
)
|
)
|
||||||
@@ -22,21 +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
|
||||||
|
antraege map[string]store.Antrag
|
||||||
|
werkzeuge map[string]store.Werkzeug
|
||||||
|
bewertungen map[string][]store.Bewertung // antragID -> Bewertungen, älteste zuerst
|
||||||
|
entscheidungen map[string][]store.Entscheidung // antragID -> Entscheidungen, älteste zuerst
|
||||||
}
|
}
|
||||||
|
|
||||||
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{},
|
||||||
|
antraege: map[string]store.Antrag{},
|
||||||
|
werkzeuge: map[string]store.Werkzeug{},
|
||||||
|
bewertungen: map[string][]store.Bewertung{},
|
||||||
|
entscheidungen: map[string][]store.Entscheidung{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,11 +181,185 @@ func (f *fakeStore) ListAuditLog(ctx context.Context, limit int) ([]store.AuditE
|
|||||||
return out, nil
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []store.Werkzeug
|
||||||
|
for _, w := range f.werkzeuge {
|
||||||
|
if w.AccountID == nil || *w.AccountID == accountID {
|
||||||
|
out = append(out, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetWerkzeug(ctx context.Context, id string) (store.Werkzeug, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
w, ok := f.werkzeuge[id]
|
||||||
|
if !ok {
|
||||||
|
return store.Werkzeug{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CurrentKatalogVersion(ctx context.Context) (string, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return fmt.Sprintf("fake-%d", len(f.werkzeuge)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateBewertung(ctx context.Context, in store.BewertungInput) (store.Bewertung, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
b := store.Bewertung{
|
||||||
|
ID: f.newID(), AntragID: in.AntragID,
|
||||||
|
Datenklasse: in.Datenklasse, DatenklasseHerleitung: in.DatenklasseHerleitung,
|
||||||
|
Einstufung: in.Einstufung, EinstufungHerleitung: in.EinstufungHerleitung,
|
||||||
|
Verboten: in.Verboten, Anforderungen: in.Anforderungen,
|
||||||
|
ZulaessigeWerkzeuge: in.ZulaessigeWerkzeuge, AusgeschlosseneWerkzeuge: in.AusgeschlosseneWerkzeuge,
|
||||||
|
RegelwerkVersion: in.RegelwerkVersion, KatalogVersion: in.KatalogVersion,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
f.bewertungen[in.AntragID] = append(f.bewertungen[in.AntragID], b)
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetLatestBewertungForAntrag(ctx context.Context, antragID string) (store.Bewertung, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
bs := f.bewertungen[antragID]
|
||||||
|
if len(bs) == 0 {
|
||||||
|
return store.Bewertung{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
// die auch main.go beim Start lädt. Handler-Tests laufen so gegen das
|
||||||
|
// tatsächliche Regelwerk statt gegen ein Test-Fixture, das getrennt
|
||||||
|
// von rules/ gepflegt werden müsste.
|
||||||
|
func loadTestRegelwerk(t *testing.T) web.Regelwerk {
|
||||||
|
t.Helper()
|
||||||
|
fsys := os.DirFS("../../rules")
|
||||||
|
dk, err := rules.LoadDatenklasse(fsys, "datenklasse.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadDatenklasse: %v", err)
|
||||||
|
}
|
||||||
|
ei, err := rules.LoadEinstufung(fsys, "kivo_einstufung.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadEinstufung: %v", err)
|
||||||
|
}
|
||||||
|
an, err := rules.LoadAnforderungen(fsys, "anforderungen.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadAnforderungen: %v", err)
|
||||||
|
}
|
||||||
|
return web.Regelwerk{Datenklasse: dk, Einstufung: ei, Anforderungen: an}
|
||||||
|
}
|
||||||
|
|
||||||
func newServer(t *testing.T, fs *fakeStore) *web.Server {
|
func newServer(t *testing.T, fs *fakeStore) *web.Server {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
s, err := web.NewServer(fs)
|
s, err := web.NewServer(fs, loadTestRegelwerk(t))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewServer: %v", err)
|
t.Fatalf("NewServer: %v", err)
|
||||||
}
|
}
|
||||||
@@ -208,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")
|
||||||
|
|||||||
@@ -384,6 +384,46 @@ button.entfernen {
|
|||||||
background: #fff;
|
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
|
/* Ab hier mehr Platz (Tablet/Desktop) — der Container bekommt spürbaren
|
||||||
Rand statt volle Breite, sonst bleibt alles identisch. */
|
Rand statt volle Breite, sonst bleibt alles identisch. */
|
||||||
@media (min-width: 640px) {
|
@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>
|
||||||
|
|
||||||
|
{{if .Bewertung}}
|
||||||
|
{{template "bewertung-block" .Bewertung}}
|
||||||
|
{{else}}
|
||||||
|
<p class="hinweis">Die Bewertung wird berechnet, sobald der Antrag eingereicht ist.</p>
|
||||||
|
{{end}}
|
||||||
|
</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}}
|
||||||
24
internal/web/templates/faelle_liste.html
Normal file
24
internal/web/templates/faelle_liste.html
Normal 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}}
|
||||||
60
internal/web/templates/fall_detail.html
Normal file
60
internal/web/templates/fall_detail.html
Normal 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">← 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}}
|
||||||
@@ -5,10 +5,10 @@
|
|||||||
{{template "nav" .Nav}}
|
{{template "nav" .Nav}}
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<h1>Willkommen bei Deklarix</h1>
|
<h1>Willkommen bei Deklarix</h1>
|
||||||
<p class="hinweis">
|
<ul class="admin-kacheln">
|
||||||
Der geführte Fragebogen für die Antragsprüfung folgt in der nächsten
|
<li><a href="/antraege/neu">Antrag stellen</a></li>
|
||||||
Ausbaustufe. Datenmodell, Regelwerk und Werkzeugkatalog stehen bereits.
|
<li><a href="/antraege">Meine Anträge</a></li>
|
||||||
</p>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -9,9 +9,74 @@
|
|||||||
{{define "nav"}}
|
{{define "nav"}}
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/">Start</a>
|
<a href="/">Start</a>
|
||||||
|
{{if not .IsBetreiber}}<a href="/antraege">Meine Anträge</a>{{end}}
|
||||||
|
{{if .IsFachebene}}<a href="/faelle">Posteingang</a>{{end}}
|
||||||
|
{{if .IsAdmin}}<a href="/verwaltung/nutzer">Nutzerverwaltung</a>{{end}}
|
||||||
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
|
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
|
||||||
<form method="post" action="/logout" style="display:inline">
|
<form method="post" action="/logout" style="display:inline">
|
||||||
<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}}
|
||||||
|
|||||||
17
internal/web/templates/nutzer_liste.html
Normal file
17
internal/web/templates/nutzer_liste.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{{define "nutzer-liste"}}<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>{{template "head" .}}</head>
|
||||||
|
<body>
|
||||||
|
{{template "nav" .Nav}}
|
||||||
|
<div class="page">
|
||||||
|
<h1>Nutzerverwaltung</h1>
|
||||||
|
<ul class="beitraege-liste">
|
||||||
|
{{range .Nutzer}}
|
||||||
|
<li>{{.Email}} <span class="status">{{.Role}}</span></li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
<p><a href="/verwaltung/nutzer/neu">Nutzer anlegen</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
33
internal/web/templates/nutzer_neu.html
Normal file
33
internal/web/templates/nutzer_neu.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{{define "nutzer-neu"}}<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>{{template "head" .}}</head>
|
||||||
|
<body>
|
||||||
|
{{template "nav" .Nav}}
|
||||||
|
<div class="page">
|
||||||
|
<p><a href="/verwaltung/nutzer">← Nutzerverwaltung</a></p>
|
||||||
|
<h1>Nutzer anlegen</h1>
|
||||||
|
<p class="hinweis">
|
||||||
|
Es gibt noch keine Einladungsmail — das hier gesetzte Passwort muss
|
||||||
|
auf einem anderen Weg an die neue Person weitergegeben werden.
|
||||||
|
</p>
|
||||||
|
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||||
|
<form method="post" action="/verwaltung/nutzer">
|
||||||
|
<label for="email">E-Mail</label>
|
||||||
|
<input type="email" id="email" name="email" required>
|
||||||
|
|
||||||
|
<label for="password">Initialpasswort (mind. 8 Zeichen)</label>
|
||||||
|
<input type="password" id="password" name="password" minlength="8" required>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Rolle</legend>
|
||||||
|
{{range .Rollen}}
|
||||||
|
<label><input type="radio" name="role" value="{{.}}" required> {{.}}</label>
|
||||||
|
{{end}}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<button type="submit">Nutzer anlegen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -35,24 +35,17 @@ das lässt offen, ob `auftragsdaten` (zwischen `intern` und
|
|||||||
ein (Kundendaten sollten aus denselben Gründen wie Geschäftsgeheimnisse
|
ein (Kundendaten sollten aus denselben Gründen wie Geschäftsgeheimnisse
|
||||||
nicht zum Training verwendet werden) — **Annahme, nicht bestätigt.**
|
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
|
~~Fragebogen-Abschnitt C stellt nur eine einzelne Frage...~~ Mit Phase 2
|
||||||
oder bewertet es Emotionen, Verhalten oder biometrische Merkmale?")
|
(Fragebogen, `internal/web/templates/antrag_neu.html`) gelöst: C3 hat
|
||||||
ohne Folgefragen, die zwischen den drei in der Bewertungslogik
|
jetzt eine Folgefrage `c3_art` mit den Werten
|
||||||
genannten Auslösern für "verboten" unterscheiden:
|
`emotionserkennung_arbeitsplatz`, `social_scoring`,
|
||||||
|
`biometrische_kategorisierung` und `keine` — exakt die Werte, die
|
||||||
- Emotionserkennung am Arbeitsplatz
|
`rules/kivo_einstufung.yaml` bereits erwartete. Feldnamen sind damit
|
||||||
- Social Scoring
|
final, nicht mehr Platzhalter. **Weiterhin offen:** ob diese drei
|
||||||
- Biometrische Kategorisierung
|
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.
|
||||||
`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.
|
|
||||||
|
|
||||||
## 4. `loeschfrist_max_tage` — konkrete Fristen je Datenklasse
|
## 4. `loeschfrist_max_tage` — konkrete Fristen je Datenklasse
|
||||||
|
|
||||||
@@ -61,4 +54,32 @@ Datenklasse", ohne die konkreten Tageswerte pro Stufe zu nennen.
|
|||||||
`rules/anforderungen.yaml` listet die Anforderung nur als grundsätzlich
|
`rules/anforderungen.yaml` listet die Anforderung nur als grundsätzlich
|
||||||
anwendbar (welche Datenklassen sie überhaupt auslösen), ohne
|
anwendbar (welche Datenklassen sie überhaupt auslösen), ohne
|
||||||
Zahlenwerte — die eigentlichen Fristen (z. B. "personenbezogen: 90
|
Zahlenwerte — die eigentlichen Fristen (z. B. "personenbezogen: 90
|
||||||
Tage") müssen vor Phase 3 fachlich festgelegt werden.
|
Tage") müssen vor Phase 3 fachlich festgelegt werden. **Auswirkung auf
|
||||||
|
Schritt 3 (`internal/rules.ErfuelltAnforderung`):** ohne Zahlenwerte
|
||||||
|
wird diese Anforderung aktuell NICHT hart gegen Werkzeuge gefiltert
|
||||||
|
(jedes Werkzeug gilt hier als "erfüllt") — sobald Fristen feststehen,
|
||||||
|
muss die Filterfunktion entsprechend erweitert werden.
|
||||||
|
|
||||||
|
## 5. Fallback, wenn keine Datenklasse zutrifft
|
||||||
|
|
||||||
|
Wenn ein Antrag bei allen B-Fragen "nein" beantwortet — auch bei B7
|
||||||
|
("nur allgemein zugängliche/erfundene Inhalte") — trifft laut Tabelle
|
||||||
|
keine Stufe zu (ein eigentlich widersprüchlicher Zustand: irgendeine
|
||||||
|
Kategorie sollte immer zutreffen). `internal/rules.EvaluateDatenklasse`
|
||||||
|
nimmt in diesem Fall konservativ `intern` an, nicht `oeffentlich` — im
|
||||||
|
Zweifel mehr Schutzanforderungen, nicht weniger. **Nicht fachlich
|
||||||
|
bestätigt**, nur eine sichere Standardannahme.
|
||||||
|
|
||||||
|
## 6. Welche Anforderungen werden hart gegen ein Werkzeug gefiltert?
|
||||||
|
|
||||||
|
`internal/rules.ErfuelltAnforderung` filtert aktuell hart nur
|
||||||
|
`avv_erforderlich`, `eu_verarbeitung` und `kein_training_auf_eingabe` —
|
||||||
|
das sind technische Eigenschaften eines Werkzeugs
|
||||||
|
(`werkzeug.avv_verfuegbar`, `verarbeitungsort`, `training_standard`).
|
||||||
|
`menschliche_aufsicht`, `kennzeichnungspflicht` und `dsfa_erforderlich`
|
||||||
|
betreffen dagegen einen Prozess rund um die Nutzung, nicht das Werkzeug
|
||||||
|
selbst — ein Werkzeug wird deswegen nicht aussortiert, das wird
|
||||||
|
stattdessen als Auflage vermerkt (geplant für Schritt 4/5). **Diese
|
||||||
|
Aufteilung ist eine Annahme von Schritt 3, nicht ausdrücklich in der
|
||||||
|
Spezifikation so festgelegt** — vor Schritt 4 gegenprüfen, ob das der
|
||||||
|
gewünschten Produktlogik entspricht.
|
||||||
|
|||||||
Reference in New Issue
Block a user