Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d2cb79424 | ||
|
|
4dd9bd88cb | ||
|
|
96ac5d6b59 | ||
|
|
29961f5657 | ||
|
|
3ec57f2786 | ||
|
|
6884f28109 | ||
|
|
b91a58ccba | ||
|
|
eb99891e38 | ||
|
|
36af1bf288 | ||
|
|
5f6502bb41 | ||
|
|
22f748d66c | ||
|
|
d52b325424 | ||
|
|
cff8c9eadf | ||
|
|
aad517ce23 | ||
|
|
d1e80dd19f | ||
|
|
e968cf9761 | ||
|
|
b1b121bb23 | ||
|
|
db9c5b7482 | ||
|
|
344a33805a | ||
|
|
b4d4ee8d3c | ||
|
|
6fd7831784 | ||
|
|
5813e6209c | ||
|
|
790ab20651 | ||
|
|
ae59e745c0 | ||
|
|
8e5d06aafb | ||
|
|
835ad9f0a7 |
747
CLAUDE.md
747
CLAUDE.md
@@ -1,102 +1,402 @@
|
||||
# Deklarix
|
||||
|
||||
> Prüfung und Nachweis für Werbe-Content. Pre-Publish-Kennzeichnungsprüfung
|
||||
> und revisionssichere Archivierung für Creator, Agenturen, Marken und
|
||||
> Kanzleien. `deklarix.de` / `deklarix.com`
|
||||
> KI-Antragsprüfung für Unternehmen: Mitarbeitende beschreiben ein
|
||||
> KI-Vorhaben in eigenen Worten, das System leitet Datenklasse,
|
||||
> KI-VO-Einstufung und Anforderungsprofil ab, gleicht sie gegen einen
|
||||
> gepflegten Werkzeugkatalog ab und erzeugt einen begründeten
|
||||
> Entscheidungsvorschlag. Ein Mensch entscheidet, das System bereitet
|
||||
> nur vor.
|
||||
|
||||
---
|
||||
|
||||
## Was das Projekt ist
|
||||
## Produktwechsel (2026-08-28)
|
||||
|
||||
Drei Funktionen, mehr nicht:
|
||||
|
||||
1. **Pre-Publish-Prüfung** — Caption, Standbild und Vertragslage rein,
|
||||
Risikobewertung mit konkreten Korrekturvorschlägen raus.
|
||||
2. **Beweissicherung** — jeder veröffentlichte Beitrag wird mit Hash und
|
||||
Zeitstempel archiviert: Screenshot, Caption, sichtbare Kennzeichnung,
|
||||
verknüpfter Vertrag, Briefing.
|
||||
3. **Verantwortungsmatrix** — wer hat was vorgegeben, wer hat freigegeben,
|
||||
wer schuldet welche Prüfung (Creator / Agentur / Marke).
|
||||
|
||||
**Wer nutzt es, wer zahlt:** Creator nutzen es täglich, zahlen wenig
|
||||
(Reichweite/Referenz). Agenturen und Marken zahlen richtig — sie haften
|
||||
über § 8 Abs. 2 UWG mit und brauchen Freigabe-Workflow plus
|
||||
Nachweisarchiv. Kanzleien lizenzieren Deklarix als Whitelabel für ihre
|
||||
Mandanten (zahlen für Software, nicht für Mandantenzugang); das
|
||||
Kanzlei-Verzeichnis im Produkt bleibt kostenlos.
|
||||
|
||||
**Rollen von Anfang an.** Eine Submission gehört nicht einem Nutzer,
|
||||
sondern hat Beteiligte mit Rollen (`creator`, `agentur`, `marke`,
|
||||
`kanzlei`). Alle vier existieren im Datenmodell ab dem ersten Commit.
|
||||
|
||||
**Kein Demo-Shortcut.** Es wird direkt produktionsreif entwickelt —
|
||||
keine reduzierte Zwischenstufe mit manuell eingetragenen Daten oder ohne
|
||||
Login als Selbstzweck.
|
||||
Deklarix war ursprünglich eine Pre-Publish-Kennzeichnungsprüfung für
|
||||
Werbe-Content (UWG/MStV) für Creator, Agenturen, Marken und Kanzleien.
|
||||
Dieser Scope wurde **komplett verworfen** und durch das oben
|
||||
beschriebene Produkt ersetzt — kein Feature-Zusatz, ein Produktwechsel.
|
||||
Wiederverwendet wurden nur produktunabhängige Bausteine: Auth/Session/
|
||||
Account-Mandantentrennung, das Prinzip "Regelwerk als versionierte
|
||||
YAML-Dateien, kein ML für die Bewertung", die apt/systemd/Gitea-
|
||||
Deployment-Pipeline und das Design-System. Alles Werberecht-Spezifische
|
||||
(Kennzeichnungsregeln, Beweisarchiv für Social-Media-Posts, Instagram/
|
||||
TikTok-OAuth, Insights-Erinnerung) wurde entfernt, nicht nur ersetzt —
|
||||
siehe Migration `0008_pivot_ki_antragspruefung` für den genauen Schnitt.
|
||||
|
||||
---
|
||||
|
||||
## Kernprinzip: Fakten trennen von Urteil
|
||||
## Grundregel
|
||||
|
||||
**Stufe 1 bestimmt Fakten (deterministisch, kein LLM). Das Regelwerk in
|
||||
Go fällt das Urteil.**
|
||||
**Das System entscheidet nicht. Es bereitet vor.** Der/die
|
||||
Verantwortliche bestätigt, ändert oder überschreibt einen
|
||||
Bewertungsvorschlag — mit Begründungsfeld bei Abweichung. Jede
|
||||
Entscheidung wird als Registereintrag protokolliert. Die Bewertung wird
|
||||
**nie** ohne ihre vollständige Herleitung angezeigt (welche Antwort
|
||||
welche Anforderung ausgelöst hat) — ohne das ist ein Vorschlag im Audit
|
||||
wertlos.
|
||||
|
||||
- **Stufe 1 — Extraktion (`internal/extract`, regelbasiert):** Ob eine
|
||||
Gegenleistung vorlag, kann aus dem Text nicht zuverlässig erraten
|
||||
werden — ein verschwiegener bezahlter Post sieht textlich identisch
|
||||
aus wie ein echter organischer Post. Deshalb gibt der/die Einreichende
|
||||
die Gegenleistung selbst an (Formularfeld). Was sich objektiv aus der
|
||||
Caption bestimmen lässt, wird per Zeichenketten-Logik erkannt: ob ein
|
||||
Kennzeichnungswort vorkommt, welcher Wortlaut, und ob es vor der
|
||||
plattformspezifischen "mehr anzeigen"-Kürzung steht. Kein externer
|
||||
Dienst, keine API-Kosten, kein Sprachmodell — daher auch keine
|
||||
Interpretation kreativer/impliziter Formulierungen; was nicht über ein
|
||||
bekanntes Kennzeichnungswort läuft, wird nicht erkannt.
|
||||
- **Stufe 2 — Bewertung (Go, deterministisch):** Regelwerk als
|
||||
versionierte YAML-Dateien (`rules/`). Jedes Finding referenziert
|
||||
Regel-ID und Version (z. B. `WK-004 v3`). Regeln sind
|
||||
plattformunabhängig — UWG/MStV gelten überall gleich, nur
|
||||
Darstellungsdetails unterscheiden sich; die gehören in
|
||||
Regelbedingungen/Extraktion, nie in separate Regelsätze pro Plattform.
|
||||
|
||||
**Absolute Regel:** Fundstellen stehen ausschließlich in den kuratierten
|
||||
YAML-Regeln, nie in Freitext oder Extraktionslogik erfunden. Eine
|
||||
halluzinierte Fundstelle ist in diesem Markt ein Totalschaden.
|
||||
|
||||
**Historie:** Ursprünglich war Stufe 1 als Claude-API-Aufruf geplant
|
||||
(Kennzeichnung *und* Gegenleistung vom Modell erraten). Das wurde
|
||||
bewusst verworfen — der Nutzer will keine laufende Anthropic-Abhängigkeit
|
||||
für Deklarix selbst (getrennt von Claude Code, mit dem an Deklarix
|
||||
programmiert wird). Die Idee "LLM sieht Kontext-Hinweise wie
|
||||
Rabattcodes/Affiliate-Links, die reines Keyword-Matching übersieht"
|
||||
bleibt ein späterer Ausbaupunkt, kein aktueller Blocker.
|
||||
Regelbasiert, nicht ML: die Ableitungsregeln (Datenklasse, KI-VO-
|
||||
Einstufung, Anforderungsprofil) liegen als versionierte YAML-Dateien
|
||||
vor, nicht im Code — ein fachlich Verantwortlicher soll sie anpassen
|
||||
können, ohne Go-Code anzufassen. Ein LLM ist v1 höchstens optional zur
|
||||
Zweckerkennung aus dem Freitext vorgesehen (Textgenerierung/Übersetzung/
|
||||
Code/Bild/Transkription/Recherche erkennen), nie für die Einstufung
|
||||
selbst — und auch dort nur als Vorschlag, den der Mensch bestätigt.
|
||||
|
||||
---
|
||||
|
||||
## Stack
|
||||
## Fünf Ebenen, fünf Rollen
|
||||
|
||||
Bewusst langweilig, ein Binary, lokal lauffähig:
|
||||
Die Oberfläche gliedert sich in fünf Ebenen mit steigender Sensibilität.
|
||||
Vier Mandanten-Rollen sind eine Voreinstellung, kein fest verdrahtetes
|
||||
Modell (spätere Ausbaustufe: konfigurierbare Rollen) — `betreiber` ist
|
||||
technisch getrennt vom Mandantenbereich, keine Voreinstellung.
|
||||
|
||||
| Bereich | Wahl |
|
||||
|---|---|
|
||||
| Sprache | Go 1.26, Standard-Library wo möglich |
|
||||
| Web | `net/http` + `html/template` + htmx, kein Frontend-Build, kein React |
|
||||
| Datenbank | PostgreSQL über `pgx`, Migrationen über `golang-migrate` |
|
||||
| PDF | serverseitig erzeugt |
|
||||
| Zeitstempel | RFC-3161 von einer öffentlichen TSA |
|
||||
| Extraktion | regelbasiert, `internal/extract`, keine externe Abhängigkeit |
|
||||
| Design | `design/enterprise.css` (Enterprise Light Theme, enconf-Pattern, Primärfarbe `#1677ff`) |
|
||||
| Ebene | Zugang | Rolle(n) | Zweck |
|
||||
|---|---|---|---|
|
||||
| 1 — Öffentlich | ohne Konto | — | Anmeldung, Passwort zurücksetzen, Einladung annehmen, Antrag über Mandantenlink |
|
||||
| 2 — Mitarbeiter | Konto nötig | `mitarbeiter` | Antrag stellen, eigene Anträge verfolgen, Rückfragen beantworten, freigegebene Werkzeuge einsehen. Bewusst schlank — dauert der Antrag zu lange, wird er umgangen |
|
||||
| 3 — Fachebene | Konto nötig | `verantwortlicher` (Entscheidungsrecht), `pruefer` (identische Sicht, kein Entscheidungsrecht) | Posteingang offener Anträge, Bewertungsvorschlag mit Herleitung, entscheiden, Register, Berichte/Exporte, Audit-Log lesend |
|
||||
| 4 — Mandanten-Verwaltung | Konto nötig | `admin` | Nutzer/Rollen, Abteilungen, Anmeldeverfahren, eigene Werkzeug-Freigaben/-Sperrungen, Rechnungsdaten — bezogen auf GENAU EINEN Mandanten |
|
||||
| 5 — Plattform | Konto nötig, technisch getrennt | `betreiber` | Werkzeugkatalog, Regelwerk-Versionierung, Mandantenverwaltung/Freischaltung, Nutzungssicht — Netcell-IT-Personal, plattformweit über alle Mandanten |
|
||||
|
||||
Kein Docker für die App selbst, kein React, keine Queue. Neue
|
||||
Abhängigkeiten nur nach Rückfrage.
|
||||
**Funktionstrennung bleibt bestehen**, auch wenn in kleinen Firmen
|
||||
dieselbe Person Admin und Verantwortlicher ist — das sind zwei Rollen
|
||||
auf demselben Menschen, keine verschmolzene Rolle. `admin` (Ebene 4,
|
||||
Mandanten-Verwaltung für den eigenen Account) und `betreiber` (Ebene 5,
|
||||
Plattform-Betrieb für Netcell-IT über alle Mandanten) sind nicht
|
||||
dasselbe, auch wenn beide "Admin"-artige Rechte haben — nur `betreiber`
|
||||
ist der Nachfolger dessen, was früher (vor dem Produktwechsel)
|
||||
`admin` hieß. **Praktische Umsetzung dieser Doppelrolle:** `admin`
|
||||
bekommt zusätzlich dieselben Fachebene-Rechte wie `verantwortlicher`
|
||||
(siehe `requireFachebene`/`hatEntscheidungsrecht`) — pragmatisch gelöst
|
||||
ohne Datenmodell-Umbau, siehe Offene Punkte weiter unten.
|
||||
|
||||
**Warum Postgres statt SQLite:** Das Produkt läuft von Anfang an für
|
||||
hunderte gleichzeitige Nutzer über mehrere Mandanten (Creator, Agentur,
|
||||
Marke, Kanzlei). Ein revisionssicheres Beweisarchiv braucht robuste
|
||||
Concurrent-Writes sowie Point-in-Time-Recovery/Replikation — das ist mit
|
||||
SQLites Single-Writer-Modell nicht sauber zu bauen. Gehostet wird
|
||||
Managed Postgres in der EU (DSGVO).
|
||||
Rechte werden **als Prüfung an jeder Aktion** durchgesetzt (Middleware
|
||||
je Handler), nicht als grob unterschiedene Seitenbereiche.
|
||||
|
||||
**Pfad:** `/var/www/deklarix` | **Git:** `https://git.netcell-it.de/projekte/deklarix` | **Branch:** `main`
|
||||
**Ebene 4 ist inzwischen zu großen Teilen umgesetzt** (Anspruch: alles,
|
||||
was im Datenmodell existiert, muss über das Frontend steuerbar sein,
|
||||
nicht nur einsehbar — Deklarix soll ein buchbarer Service werden, ein
|
||||
reiner Anzeige-Screen reicht nicht):
|
||||
|
||||
- **Nutzerverwaltung** (`internal/web/admin_handlers.go`, Middleware
|
||||
`requireAdmin`): `GET /verwaltung/nutzer` listet alle Logins des
|
||||
eigenen Mandanten mit Status (aktiv/deaktiviert), `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. Nutzer werden **nicht gelöscht**
|
||||
(`app_user` wird von `antrag`/`entscheidung`/`audit_log` per Foreign
|
||||
Key referenziert — ein Hard-Delete würde die Historie zerstören),
|
||||
sondern über `POST /verwaltung/nutzer/{id}/deaktivieren` bzw.
|
||||
`.../aktivieren` (de-)aktiviert (Spalte `app_user.active`, Migration
|
||||
0012). Ein deaktivierter Login kann sich nicht mehr anmelden
|
||||
(`handleLogin` prüft `Active` erst NACH der Passwortprüfung, um keine
|
||||
Kontoexistenz zu verraten) und verliert eine bereits laufende Sitzung
|
||||
sofort (`authenticate`-Middleware prüft `Active` bei jedem Request).
|
||||
Ein Admin kann sich nicht selbst deaktivieren (Aussperr-Schutz).
|
||||
- **Abteilungen** (`GET /verwaltung/abteilungen`, anlegen + löschen):
|
||||
reine Stammdaten für das Fragebogen-Feld "Abteilung" — ohne diese
|
||||
Seite blieb die Abteilung-Auswahl im Fragebogen faktisch leer und
|
||||
unbenutzbar, das war kein Komfort-, sondern ein Funktionsdefizit.
|
||||
- **Eigene Werkzeug-Sperrungen und -Einträge** (`internal/web/mandant_werkzeug_handlers.go`,
|
||||
`GET /verwaltung/werkzeuge`): ein Mandant kann einen zentralen
|
||||
Katalogeintrag für sich sperren/entsperren (`werkzeug_sperre`), ohne
|
||||
den zentralen Katalog selbst zu verändern — das bleibt Sache der
|
||||
Plattform (Ebene 5, `betreiber_werkzeug_handlers.go`). Zusätzlich kann
|
||||
ein Mandant eigene, nur für sich sichtbare Katalogeinträge anlegen/
|
||||
bearbeiten/löschen (`GET/POST /verwaltung/werkzeuge/neu`, `GET/POST
|
||||
/verwaltung/werkzeuge/{id}`, `POST /verwaltung/werkzeuge/{id}/loeschen`
|
||||
— `werkzeug.account_id` = der eigene Account). Nutzt dasselbe Formular
|
||||
wie der zentrale Katalog (`werkzeugFormData`/`betreiber-werkzeug-form.html`,
|
||||
ein `ActionBase`-Feld unterscheidet Betreiber- von Mandanten-Ziel-URL)
|
||||
— ein zentraler oder fremder Eintrag ist über diese Route nicht
|
||||
erreichbar (404).
|
||||
|
||||
**Weiterhin nicht gebaut:** Anmeldeverfahren-Konfiguration, Rechnungsdaten/
|
||||
Abrechnung (kein Abo-System, bewusst "Nicht bauen v1"), Account-
|
||||
Verwaltung durch den Betreiber (Ebene 5 zeigt Accounts nur lesend an —
|
||||
Bearbeiten/Sperren hängt an der noch nicht getroffenen Abrechnungs-/
|
||||
Freischaltungs-Architektur, siehe Offene Punkte: `account.verified`
|
||||
wurde beim Produktwechsel sogar entfernt).
|
||||
|
||||
**Mandantenfähigkeit:** jede Tabelle trägt `account_id`. Aktuell wird
|
||||
Isolation in der Anwendungsschicht erzwungen (Handler vergleichen
|
||||
`AccountID` gegen den angemeldeten Account, wie bisher) — die
|
||||
Zielvorgabe "Isolation auf Datenbankebene erzwungen" (Postgres Row-
|
||||
Level Security) ist eine bewusst noch nicht getroffene Entscheidung,
|
||||
siehe Offene Punkte unten.
|
||||
|
||||
---
|
||||
|
||||
## Fragebogen (Ebene 2, "Antrag stellen")
|
||||
|
||||
Adaptiv: Folgefragen erscheinen nur, wenn die Antwort sie auslöst.
|
||||
Alltagssprache, keine Fachbegriffe — die stehen nur in der Ableitung.
|
||||
|
||||
**A — Vorhaben:** `titel`, `beschreibung` (Freitext: was soll die KI
|
||||
tun?), `ergebnis` (Freitext: was soll herauskommen?), `haeufigkeit`
|
||||
(einmalig/gelegentlich/täglich/automatisiert), `abteilung` (aus
|
||||
Stammdaten).
|
||||
|
||||
**B — Daten** (bestimmt die Datenklasse). Mehrfachauswahl Ja/Nein/
|
||||
Unsicher, "Unsicher" zählt wie "Ja": Namen/E-Mail/Personenangaben (B1),
|
||||
Gesundheit/Religion/Herkunft/Gewerkschaft/Sexualleben (B2), Patienten-/
|
||||
Mandanten-/Versichertendaten (B3), Bewerbungs-/Personal-/
|
||||
Leistungsdaten Beschäftigter (B4), von Kunden anvertraute Daten (B5),
|
||||
Konstruktions-/Kalkulations-/Vertragsunterlagen (B6), nur allgemein
|
||||
zugängliche/erfundene Inhalte (B7).
|
||||
|
||||
**C — Verwendung des Ergebnisses** (bestimmt die KI-VO-Einstufung):
|
||||
geht das Ergebnis unverändert nach außen (C1)? beeinflusst es eine
|
||||
Entscheidung über einen Menschen (C2, Folgefrage: Einstellung/
|
||||
Beförderung/Kündigung, Kreditwürdigkeit, Leistungsbewilligung, Zugang
|
||||
zu Bildung, medizinische Beurteilung, keins davon)? erkennt/bewertet es
|
||||
Emotionen, Verhalten oder biometrische Merkmale (C3)? läuft es ohne
|
||||
menschliche Prüfung (C4)? merkt der Empfänger, dass es von einer KI
|
||||
stammt (C5)?
|
||||
|
||||
**D — Werkzeug:** ggf. schon ein Werkzeug im Sinn (optional, Freitext),
|
||||
Firmen- oder privater Zugang, Firmen- oder Privatgerät.
|
||||
|
||||
---
|
||||
|
||||
## Ableitungen (`rules/*.yaml`, `internal/rules`)
|
||||
|
||||
**Datenklasse** (`rules/datenklasse.yaml`) — höchste zutreffende Stufe
|
||||
gewinnt: `oeffentlich` (B7) < `intern` (B6) < `auftragsdaten` (B5) <
|
||||
`personenbezogen` (B1, B4) < `berufsgeheimnis` (B3, § 203 StGB) <
|
||||
`besondere_kategorie` (B2, Art. 9 DSGVO). **Die Rangfolge ist eine
|
||||
Annahme** (Spezifikation nennt keine expliziten Ränge) — siehe
|
||||
`rules/OPEN.md`, vor Produktiveinsatz mit dem/der Datenschutz-
|
||||
beauftragten bestätigen.
|
||||
|
||||
**KI-VO-Einstufung** (`rules/kivo_einstufung.yaml`) — Prüfreihenfolge,
|
||||
erste zutreffende Stufe gewinnt: `verboten` (Art. 5 KI-VO: C3 mit
|
||||
Emotionserkennung am Arbeitsplatz, Social Scoring oder biometrischer
|
||||
Kategorisierung) → `hochrisiko` (C2 mit Beschäftigung/Kreditwürdigkeit/
|
||||
Leistungsbewilligung/Bildung/Gesundheit) → `transparenzpflicht` (C1 ja
|
||||
und C5 nein) → `minimal` (alles übrige). Die genauen Fragebogen-Felder
|
||||
für die drei "verboten"-Varianten sind noch nicht abschließend
|
||||
spezifiziert — siehe `rules/OPEN.md`.
|
||||
|
||||
**Anforderungsprofil** (`rules/anforderungen.yaml`) — aus Datenklasse
|
||||
und/oder Einstufung abgeleitete Menge von Anforderungen an ein
|
||||
Werkzeug: `avv_erforderlich`, `eu_verarbeitung`,
|
||||
`kein_training_auf_eingabe`, `loeschfrist_max_tage`,
|
||||
`menschliche_aufsicht`, `kennzeichnungspflicht`, `dsfa_erforderlich`.
|
||||
|
||||
`internal/rules` lädt und validiert diese drei Dateien (eindeutige IDs,
|
||||
eindeutige Ränge, jede Anforderung braucht mindestens einen Auslöser)
|
||||
und wertet sie seit Schritt 3 auch aus: `EvaluateDatenklasse`,
|
||||
`EvaluateEinstufung`, `IstVerboten`, `DeriveAnforderungen` operieren auf
|
||||
`rules.Antworten` (geparst aus `antrag.antworten` via `ParseAntworten`)
|
||||
— 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."
|
||||
|
||||
---
|
||||
|
||||
## Werkzeugkatalog (`werkzeug`, `werkzeug_sperre`)
|
||||
|
||||
Der eigentliche Wert des Produkts. Zentral gepflegt (Ebene 5,
|
||||
`account_id IS NULL`), für alle Mandanten identisch sichtbar; jeder
|
||||
Mandant kann zusätzlich eigene Einträge (`account_id` gesetzt) und
|
||||
Sperrungen zentraler Einträge (`werkzeug_sperre`) führen, ohne den
|
||||
zentralen Katalog zu verändern.
|
||||
|
||||
**Pflicht:** `letzte_pruefung` und `quelle` sind NOT NULL — jede
|
||||
Zusicherung im Katalog (AVV verfügbar, Verarbeitungsort, Training-Opt-
|
||||
out, Zertifizierungen) muss belegbar sein, sonst haftet Deklarix für
|
||||
eine Aussage, die nicht nachweisbar ist. Einträge älter als 180 Tage
|
||||
sollen im Ergebnis als "nicht aktuell geprüft" markiert werden (noch
|
||||
nicht in der UI umgesetzt, siehe Offene Punkte).
|
||||
|
||||
`CurrentKatalogVersion` liefert eine reproduzierbare Kennung des
|
||||
aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird in
|
||||
jeder Bewertung/Entscheidung eingefroren, damit im Audit nachvollziehbar
|
||||
bleibt, mit welchem Katalogstand ein Vorschlag erzeugt wurde.
|
||||
|
||||
---
|
||||
|
||||
## Bewertungslogik (geplant, Schritt 3+)
|
||||
|
||||
1. **K.-o.-Prüfung.** Einstufung `verboten` → sofort Ablehnung (Art. 5
|
||||
KI-VO), keine Werkzeugsuche.
|
||||
2. **Harte Filter.** Werkzeuge aussortieren, die eine Pflichtanforderung
|
||||
nicht erfüllen — Grund je Werkzeug festhalten, auch aussortierte
|
||||
Werkzeuge werden im Ergebnis mit Begründung gezeigt.
|
||||
3. **Zweckabgleich.** Aus `beschreibung`/`ergebnis` den Zweck ableiten
|
||||
(Textgenerierung, Übersetzung, Code, Bild, Transkription, Recherche)
|
||||
und gegen `geeignete_zwecke` filtern.
|
||||
4. **Rangfolge.** Verbleibende Werkzeuge sortieren: EU-Verarbeitung,
|
||||
Training standardmäßig aus, kurze Aufbewahrung, Aktualität der
|
||||
Prüfung.
|
||||
5. **Auflagen ableiten.** Statt Ablehnung Bedingungen erzeugen
|
||||
(Pseudonymisierung, keine Klarnamen, Vier-Augen-Prüfung,
|
||||
Kennzeichnung, Aufbewahrung begrenzen).
|
||||
6. **Vorschlag erzeugen.** Empfehlung, Alternativen, Auflagen,
|
||||
vollständige Herleitung.
|
||||
|
||||
Jede Bewertung wird als eigene Zeile gespeichert (append-only, wie
|
||||
`finding`/`extraction` im alten Produkt) — eine neue Bewertung ersetzt
|
||||
nicht die alte, sie ergänzt sie, damit Nachvollziehbarkeit auch bei
|
||||
mehrfacher Neubewertung eines Antrags erhalten bleibt.
|
||||
|
||||
---
|
||||
|
||||
## Entscheidung, Register, Wiedervorlage (Schritt 5-7 erledigt)
|
||||
|
||||
**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 /
|
||||
abgelehnt / Rückfrage. **Vorschlag-Vergleich:** `vorschlagFuer` leitet
|
||||
aus der Bewertung ab, was das System vorschlagen würde (`abgelehnt` bei
|
||||
`verboten`, sonst `genehmigt` wenn mindestens ein zulässiges Werkzeug
|
||||
existiert, sonst `rueckfrage`) — weicht die tatsächliche Entscheidung
|
||||
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 (`gueltig_bis`: 12 Monate, bei
|
||||
`hochrisiko` 6 — `gueltigkeitFuer`).
|
||||
|
||||
**Schritt 7 (Wiedervorlage, Katalog-Benachrichtigung) ist umgesetzt**
|
||||
(`internal/web/wiedervorlage_handlers.go`, `GET /wiedervorlage`, Ebene
|
||||
3). Es gibt keine E-Mail-Infrastruktur — "Benachrichtigung" ist bewusst
|
||||
eine In-App-Liste, kein Versand. Eine aktive Genehmigung erscheint dort,
|
||||
wenn mindestens einer zutrifft: (1) `gueltig_bis` ist erreicht oder
|
||||
liegt innerhalb von 30 Tagen, (2) das zugesagte Werkzeug wurde aus dem
|
||||
Katalog entfernt, (3) `werkzeugDiff` erkennt eine Abweichung zwischen
|
||||
dem eingefrorenen `werkzeug_snapshot` und dem aktuellen Katalogeintrag
|
||||
bei AVV-Verfügbarkeit, Training-Standard oder Verarbeitungsort. Keine
|
||||
gefundene Abweichung → die Genehmigung erscheint nicht (kein stiller
|
||||
Blanko-Eintrag für jede Genehmigung). `store.ListAktiveGenehmigungenForAccount`
|
||||
liefert dafür alle `genehmigt`/`genehmigt_mit_auflagen`-Entscheidungen
|
||||
eines Mandanten (Join über `antrag.account_id`, da `entscheidung`
|
||||
selbst kein `account_id` trägt).
|
||||
|
||||
**Schritt 6 (Registereintrag, Export) ist umgesetzt.** Jede Genehmigung
|
||||
erzeugt automatisch einen `registereintrag` (append-only, Migration
|
||||
0011): Zweck (`antrag.titel`), Abteilung, Werkzeug, Datenklasse,
|
||||
Einstufung, Auflagen (die Anforderungen der Bewertung), Verantwortliche/r,
|
||||
Entscheidungsdatum, Gültigkeit — siehe `internal/web/register_handlers.go`.
|
||||
`GET /registereintraege` (Ebene 3, `verantwortlicher`/`pruefer`) zeigt
|
||||
sie, `GET /registereintraege/export.{csv,pdf}` exportiert. Abgelehnte
|
||||
Entscheidungen und Rückfragen erzeugen bewusst KEINEN Registereintrag —
|
||||
das Register ist eine Genehmigungsliste, kein vollständiges Antragslog
|
||||
(das leistet bereits `GET /faelle`).
|
||||
|
||||
---
|
||||
|
||||
## Onboarding
|
||||
|
||||
**Firma (Ebene 1, öffentlich, `POST /register`):** Registrierungsformular
|
||||
mit Firmendaten → Testzugang sofort aktiv → Freischaltung des
|
||||
Bezahlbetriebs nach Prüfung durch den Betreiber (letzteres noch nicht
|
||||
gebaut). Beim Anlegen entstehen automatisch: Account, erster Nutzer mit
|
||||
Rolle `admin` (siehe Rollentabelle — vereinfacht v1: nicht zusätzlich
|
||||
separat als `verantwortlicher` angelegt, siehe Offene Punkte),
|
||||
leere Abteilungsliste, Werkzeugkatalog aus der Plattform vorbelegt
|
||||
(noch nicht automatisiert — der zentrale Katalog ist aktuell leer, bis
|
||||
Ebene 5/Betreiber-Pflege gebaut ist).
|
||||
|
||||
**Mitarbeiter, geplante Reihenfolge:** (1) ~~Einladungslink~~ —
|
||||
**erledigt** (`internal/web/einladung_handlers.go`: `account.einladung_token`,
|
||||
Migration 0013, ein Sammellink pro Mandant statt Einmal-Token pro
|
||||
Person, kein Ablaufdatum, per Admin unter `GET /verwaltung/einladung`
|
||||
jederzeit erneuerbar [macht den alten Link sofort ungültig].
|
||||
`GET/POST /einladung/{token}` — öffentlich, Ebene 1 — legt einen Login
|
||||
mit Rolle `mitarbeiter` im Mandanten des Links an und meldet direkt an,
|
||||
ohne E-Mail-Bestätigung [noch nicht gebaut, siehe unten]. Die Abteilung
|
||||
wählt die Person weiterhin erst beim ersten Antrag, nicht bei der
|
||||
Einladung.), (2) ~~CSV-Import~~ — **erledigt** (`internal/web/csv_import_handlers.go`,
|
||||
`GET /verwaltung/nutzer/import`: CSV mit Kopfzeile, Pflichtspalte
|
||||
"email", optionale Spalte "role" [Standard `mitarbeiter`]. Wie beim
|
||||
einzelnen Nutzer-Anlegen gibt es keine Einladungsmail — pro Zeile wird
|
||||
ein Einmalpasswort erzeugt [wiederverwendet `auth.NewSessionToken()`],
|
||||
das NUR auf der Ergebnisseite direkt nach dem Import angezeigt wird und
|
||||
danach nirgendwo mehr abrufbar ist. Kein Alles-oder-nichts-Import:
|
||||
gültige Zeilen werden angelegt, eine ungültige Rolle oder bereits
|
||||
vergebene E-Mail in einer Zeile blockiert nicht die übrigen Zeilen —
|
||||
jede Zeile bekommt ihr eigenes Ergebnis [Passwort oder Fehlermeldung].),
|
||||
(3) SSO (Entra ID/Google Workspace), (4) Antrag ganz ohne vorheriges
|
||||
Konto über Mandantenlink + E-Mail-Bestätigungscode (Konto entsteht
|
||||
nebenbei). (3)-(4) sind weiterhin nicht gebaut.
|
||||
|
||||
**Abrechnung:** nach vom Kunden angegebener Mitarbeiterzahl, **nicht**
|
||||
nach angelegten Nutzern — sonst entsteht der Anreiz, Mitarbeitende
|
||||
nicht anzulegen. Noch nicht gebaut (kein Abo-System).
|
||||
|
||||
---
|
||||
|
||||
## Frontend
|
||||
|
||||
Anspruch (2026-08-29 explizit vom Nutzer bestätigt, da Deklarix ein
|
||||
buchbarer Service werden soll): **für Browser UND Mobile optimiert,
|
||||
und jede Entität im Datenmodell muss über das Frontend steuerbar sein**
|
||||
— eine reine Anzeige-Seite ohne CRUD ist ein Defizit, kein v1-Kompromiss.
|
||||
Stack bleibt `html/template` + htmx, kein React/Vue/SPA (siehe
|
||||
Baureihenfolge/Architektur-Entscheidungen) — die responsive Optik wird
|
||||
rein mit CSS erreicht, ohne das Template-Modell zu ändern.
|
||||
|
||||
**Navigation** (`internal/web/templates/layout.html`, `"nav"`-Block,
|
||||
einmal definiert und von jeder Seite eingebunden — Änderungen hier
|
||||
wirken auf das ganze Produkt): ein Checkbox-Hack (versteckte Checkbox +
|
||||
`<label for=...>` als Klick-Ziel) klappt auf dem Handy ein Menü auf,
|
||||
ganz ohne JavaScript. **Nicht** das naheliegendere `<details>`/`<summary>`
|
||||
verwenden — aktuelle Chromium-Versionen steuern dessen Auf-/Zuklapp-
|
||||
Zustand über eine interne, mit CSS (auch nicht mit `!important`)
|
||||
nicht überschreibbare Animation; ein Versuch, `.app-nav { display:
|
||||
flex !important }` bei geschlossenem `<details>` zu erzwingen, bleibt
|
||||
wirkungslos (mit Chromium-Headless-Screenshot verifiziert). Ab
|
||||
960px Breite (`@media (min-width: 960px)`, `app.css`) wird exakt
|
||||
dieselbe Markup-Struktur per CSS zu einer permanenten, dunklen
|
||||
Seitenleiste (Farb-Tokens aus `design/enterprise.css` übernommen) —
|
||||
kein separates Desktop-Template.
|
||||
|
||||
**Tabellen** (`table`/`th`/`td` in `app.css`) sind seit dieser Änderung
|
||||
gestylt (vorher komplett ungestylt, betraf v. a. `register_liste.html`);
|
||||
breite Tabellen stehen in einem `.table-scroll`-Wrapper (horizontales
|
||||
Scrollen auf dem Handy statt gequetschter Spalten).
|
||||
|
||||
**Verifikationsmethode für CSS-Änderungen:** Live-Cookie-Auth per
|
||||
Chromium-Headless/CDP ist im Sandbox-Environment nicht möglich (kein
|
||||
websocket-Python-Modul) — stattdessen: Seite per `curl -b cookies.txt`
|
||||
authentifiziert abrufen, `/static/...`-Pfade im HTML auf eine absolute
|
||||
`http://localhost:<port>/static/...`-URL umschreiben (Assets brauchen
|
||||
kein Auth-Cookie), dann `chromium --headless --disable-gpu --no-sandbox
|
||||
--window-size=<W>,<H> --screenshot=out.png file:///pfad/zur/seite.html`
|
||||
bei mobiler (z. B. 390×844) und Desktop-Breite (z. B. 1440×900)
|
||||
screenshotten. Für einen Menü-Aufklapp-Zustand: `checked`-Attribut
|
||||
manuell ins gespeicherte HTML einfügen vor dem Screenshot.
|
||||
|
||||
---
|
||||
|
||||
@@ -106,19 +406,18 @@ Managed Postgres in der EU (DSGVO).
|
||||
/var/www/deklarix/
|
||||
├── cmd/
|
||||
│ └── deklarix/
|
||||
│ └── main.go # Entry Point, HTTP-Server
|
||||
│ └── main.go # Entry Point: Migration, Regelwerk laden, HTTP-Server
|
||||
├── internal/
|
||||
│ ├── extract/ # regelbasierte Fakten-Erkennung (Keywords, Kürzungs-Position)
|
||||
│ ├── rules/ # YAML-Loader, Auswertung, Versionierung
|
||||
│ ├── evidence/ # Hashing, Zeitstempel, Append-only-Log
|
||||
│ ├── dossier/ # PDF-Erzeugung
|
||||
│ ├── auth/ # Passwort-Hashing, Session-Token (produktunabhängig)
|
||||
│ ├── rules/ # Lädt/validiert Datenklasse-/Einstufungs-/Anforderungs-YAML
|
||||
│ ├── store/ # Postgres, Migrationen
|
||||
│ └── web/ # Handler, Templates
|
||||
├── rules/ # YAML-Regeln, versioniert im Git
|
||||
├── testdata/
|
||||
│ └── golden/ # Golden-Tests: Beispielbeiträge + erwartetes Ergebnis
|
||||
├── rules/ # YAML-Regelwerk + OPEN.md (offene fachliche Fragen)
|
||||
├── design/
|
||||
│ └── enterprise.css # Design-System (enconf-Basis)
|
||||
│ └── enterprise.css # enconf Ant-Design-Theme — Referenz für Design-Tokens
|
||||
│ (Farben/Radius/Schatten), nicht direkt eingebunden
|
||||
│ (Ant-Design-Klassennamen passen nicht zu Deklarix'
|
||||
│ reinem HTML/htmx-Stack, siehe internal/web/static/app.css)
|
||||
├── packaging/
|
||||
│ └── DEBIAN/
|
||||
│ └── control.tmpl # .deb Package-Control-Template
|
||||
@@ -135,48 +434,104 @@ Managed Postgres in der EU (DSGVO).
|
||||
|
||||
## Datenmodell
|
||||
|
||||
Plus drei Tabellen für Auth/Mandantentrennung (`account`, `app_user`,
|
||||
`session`), mehr braucht der MVP nicht:
|
||||
- `account` — ein Mandant (ein Unternehmen). Jeder Antrag gehört genau
|
||||
einem Account.
|
||||
- `app_user` — ein Login innerhalb eines Accounts. Rolle ist eine von
|
||||
`mitarbeiter`, `verantwortlicher`, `pruefer`, `admin` (alle vier
|
||||
Mandanten-bezogen) oder `betreiber` (plattformweit, Ebene 5).
|
||||
- `session` — eine angemeldete Sitzung (Token, Ablaufzeit); echte
|
||||
Tabelle statt zustandsloser signierter Tokens, damit Logout eine
|
||||
Sitzung wirklich beendet.
|
||||
- `audit_log` — Protokoll (append-only): jede Antragsänderung, jede
|
||||
Bewertung, jede Entscheidung, jede Katalogänderung mit Zeitstempel
|
||||
und Akteur.
|
||||
- `abteilung` — Stammdaten für Fragebogen-Feld A.abteilung, je Mandant.
|
||||
- `werkzeug` — Katalogeintrag (siehe oben); `account_id` NULL = zentral,
|
||||
gesetzt = mandantenspezifische Ergänzung. NICHT append-only — der
|
||||
Katalog wird gepflegt, eine Entscheidung friert den zu ihrem
|
||||
Zeitpunkt gültigen Datensatz separat ein (geplant).
|
||||
- `werkzeug_sperre` — Sperrung eines (auch zentralen) Katalogeintrags
|
||||
durch einen einzelnen Mandanten.
|
||||
- `antrag` — das Vorhaben aus Fragebogen-Abschnitt A plus die
|
||||
vollständigen Antworten aus B/C/D als JSON (`antworten`, JSONB) — ein
|
||||
starres Spaltenschema könnte den adaptiven Fragebogen nicht abbilden.
|
||||
NICHT append-only (normale Zustandsänderung `entwurf` →
|
||||
`eingereicht` → `entschieden`, wie `submission` es im alten Produkt
|
||||
war).
|
||||
- `bewertung` — der berechnete Vorschlag (Schritt 4).
|
||||
- `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` — automatisch bei jeder Genehmigung erzeugt
|
||||
(Schritt 6), denormalisierter Nachweis (eigene Textspalten statt
|
||||
Joins über bewertung/entscheidung/werkzeug).
|
||||
|
||||
- `account` — ein Mandant (Creator, Agentur, Marke oder Kanzlei als
|
||||
eigene Organisation); jede Submission gehört genau einem Account
|
||||
- `app_user` — ein Login innerhalb eines Accounts (E-Mail, Passwort-
|
||||
Hash, Rolle)
|
||||
- `session` — eine angemeldete Sitzung (Token, Ablaufzeit); bewusst
|
||||
eine echte Tabelle statt zustandsloser signierter Tokens, damit
|
||||
Logout eine Sitzung wirklich beendet
|
||||
- `submission` — ein eingereichter Beitrag, Status, Zeitpunkte
|
||||
- `asset` — Bild oder Datei, Pfad, SHA-256
|
||||
- `extraction` — das JSON aus Stufe 1, Modellversion, Prompt-Version
|
||||
- `finding` — Ergebnis pro Regel: Regel-ID, Regel-Version, Schwere,
|
||||
Titel, Korrektur, Fundstellen (zum Zeitpunkt des Findings fixiert,
|
||||
kein Verweis auf die aktuelle Regel-YAML — die kann sich inzwischen
|
||||
geändert haben)
|
||||
- `evidence_package` — Dossier, Hash, Zeitstempel-Token, erzeugt am
|
||||
- `participant` — Beteiligter an einer Submission mit Rolle
|
||||
(`creator`, `agentur`, `marke`, `kanzlei`) und Beitrag zur
|
||||
Verantwortungsmatrix (wer hat vorgegeben, wer freigegeben)
|
||||
**Append-only:** kein UPDATE auf `audit_log` (Trigger `forbid_update_delete`,
|
||||
wiederverwendet aus dem alten Produkt), ebenso `bewertung`,
|
||||
`entscheidung` und `registereintrag`.
|
||||
|
||||
**Append-only.** Kein UPDATE auf `finding`, `extraction` oder
|
||||
`evidence_package`. Korrekturen sind neue Zeilen mit Verweis auf die alte.
|
||||
Ein Beweisarchiv, in dem man Zeilen ändern kann, ist kein Beweisarchiv.
|
||||
---
|
||||
|
||||
**Beweiskette:** SHA-256 über jedes Asset und über die kanonisierte
|
||||
JSON-Repräsentation der Metadaten, RFC-3161-Zeitstempel über diesen Hash.
|
||||
Dossier-PDF enthält Beitrag, Caption, Kennzeichnung, Findings mit
|
||||
Fundstellen, Vertrags- und Briefing-Bezug, Verantwortungsmatrix, alle
|
||||
Hashes und das Zeitstempel-Token. Vollständiger Export muss für den
|
||||
Nutzer jederzeit möglich sein — wer kündigt, bekommt sein Archiv.
|
||||
## Baureihenfolge
|
||||
|
||||
**Auth/Mandantentrennung:** `POST /register` legt einen neuen Account
|
||||
plus den ersten Nutzer darin an, `POST /login` meldet einen bestehenden
|
||||
Nutzer an — beides setzt ein `deklarix_session`-Cookie (HttpOnly,
|
||||
SameSite=Strict, Secure sobald über TLS erreicht). `GET /` verlangt eine
|
||||
gültige Sitzung (sonst Redirect zu `/login`); `POST /pruefen`,
|
||||
`POST /veroeffentlichen` und `GET /dossier/{id}` verlangen sie ebenfalls
|
||||
(sonst 401). Ein Beitrag eines fremden Accounts wird wie ein nicht
|
||||
existierender behandelt (404), nie mit einer expliziten 403 bestätigt —
|
||||
sonst würde die Antwort selbst verraten, dass die ID existiert.
|
||||
1. ~~Datenmodell, Regelwerk als YAML, Katalogstruktur~~ — **erledigt**
|
||||
(dieser Stand: `account`/`app_user`/`session`/`audit_log`/
|
||||
`abteilung`/`werkzeug`/`werkzeug_sperre`/`antrag` in Postgres,
|
||||
`internal/rules` lädt und validiert die drei YAML-Regelwerke,
|
||||
Web-Layer kompiliert mit Auth + Plattform-Bereich-Gerüst).
|
||||
2. ~~Fragebogen mit adaptiver Logik~~ — **erledigt** (`GET /antraege/neu`
|
||||
einseitiges Formular, `POST /antraege` legt an und reicht direkt ein,
|
||||
`GET /antraege` eigene Anträge, `GET /antraege/{id}` Detail. Adaptive
|
||||
Folgefragen C2/C3 rein per CSS `:has()` ein-/ausgeblendet, kein
|
||||
JavaScript. `antworten`-JSON nutzt exakt die Fakten-Schlüssel aus
|
||||
`rules/*.yaml` (b1-b7, c1-c5, c2_folge, c3_art) — das hat auch die
|
||||
OPEN.md-Frage zu den "verboten"-Fragebogen-Feldern final geklärt.)
|
||||
3. ~~Ableitungen und harte Filter~~ — **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~~ — **erledigt** (`internal/web/register_handlers.go`:
|
||||
`GET /registereintraege` zeigt alle Registereinträge des Mandanten,
|
||||
`GET /registereintraege/export.{csv,pdf}` exportiert sie. Jede
|
||||
Genehmigung — `genehmigt` oder `genehmigt_mit_auflagen` — erzeugt in
|
||||
`handleFallEntscheiden` automatisch einen `registereintrag`
|
||||
[append-only, Migration 0011]: Zweck [`antrag.titel`], Abteilung,
|
||||
Werkzeug, Datenklasse, Einstufung, Auflagen [die Anforderungen der
|
||||
Bewertung], Verantwortliche/r, Entscheidungsdatum, Gültigkeit —
|
||||
bewusst denormalisiert [eigene Textspalten statt Joins], damit ein
|
||||
Eintrag nicht von einem späteren Katalog-/Abteilungs-Wandel abhängt.
|
||||
Abgelehnte Entscheidungen und Rückfragen erzeugen keinen Eintrag.
|
||||
Der PDF-Export nutzt das bereits vorhandene, bis dahin ungenutzte
|
||||
`go-pdf/fpdf` [keine neue Abhängigkeit] mit `UnicodeTranslatorFromDescriptor`
|
||||
für deutsche Umlaute.)
|
||||
7. ~~Wiedervorlage und Katalog-Benachrichtigung~~ — **erledigt**
|
||||
(`GET /wiedervorlage`: abgelaufene/bald ablaufende Genehmigungen und
|
||||
Genehmigungen, deren Werkzeug sich seither im Katalog geändert hat
|
||||
oder entfernt wurde. In-App-Liste, kein E-Mail-Versand.)
|
||||
|
||||
Nicht bauen (v1): automatische Genehmigung ohne Mensch, Erkennung
|
||||
tatsächlicher Werkzeug-Nutzung, Mitarbeiterüberwachung (nichts, was
|
||||
Nutzung einzelner Personen auswertet), konfigurierbare Rollen mit
|
||||
eigener Oberfläche, Konzernstrukturen mit Vererbung, mandantenspezifische
|
||||
Regelwerke, Workflow-Designer, SAML, Schnittstellen zu Fremdsystemen.
|
||||
|
||||
---
|
||||
|
||||
@@ -297,56 +652,23 @@ zieht dafür aber **kein** eigenes Postgres hoch (kein Docker-Zwang auf
|
||||
dem lokalen Dev-Rechner) — das ist Aufgabe des dedizierten Testsystems,
|
||||
dort muss `DATABASE_URL` vorkonfiguriert sein.
|
||||
|
||||
**Golden-Test-Suite ist das eigentliche Asset des Projekts, nicht die
|
||||
UI.** 30 bis 50 Beispielbeiträge in `testdata/golden/`, abgeleitet aus
|
||||
veröffentlichten Urteilen und Beanstandungen der Medienanstalten, jeweils
|
||||
mit erwarteten Findings. Jede Regeländerung läuft gegen die komplette
|
||||
Suite. Eine Regeländerung, die ein Golden-Ergebnis kippt, wird nicht
|
||||
gemerged, ohne dass der erwartete Wert bewusst und dokumentiert angepasst
|
||||
wird. Extraktion wird separat getestet: fixierte Eingaben, geprüfte
|
||||
JSON-Ausgabe.
|
||||
|
||||
---
|
||||
|
||||
## Leitplanken, die dauerhaft gelten
|
||||
|
||||
- **Keine Rechtsdienstleistung.** Deklarix ist ein Werkzeug, keine
|
||||
Rechtsberatung. Keine Formulierung darf eine individuelle rechtliche
|
||||
Bewertung suggerieren. Auf jedem Screen mit einem Ergebnis steht ein
|
||||
entsprechender Hinweis. Keine Erfolgsaussichten-Aussagen.
|
||||
- **Keine Versicherungssprache.** Nie „Abmahnschutz“, „abgesichert“,
|
||||
„geschützt“, „Kostenübernahme“ — sonst erlaubnispflichtiges
|
||||
Versicherungsgeschäft. Immer „Prüfung“, „Hinweis“, „Dokumentation“,
|
||||
„Nachweis“.
|
||||
- **Keine erfundenen Fundstellen.** Jede Norm und jedes Urteil steht in
|
||||
einer Regel-YAML und wurde dort von Hand geprüft.
|
||||
- **Nur eigene Inhalte.** Kein Scraping fremder Accounts, keine fremden
|
||||
Posts als Demo-Material. Demo-Material ist synthetisch. Nutzerinhalte
|
||||
kommen über eigenen OAuth-Zugang oder Datenexport.
|
||||
- **DSGVO und Hosting in der EU.** Löschkonzept von Anfang an mitdenken,
|
||||
aber Beweisarchiv und Löschpflicht sauber gegeneinander abgrenzen
|
||||
(offener Punkt, siehe unten).
|
||||
- **Berufsrecht der Anwälte (§ 49b Abs. 3 S. 1 BRAO).** Kein Routing
|
||||
(kein Button, der einen Fall an eine Kanzlei übergibt — Nutzer wählt
|
||||
selbst aus dem Verzeichnis), kein ergebnisabhängiger Preis, keine
|
||||
Sachvorteile an gelistete Kanzleien, Verzeichnis bleibt kostenlos
|
||||
(BGH IX ZR 89/23: entgeltliche Mandatsvermittlung ist nichtig). Vor dem
|
||||
ersten Kanzlei-Vertrag: formlose Auskunft der zuständigen
|
||||
Rechtsanwaltskammer einholen.
|
||||
|
||||
---
|
||||
|
||||
## Domains
|
||||
|
||||
| Domain | Verwendung |
|
||||
|--------|-----------|
|
||||
| deklarix.de | Primär (Marketing-Site) |
|
||||
| app.deklarix.de | Die Anwendung selbst (dieser Go-Server) |
|
||||
| deklarix.com | Redirect auf deklarix.de |
|
||||
| get.deklarix.com | Installer-Endpoint (apt-Einrichtung, siehe Deployment) |
|
||||
|
||||
Kein separates `app.deklarix.com` — der Root-Redirect von `.com` auf
|
||||
`.de` deckt Fehlnavigation ab, keine zweite Produktions-Subdomain nötig.
|
||||
- **Keine Rechtsdienstleistung.** Deklarix bereitet eine Einstufung vor,
|
||||
entscheidet nicht. Keine Formulierung darf eine individuelle
|
||||
rechtliche Bewertung suggerieren oder Erfolgsaussichten behaupten.
|
||||
- **Keine erfundenen Einstufungen.** Jede Ableitungsregel steht in einer
|
||||
Regel-YAML und wurde dort von Hand geprüft; offene fachliche Fragen
|
||||
stehen in `rules/OPEN.md`, werden nicht geraten.
|
||||
- **Werkzeugkatalog-Zusicherungen müssen belegbar sein.** `quelle` und
|
||||
`letzte_pruefung` sind Pflichtfelder, keine Ausnahme.
|
||||
- **Keine Mitarbeiterüberwachung.** Nichts im Produkt darf auswerten,
|
||||
welche einzelne Person welches Werkzeug wie oft nutzt — das
|
||||
widerspricht dem Vertrauensmodell, auf dem die freiwillige
|
||||
Antragstellung beruht.
|
||||
- **DSGVO und Hosting in der EU.**
|
||||
|
||||
---
|
||||
|
||||
@@ -370,8 +692,7 @@ git push origin main
|
||||
sudo systemctl start deklarix
|
||||
sudo systemctl status deklarix
|
||||
|
||||
# Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, RULES_DIR,
|
||||
# DOSSIER_DIR, TSA_URL)
|
||||
# Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, RULES_DIR)
|
||||
|
||||
# Logs prüfen
|
||||
journalctl -u deklarix -f
|
||||
@@ -390,16 +711,11 @@ journalctl -u deklarix -f
|
||||
## Arbeitsweise für Claude Code
|
||||
|
||||
- Kleine Commits, ein Thema pro Commit
|
||||
- Erst Test oder Golden-Fall, dann Implementierung
|
||||
- Erst Test, dann Implementierung
|
||||
- Keine neue Abhängigkeit ohne Rückfrage
|
||||
- Keine Umbenennung von Regel-IDs; Regeln werden versioniert, nicht
|
||||
ersetzt
|
||||
- Fehler nicht verschlucken, keine stillen Fallbacks bei der Extraktion
|
||||
- Bei Unsicherheit über eine Rechtsfrage: nicht raten, sondern als offene
|
||||
Frage in `rules/OPEN.md` notieren
|
||||
- Der Code-RAG dient der Orientierung im Bestand, nicht als Nachweis von
|
||||
Korrektheit. Verifiziert wird durch Compiler, Tests und die
|
||||
Golden-Suite.
|
||||
- Fehler nicht verschlucken, keine stillen Fallbacks
|
||||
- Bei fachlicher Unsicherheit: nicht raten, sondern als offene Frage in
|
||||
`rules/OPEN.md` notieren
|
||||
|
||||
## Vor Änderungen
|
||||
|
||||
@@ -418,13 +734,56 @@ journalctl -u deklarix -f
|
||||
|
||||
## Offene Punkte
|
||||
|
||||
- Umfang des Regelsatzes für den ersten Pilotkunden festlegen
|
||||
- **TSA für die Zeitstempel:** interimsweise FreeTSA.org (frei, RFC-3161-
|
||||
konform, siehe `internal/evidence.DefaultTSAURL`) — funktioniert
|
||||
technisch, ist aber **nicht eIDAS-qualifiziert**. Vor echtem
|
||||
Kundeneinsatz auf einen eIDAS-qualifizierten Zeitstempeldienst (z. B.
|
||||
D-Trust, Bundesdruckerei) umstellen, der die gesetzliche
|
||||
Vermutungswirkung nach eIDAS Art. 41 hat — dafür ist ein Vertrag/Account
|
||||
nötig, kein anonymer HTTP-Call.
|
||||
- Löschkonzept versus Beweisarchiv sauber ausformulieren
|
||||
- Markenanmeldung Deklarix, sobald erster Kunde zahlt
|
||||
- ~~Rangfolge der Datenklassen~~ (`rules/OPEN.md`, Punkt 1) — **am
|
||||
2026-08-29 vom Produktverantwortlichen bestätigt**, keine Änderung
|
||||
nötig.
|
||||
- ~~Anforderung `kein_training_auf_eingabe` und `auftragsdaten`~~
|
||||
(`rules/OPEN.md`, Punkt 2) — **bestätigt**, `auftragsdaten` bleibt
|
||||
eingeschlossen.
|
||||
- ~~Genaue Fragebogen-Felder für die drei "verboten"-Varianten~~ —
|
||||
**erledigt in Schritt 2**, C3 hat jetzt eine Folgefrage `c3_art`. Ob
|
||||
die drei Varianten fachlich vollständig sind, wurde am 2026-08-29
|
||||
ebenfalls **bestätigt** (siehe `rules/OPEN.md`, Punkt 3).
|
||||
- ~~Fallback ohne zutreffende Datenklasse~~ (`rules/OPEN.md`, Punkt 5)
|
||||
— **bestätigt**, `intern` bleibt die konservative Standardannahme.
|
||||
- **Welche Anforderungen hart gegen ein Werkzeug gefiltert werden**
|
||||
(`rules/OPEN.md`, Punkt 6 — `avv_erforderlich`/`eu_verarbeitung`/
|
||||
`kein_training_auf_eingabe` hart, `menschliche_aufsicht`/
|
||||
`kennzeichnungspflicht`/`dsfa_erforderlich` nur als Auflage vermerkt)
|
||||
— weiterhin nicht ausdrücklich bestätigt, aber plausibel, keine
|
||||
Rückmeldung dazu bisher eingeholt.
|
||||
- **Löschfristen je Datenklasse** (konkrete Tageswerte für
|
||||
`loeschfrist_max_tage`) — noch nicht fachlich festgelegt.
|
||||
- **Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)**
|
||||
— die Frontend-Spezifikation fordert das explizit ("nicht in der
|
||||
Anwendungslogik"), aktuell ist Isolation ausschließlich
|
||||
anwendungsseitig (Handler-Vergleich `AccountID`). Umstellung auf RLS
|
||||
ist ein bewusst noch nicht getroffener Architektur-Entscheid —
|
||||
Aufwand und Zeitpunkt mit dem Nutzer klären, bevor mehr Tabellen
|
||||
entstehen, die sonst nachträglich migriert werden müssten.
|
||||
- ~~"Admin und KI-Verantwortlicher" beim Firma-Onboarding~~ — **pragmatisch
|
||||
gelöst, kein Datenmodell-Umbau:** `app_user.role` bleibt ein einzelner
|
||||
Wert (kein `roles`-Array, keine zwei `app_user`-Zeilen pro Person).
|
||||
Stattdessen bekommt die Rolle `admin` in `requireFachebene` (Ebene 3)
|
||||
dieselben Rechte wie `verantwortlicher` — sieht Posteingang/Register/
|
||||
Wiedervorlage und darf entscheiden (`hatEntscheidungsrecht` in
|
||||
`fachebene_handlers.go`). Grund: ohne das könnte eine frisch
|
||||
registrierte Firma mit nur einem `admin`-Login keinen einzigen
|
||||
eingereichten Antrag sehen oder bearbeiten — der Kernablauf wäre für
|
||||
Einzelpersonen-/Kleinfirmen-Onboarding komplett blockiert. `pruefer`
|
||||
bleibt unverändert nur lesend, ohne Entscheidungsrecht. Kompromiss statt
|
||||
"sauberer" Lösung — falls künftig eine Firma admin und verantwortlicher
|
||||
bewusst auf zwei verschiedene Personen verteilen will, funktioniert das
|
||||
weiterhin unverändert (zwei separate Logins mit den jeweiligen Rollen).
|
||||
- **Zentraler Werkzeugkatalog muss noch befüllt werden** — die
|
||||
Ebene-5/Betreiber-UI zur Katalogpflege ist jetzt gebaut (`GET
|
||||
/betreiber/werkzeuge` Liste, `GET/POST /betreiber/werkzeuge/neu`
|
||||
anlegen, `GET/POST /betreiber/werkzeuge/{id}` bearbeiten, `POST
|
||||
.../loeschen` löschen — siehe `internal/web/betreiber_werkzeug_handlers.go`.
|
||||
Bearbeitet ausschließlich `account_id IS NULL`-Einträge, ein
|
||||
mandantenspezifischer Eintrag ist über diese Seiten nicht erreichbar
|
||||
[404]). "Werkzeugkatalog aus der Plattform vorbelegt" beim Firma-
|
||||
Onboarding bleibt trotzdem nicht automatisiert — der Katalog ist
|
||||
inhaltlich weiterhin leer, bis der Betreiber tatsächlich Einträge
|
||||
pflegt.
|
||||
- Markenanmeldung Deklarix, sobald erster Kunde zahlt.
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/evidence"
|
||||
"github.com/netcell-it/deklarix/internal/extract"
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
"github.com/netcell-it/deklarix/internal/web"
|
||||
@@ -29,26 +27,33 @@ func main() {
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
extractor := extract.NewEngine()
|
||||
|
||||
rulesDir := os.Getenv("RULES_DIR")
|
||||
if rulesDir == "" {
|
||||
rulesDir = "rules"
|
||||
}
|
||||
ruleSet, err := rules.Load(os.DirFS(rulesDir))
|
||||
rulesFS := os.DirFS(rulesDir)
|
||||
// Beim Start laden und validieren, auch wenn die Auswertungslogik
|
||||
// (Phase 3 der Baureihenfolge) sie noch nicht konsumiert — ein
|
||||
// kaputtes Regelwerk soll den Dienst nicht erst beim ersten Antrag
|
||||
// zum Absturz bringen, sondern gar nicht erst starten lassen.
|
||||
datenklasse, err := rules.LoadDatenklasse(rulesFS, "datenklasse.yaml")
|
||||
if err != nil {
|
||||
log.Fatalf("load rules from %s: %v", rulesDir, err)
|
||||
log.Fatalf("load datenklasse-regelwerk from %s: %v", rulesDir, err)
|
||||
}
|
||||
log.Printf("%d Regeln aus %s geladen", len(ruleSet), rulesDir)
|
||||
|
||||
timestamper := evidence.NewHTTPTimestamper(os.Getenv("TSA_URL"))
|
||||
|
||||
dossierDir := os.Getenv("DOSSIER_DIR")
|
||||
if dossierDir == "" {
|
||||
dossierDir = "dossiers"
|
||||
einstufung, err := rules.LoadEinstufung(rulesFS, "kivo_einstufung.yaml")
|
||||
if err != nil {
|
||||
log.Fatalf("load ki-vo-einstufung-regelwerk from %s: %v", rulesDir, err)
|
||||
}
|
||||
anforderungen, err := rules.LoadAnforderungen(rulesFS, "anforderungen.yaml")
|
||||
if err != nil {
|
||||
log.Fatalf("load anforderungsprofil-regelwerk from %s: %v", rulesDir, err)
|
||||
}
|
||||
log.Printf("Regelwerk geladen: %d Datenklassen, %d KI-VO-Stufen, %d Anforderungen (aus %s)",
|
||||
len(datenklasse.Stufen), len(einstufung.Stufen), len(anforderungen.Anforderungen), rulesDir)
|
||||
|
||||
server, err := web.NewServer(extractor, ruleSet, db, timestamper, dossierDir)
|
||||
server, err := web.NewServer(db, web.Regelwerk{
|
||||
Datenklasse: datenklasse, Einstufung: einstufung, Anforderungen: anforderungen,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("web server: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
!00 U+0000 .notdef
|
||||
!01 U+0001 .notdef
|
||||
!02 U+0002 .notdef
|
||||
!03 U+0003 .notdef
|
||||
!04 U+0004 .notdef
|
||||
!05 U+0005 .notdef
|
||||
!06 U+0006 .notdef
|
||||
!07 U+0007 .notdef
|
||||
!08 U+0008 .notdef
|
||||
!09 U+0009 .notdef
|
||||
!0A U+000A .notdef
|
||||
!0B U+000B .notdef
|
||||
!0C U+000C .notdef
|
||||
!0D U+000D .notdef
|
||||
!0E U+000E .notdef
|
||||
!0F U+000F .notdef
|
||||
!10 U+0010 .notdef
|
||||
!11 U+0011 .notdef
|
||||
!12 U+0012 .notdef
|
||||
!13 U+0013 .notdef
|
||||
!14 U+0014 .notdef
|
||||
!15 U+0015 .notdef
|
||||
!16 U+0016 .notdef
|
||||
!17 U+0017 .notdef
|
||||
!18 U+0018 .notdef
|
||||
!19 U+0019 .notdef
|
||||
!1A U+001A .notdef
|
||||
!1B U+001B .notdef
|
||||
!1C U+001C .notdef
|
||||
!1D U+001D .notdef
|
||||
!1E U+001E .notdef
|
||||
!1F U+001F .notdef
|
||||
!20 U+0020 space
|
||||
!21 U+0021 exclam
|
||||
!22 U+0022 quotedbl
|
||||
!23 U+0023 numbersign
|
||||
!24 U+0024 dollar
|
||||
!25 U+0025 percent
|
||||
!26 U+0026 ampersand
|
||||
!27 U+0027 quotesingle
|
||||
!28 U+0028 parenleft
|
||||
!29 U+0029 parenright
|
||||
!2A U+002A asterisk
|
||||
!2B U+002B plus
|
||||
!2C U+002C comma
|
||||
!2D U+002D hyphen
|
||||
!2E U+002E period
|
||||
!2F U+002F slash
|
||||
!30 U+0030 zero
|
||||
!31 U+0031 one
|
||||
!32 U+0032 two
|
||||
!33 U+0033 three
|
||||
!34 U+0034 four
|
||||
!35 U+0035 five
|
||||
!36 U+0036 six
|
||||
!37 U+0037 seven
|
||||
!38 U+0038 eight
|
||||
!39 U+0039 nine
|
||||
!3A U+003A colon
|
||||
!3B U+003B semicolon
|
||||
!3C U+003C less
|
||||
!3D U+003D equal
|
||||
!3E U+003E greater
|
||||
!3F U+003F question
|
||||
!40 U+0040 at
|
||||
!41 U+0041 A
|
||||
!42 U+0042 B
|
||||
!43 U+0043 C
|
||||
!44 U+0044 D
|
||||
!45 U+0045 E
|
||||
!46 U+0046 F
|
||||
!47 U+0047 G
|
||||
!48 U+0048 H
|
||||
!49 U+0049 I
|
||||
!4A U+004A J
|
||||
!4B U+004B K
|
||||
!4C U+004C L
|
||||
!4D U+004D M
|
||||
!4E U+004E N
|
||||
!4F U+004F O
|
||||
!50 U+0050 P
|
||||
!51 U+0051 Q
|
||||
!52 U+0052 R
|
||||
!53 U+0053 S
|
||||
!54 U+0054 T
|
||||
!55 U+0055 U
|
||||
!56 U+0056 V
|
||||
!57 U+0057 W
|
||||
!58 U+0058 X
|
||||
!59 U+0059 Y
|
||||
!5A U+005A Z
|
||||
!5B U+005B bracketleft
|
||||
!5C U+005C backslash
|
||||
!5D U+005D bracketright
|
||||
!5E U+005E asciicircum
|
||||
!5F U+005F underscore
|
||||
!60 U+0060 grave
|
||||
!61 U+0061 a
|
||||
!62 U+0062 b
|
||||
!63 U+0063 c
|
||||
!64 U+0064 d
|
||||
!65 U+0065 e
|
||||
!66 U+0066 f
|
||||
!67 U+0067 g
|
||||
!68 U+0068 h
|
||||
!69 U+0069 i
|
||||
!6A U+006A j
|
||||
!6B U+006B k
|
||||
!6C U+006C l
|
||||
!6D U+006D m
|
||||
!6E U+006E n
|
||||
!6F U+006F o
|
||||
!70 U+0070 p
|
||||
!71 U+0071 q
|
||||
!72 U+0072 r
|
||||
!73 U+0073 s
|
||||
!74 U+0074 t
|
||||
!75 U+0075 u
|
||||
!76 U+0076 v
|
||||
!77 U+0077 w
|
||||
!78 U+0078 x
|
||||
!79 U+0079 y
|
||||
!7A U+007A z
|
||||
!7B U+007B braceleft
|
||||
!7C U+007C bar
|
||||
!7D U+007D braceright
|
||||
!7E U+007E asciitilde
|
||||
!7F U+007F .notdef
|
||||
!80 U+20AC Euro
|
||||
!82 U+201A quotesinglbase
|
||||
!83 U+0192 florin
|
||||
!84 U+201E quotedblbase
|
||||
!85 U+2026 ellipsis
|
||||
!86 U+2020 dagger
|
||||
!87 U+2021 daggerdbl
|
||||
!88 U+02C6 circumflex
|
||||
!89 U+2030 perthousand
|
||||
!8A U+0160 Scaron
|
||||
!8B U+2039 guilsinglleft
|
||||
!8C U+0152 OE
|
||||
!8E U+017D Zcaron
|
||||
!91 U+2018 quoteleft
|
||||
!92 U+2019 quoteright
|
||||
!93 U+201C quotedblleft
|
||||
!94 U+201D quotedblright
|
||||
!95 U+2022 bullet
|
||||
!96 U+2013 endash
|
||||
!97 U+2014 emdash
|
||||
!98 U+02DC tilde
|
||||
!99 U+2122 trademark
|
||||
!9A U+0161 scaron
|
||||
!9B U+203A guilsinglright
|
||||
!9C U+0153 oe
|
||||
!9E U+017E zcaron
|
||||
!9F U+0178 Ydieresis
|
||||
!A0 U+00A0 space
|
||||
!A1 U+00A1 exclamdown
|
||||
!A2 U+00A2 cent
|
||||
!A3 U+00A3 sterling
|
||||
!A4 U+00A4 currency
|
||||
!A5 U+00A5 yen
|
||||
!A6 U+00A6 brokenbar
|
||||
!A7 U+00A7 section
|
||||
!A8 U+00A8 dieresis
|
||||
!A9 U+00A9 copyright
|
||||
!AA U+00AA ordfeminine
|
||||
!AB U+00AB guillemotleft
|
||||
!AC U+00AC logicalnot
|
||||
!AD U+00AD hyphen
|
||||
!AE U+00AE registered
|
||||
!AF U+00AF macron
|
||||
!B0 U+00B0 degree
|
||||
!B1 U+00B1 plusminus
|
||||
!B2 U+00B2 twosuperior
|
||||
!B3 U+00B3 threesuperior
|
||||
!B4 U+00B4 acute
|
||||
!B5 U+00B5 mu
|
||||
!B6 U+00B6 paragraph
|
||||
!B7 U+00B7 periodcentered
|
||||
!B8 U+00B8 cedilla
|
||||
!B9 U+00B9 onesuperior
|
||||
!BA U+00BA ordmasculine
|
||||
!BB U+00BB guillemotright
|
||||
!BC U+00BC onequarter
|
||||
!BD U+00BD onehalf
|
||||
!BE U+00BE threequarters
|
||||
!BF U+00BF questiondown
|
||||
!C0 U+00C0 Agrave
|
||||
!C1 U+00C1 Aacute
|
||||
!C2 U+00C2 Acircumflex
|
||||
!C3 U+00C3 Atilde
|
||||
!C4 U+00C4 Adieresis
|
||||
!C5 U+00C5 Aring
|
||||
!C6 U+00C6 AE
|
||||
!C7 U+00C7 Ccedilla
|
||||
!C8 U+00C8 Egrave
|
||||
!C9 U+00C9 Eacute
|
||||
!CA U+00CA Ecircumflex
|
||||
!CB U+00CB Edieresis
|
||||
!CC U+00CC Igrave
|
||||
!CD U+00CD Iacute
|
||||
!CE U+00CE Icircumflex
|
||||
!CF U+00CF Idieresis
|
||||
!D0 U+00D0 Eth
|
||||
!D1 U+00D1 Ntilde
|
||||
!D2 U+00D2 Ograve
|
||||
!D3 U+00D3 Oacute
|
||||
!D4 U+00D4 Ocircumflex
|
||||
!D5 U+00D5 Otilde
|
||||
!D6 U+00D6 Odieresis
|
||||
!D7 U+00D7 multiply
|
||||
!D8 U+00D8 Oslash
|
||||
!D9 U+00D9 Ugrave
|
||||
!DA U+00DA Uacute
|
||||
!DB U+00DB Ucircumflex
|
||||
!DC U+00DC Udieresis
|
||||
!DD U+00DD Yacute
|
||||
!DE U+00DE Thorn
|
||||
!DF U+00DF germandbls
|
||||
!E0 U+00E0 agrave
|
||||
!E1 U+00E1 aacute
|
||||
!E2 U+00E2 acircumflex
|
||||
!E3 U+00E3 atilde
|
||||
!E4 U+00E4 adieresis
|
||||
!E5 U+00E5 aring
|
||||
!E6 U+00E6 ae
|
||||
!E7 U+00E7 ccedilla
|
||||
!E8 U+00E8 egrave
|
||||
!E9 U+00E9 eacute
|
||||
!EA U+00EA ecircumflex
|
||||
!EB U+00EB edieresis
|
||||
!EC U+00EC igrave
|
||||
!ED U+00ED iacute
|
||||
!EE U+00EE icircumflex
|
||||
!EF U+00EF idieresis
|
||||
!F0 U+00F0 eth
|
||||
!F1 U+00F1 ntilde
|
||||
!F2 U+00F2 ograve
|
||||
!F3 U+00F3 oacute
|
||||
!F4 U+00F4 ocircumflex
|
||||
!F5 U+00F5 otilde
|
||||
!F6 U+00F6 odieresis
|
||||
!F7 U+00F7 divide
|
||||
!F8 U+00F8 oslash
|
||||
!F9 U+00F9 ugrave
|
||||
!FA U+00FA uacute
|
||||
!FB U+00FB ucircumflex
|
||||
!FC U+00FC udieresis
|
||||
!FD U+00FD yacute
|
||||
!FE U+00FE thorn
|
||||
!FF U+00FF ydieresis
|
||||
@@ -1,157 +0,0 @@
|
||||
// Package dossier erzeugt das Nachweis-Dossier (PDF) aus Submission,
|
||||
// Findings, Verantwortungsmatrix und Beweiskette. Die Aufbereitung des
|
||||
// Inhalts (BuildContent) ist von der eigentlichen PDF-Zeichnung
|
||||
// (Render) getrennt, damit die fachliche Logik — was steht wo im
|
||||
// Dossier, in welcher Form — ohne PDF-Parsing testbar ist.
|
||||
package dossier
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/evidence"
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
)
|
||||
|
||||
// Disclaimer steht auf jedem erzeugten Dossier. Siehe CLAUDE.md,
|
||||
// Leitplanken: Deklarix ist ein Werkzeug, keine Rechtsberatung.
|
||||
const Disclaimer = "Dieses Dossier dokumentiert die durchgeführte Prüfung. " +
|
||||
"Es ist keine Rechtsberatung und ersetzt keine anwaltliche Prüfung im Einzelfall."
|
||||
|
||||
// Submission sind die Basisdaten des geprüften Beitrags.
|
||||
type Submission struct {
|
||||
Platform string
|
||||
PostType string
|
||||
Caption string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Participant ist ein Beteiligter aus der Verantwortungsmatrix.
|
||||
type Participant struct {
|
||||
Role string
|
||||
Name string
|
||||
Vorgegeben bool
|
||||
Freigegeben bool
|
||||
}
|
||||
|
||||
// Data ist die Eingabe für Generate/BuildContent — alles, was ein
|
||||
// Dossier für einen Beitrag braucht.
|
||||
type Data struct {
|
||||
Submission Submission
|
||||
Facts rules.Facts
|
||||
Findings []rules.Finding
|
||||
Participants []Participant
|
||||
AssetHash []byte
|
||||
MetadataHash []byte
|
||||
TimestampToken []byte
|
||||
GeneratedAt time.Time
|
||||
}
|
||||
|
||||
// FindingRow ist die dossier-taugliche Aufbereitung eines rules.Finding.
|
||||
type FindingRow struct {
|
||||
RuleID string
|
||||
Version int
|
||||
Severity string
|
||||
Title string
|
||||
Fix string
|
||||
Sources []string
|
||||
}
|
||||
|
||||
// ParticipantRow ist die dossier-taugliche Aufbereitung eines Participant.
|
||||
type ParticipantRow struct {
|
||||
Role string
|
||||
Name string
|
||||
Vorgegeben bool
|
||||
Freigegeben bool
|
||||
}
|
||||
|
||||
// Content ist die fertig aufbereitete, PDF-unabhängige Darstellung
|
||||
// eines Dossiers.
|
||||
type Content struct {
|
||||
Title string
|
||||
Platform string
|
||||
PostType string
|
||||
Caption string
|
||||
|
||||
SubmittedAt time.Time
|
||||
GeneratedAt time.Time
|
||||
|
||||
DisclosurePresent bool
|
||||
DisclosureWording string
|
||||
DisclosureBeforeCut bool
|
||||
|
||||
Findings []FindingRow
|
||||
Participants []ParticipantRow
|
||||
|
||||
AssetHashHex string
|
||||
MetadataHashHex string
|
||||
TimestampedAt time.Time
|
||||
|
||||
Disclaimer string
|
||||
}
|
||||
|
||||
// BuildContent bereitet Data zu Content auf. Fehlt eine Pflichtangabe
|
||||
// (Plattform, Zeitstempel-Token), wird ein Fehler geliefert statt ein
|
||||
// Dossier mit stillschweigend leeren Beweisfeldern zu erzeugen.
|
||||
func BuildContent(data Data) (Content, error) {
|
||||
if data.Submission.Platform == "" {
|
||||
return Content{}, fmt.Errorf("dossier: submission.platform fehlt")
|
||||
}
|
||||
if len(data.TimestampToken) == 0 {
|
||||
return Content{}, fmt.Errorf("dossier: timestamp token fehlt")
|
||||
}
|
||||
// AssetHash ist optional: nicht jede Prüfung hat ein hochgeladenes
|
||||
// Bild/Video (aktuell reine Caption-Prüfungen). Ein erfundener
|
||||
// Platzhalter-Hash für ein nicht existierendes Asset wäre selbst ein
|
||||
// Integritätsproblem — also zeigt das Dossier stattdessen "kein
|
||||
// Asset hinterlegt" an, siehe Render.
|
||||
if len(data.MetadataHash) == 0 {
|
||||
return Content{}, fmt.Errorf("dossier: metadata hash fehlt")
|
||||
}
|
||||
|
||||
timestampedAt, err := evidence.TimestampTime(data.TimestampToken)
|
||||
if err != nil {
|
||||
return Content{}, fmt.Errorf("dossier: timestamp token: %w", err)
|
||||
}
|
||||
|
||||
findings := make([]FindingRow, len(data.Findings))
|
||||
for i, f := range data.Findings {
|
||||
findings[i] = FindingRow{
|
||||
RuleID: f.RuleID,
|
||||
Version: f.RuleVersion,
|
||||
Severity: string(f.Severity),
|
||||
Title: f.Title,
|
||||
Fix: f.Fix,
|
||||
Sources: f.Sources,
|
||||
}
|
||||
}
|
||||
|
||||
participants := make([]ParticipantRow, len(data.Participants))
|
||||
for i, p := range data.Participants {
|
||||
participants[i] = ParticipantRow{
|
||||
Role: p.Role,
|
||||
Name: p.Name,
|
||||
Vorgegeben: p.Vorgegeben,
|
||||
Freigegeben: p.Freigegeben,
|
||||
}
|
||||
}
|
||||
|
||||
return Content{
|
||||
Title: "Nachweis-Dossier",
|
||||
Platform: data.Submission.Platform,
|
||||
PostType: data.Submission.PostType,
|
||||
Caption: data.Submission.Caption,
|
||||
SubmittedAt: data.Submission.CreatedAt,
|
||||
GeneratedAt: data.GeneratedAt,
|
||||
DisclosurePresent: data.Facts.DisclosurePresent,
|
||||
DisclosureWording: data.Facts.DisclosureWording,
|
||||
DisclosureBeforeCut: data.Facts.DisclosureBeforeCut,
|
||||
Findings: findings,
|
||||
Participants: participants,
|
||||
AssetHashHex: hex.EncodeToString(data.AssetHash),
|
||||
MetadataHashHex: hex.EncodeToString(data.MetadataHash),
|
||||
TimestampedAt: timestampedAt,
|
||||
Disclaimer: Disclaimer,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
package dossier_test
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/asn1"
|
||||
"math/big"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/digitorus/timestamp"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/dossier"
|
||||
"github.com/netcell-it/deklarix/internal/evidence"
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
)
|
||||
|
||||
// fakeTimestampToken erzeugt einen strukturell gültigen, selbstsignierten
|
||||
// RFC-3161-Token für hash — offline, ohne echte TSA. Damit lassen sich
|
||||
// BuildContent/Render testen, ohne bei jedem Testlauf eine echte
|
||||
// Time-Stamp Authority anzufragen (die Echtheit/Vertrauenswürdigkeit
|
||||
// des Zertifikats spielt für diese Tests keine Rolle, nur dass der Token
|
||||
// strukturell parsbar ist wie ein echter).
|
||||
func fakeTimestampToken(t *testing.T, hash []byte, at time.Time) []byte {
|
||||
t.Helper()
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
|
||||
certTemplate := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "deklarix-test-tsa"},
|
||||
NotBefore: at.Add(-time.Hour),
|
||||
NotAfter: at.Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageTimeStamping},
|
||||
}
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, certTemplate, certTemplate, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create certificate: %v", err)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(certDER)
|
||||
if err != nil {
|
||||
t.Fatalf("parse certificate: %v", err)
|
||||
}
|
||||
|
||||
ts := timestamp.Timestamp{
|
||||
HashAlgorithm: crypto.SHA256,
|
||||
HashedMessage: hash,
|
||||
Time: at,
|
||||
SerialNumber: big.NewInt(1),
|
||||
Policy: asn1.ObjectIdentifier{1, 2, 3},
|
||||
}
|
||||
respDER, err := ts.CreateResponse(cert, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create timestamp response: %v", err)
|
||||
}
|
||||
|
||||
parsed, err := timestamp.ParseResponse(respDER)
|
||||
if err != nil {
|
||||
t.Fatalf("parse fake timestamp response: %v", err)
|
||||
}
|
||||
return parsed.RawToken
|
||||
}
|
||||
|
||||
func validData(t *testing.T) dossier.Data {
|
||||
t.Helper()
|
||||
hash := evidence.HashBytes([]byte("caption+bild"))
|
||||
at := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
return dossier.Data{
|
||||
Submission: dossier.Submission{
|
||||
Platform: "instagram",
|
||||
PostType: "reel",
|
||||
Caption: "Werbung für ein Produkt äöüß",
|
||||
CreatedAt: at,
|
||||
},
|
||||
Facts: rules.Facts{
|
||||
DisclosurePresent: true,
|
||||
DisclosureWording: "Werbung",
|
||||
DisclosureBeforeCut: false,
|
||||
},
|
||||
Findings: []rules.Finding{
|
||||
{RuleID: "WK-004", RuleVersion: 1, Severity: rules.SeverityHigh, Title: "t", Fix: "f", Sources: []string{"§ 5a Abs. 4 UWG"}},
|
||||
},
|
||||
Participants: []dossier.Participant{
|
||||
{Role: "creator", Name: "Max Mustermann", Vorgegeben: false, Freigegeben: true},
|
||||
},
|
||||
AssetHash: hash,
|
||||
MetadataHash: evidence.HashBytes([]byte("metadata")),
|
||||
TimestampToken: fakeTimestampToken(t, hash, at),
|
||||
GeneratedAt: at,
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildContentRejectsMissingPlatform(t *testing.T) {
|
||||
data := validData(t)
|
||||
data.Submission.Platform = ""
|
||||
if _, err := dossier.BuildContent(data); err == nil {
|
||||
t.Fatal("expected error for missing platform, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildContentRejectsMissingTimestampToken(t *testing.T) {
|
||||
data := validData(t)
|
||||
data.TimestampToken = nil
|
||||
if _, err := dossier.BuildContent(data); err == nil {
|
||||
t.Fatal("expected error for missing timestamp token, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildContentRejectsMissingMetadataHash(t *testing.T) {
|
||||
data := validData(t)
|
||||
data.MetadataHash = nil
|
||||
if _, err := dossier.BuildContent(data); err == nil {
|
||||
t.Fatal("expected error for missing metadata hash, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildContentAllowsMissingAssetHash(t *testing.T) {
|
||||
// Reine Caption-Pruefungen ohne hochgeladenes Bild/Video haben kein
|
||||
// Asset zum Hashen — das ist kein Fehlerfall.
|
||||
data := validData(t)
|
||||
data.AssetHash = nil
|
||||
content, err := dossier.BuildContent(data)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildContent: %v", err)
|
||||
}
|
||||
if content.AssetHashHex != "" {
|
||||
t.Fatalf("AssetHashHex = %q, want empty when no asset was provided", content.AssetHashHex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildContentSuccess(t *testing.T) {
|
||||
data := validData(t)
|
||||
|
||||
content, err := dossier.BuildContent(data)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildContent: %v", err)
|
||||
}
|
||||
|
||||
if content.Platform != "instagram" {
|
||||
t.Errorf("Platform = %q, want instagram", content.Platform)
|
||||
}
|
||||
if len(content.Findings) != 1 || content.Findings[0].RuleID != "WK-004" {
|
||||
t.Errorf("Findings = %+v, want one WK-004 row", content.Findings)
|
||||
}
|
||||
if len(content.Participants) != 1 || content.Participants[0].Name != "Max Mustermann" {
|
||||
t.Errorf("Participants = %+v, want one Max-Mustermann row", content.Participants)
|
||||
}
|
||||
if len(content.AssetHashHex) != 64 {
|
||||
t.Errorf("AssetHashHex length = %d, want 64 (hex of 32-byte SHA-256)", len(content.AssetHashHex))
|
||||
}
|
||||
if !content.TimestampedAt.Equal(data.Submission.CreatedAt) {
|
||||
t.Errorf("TimestampedAt = %v, want %v", content.TimestampedAt, data.Submission.CreatedAt)
|
||||
}
|
||||
if content.Disclaimer == "" {
|
||||
t.Error("expected a non-empty disclaimer")
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
package dossier
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/go-pdf/fpdf"
|
||||
)
|
||||
|
||||
//go:embed assets/cp1252.map
|
||||
var assetsFS embed.FS
|
||||
|
||||
// Generate erzeugt das PDF-Dossier für data und schreibt es nach w.
|
||||
func Generate(w io.Writer, data Data) error {
|
||||
content, err := BuildContent(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return Render(w, content)
|
||||
}
|
||||
|
||||
// Render zeichnet ein bereits aufbereitetes Content als PDF nach w.
|
||||
func Render(w io.Writer, c Content) error {
|
||||
tr, err := unicodeTranslator()
|
||||
if err != nil {
|
||||
return fmt.Errorf("dossier: unicode translator: %w", err)
|
||||
}
|
||||
|
||||
pdf := fpdf.New("P", "mm", "A4", "")
|
||||
pdf.SetTitle(tr(c.Title), false)
|
||||
pdf.AddPage()
|
||||
|
||||
heading := func(text string) {
|
||||
pdf.SetFont("Helvetica", "B", 12)
|
||||
pdf.CellFormat(0, 8, tr(text), "", 1, "L", false, 0, "")
|
||||
pdf.SetFont("Helvetica", "", 10)
|
||||
}
|
||||
line := func(format string, args ...any) {
|
||||
pdf.CellFormat(0, 6, tr(fmt.Sprintf(format, args...)), "", 1, "L", false, 0, "")
|
||||
}
|
||||
paragraph := func(text string) {
|
||||
pdf.MultiCell(0, 5, tr(text), "", "L", false)
|
||||
}
|
||||
|
||||
pdf.SetFont("Helvetica", "B", 16)
|
||||
pdf.CellFormat(0, 10, tr(c.Title), "", 1, "L", false, 0, "")
|
||||
pdf.SetFont("Helvetica", "", 10)
|
||||
line("Erzeugt am %s", c.GeneratedAt.Format("02.01.2006 15:04 MST"))
|
||||
pdf.Ln(4)
|
||||
|
||||
heading("Beitrag")
|
||||
line("Plattform: %s Typ: %s", c.Platform, c.PostType)
|
||||
line("Eingereicht am: %s", c.SubmittedAt.Format("02.01.2006 15:04"))
|
||||
paragraph("Caption: " + c.Caption)
|
||||
pdf.Ln(2)
|
||||
|
||||
heading("Kennzeichnung")
|
||||
line("Vorhanden: %s Wortlaut: %q Vor Kürzung sichtbar: %s",
|
||||
yesNo(c.DisclosurePresent), c.DisclosureWording, yesNo(c.DisclosureBeforeCut))
|
||||
pdf.Ln(2)
|
||||
|
||||
heading("Findings")
|
||||
if len(c.Findings) == 0 {
|
||||
line("Keine Findings.")
|
||||
}
|
||||
for _, f := range c.Findings {
|
||||
pdf.SetFont("Helvetica", "B", 10)
|
||||
line("%s v%d — %s (%s)", f.RuleID, f.Version, f.Title, f.Severity)
|
||||
pdf.SetFont("Helvetica", "", 10)
|
||||
paragraph("Korrektur: " + f.Fix)
|
||||
if len(f.Sources) > 0 {
|
||||
paragraph("Fundstellen: " + strings.Join(f.Sources, "; "))
|
||||
}
|
||||
pdf.Ln(1)
|
||||
}
|
||||
pdf.Ln(2)
|
||||
|
||||
heading("Verantwortungsmatrix")
|
||||
if len(c.Participants) == 0 {
|
||||
line("Keine Beteiligten hinterlegt.")
|
||||
}
|
||||
for _, p := range c.Participants {
|
||||
line("%s: %s (vorgegeben: %s, freigegeben: %s)",
|
||||
p.Role, p.Name, yesNo(p.Vorgegeben), yesNo(p.Freigegeben))
|
||||
}
|
||||
pdf.Ln(2)
|
||||
|
||||
heading("Beweiskette")
|
||||
if c.AssetHashHex != "" {
|
||||
line("Asset-Hash (SHA-256): %s", c.AssetHashHex)
|
||||
} else {
|
||||
line("Asset-Hash: kein Asset hinterlegt (reine Caption-Prüfung)")
|
||||
}
|
||||
line("Metadaten-Hash (SHA-256): %s", c.MetadataHashHex)
|
||||
line("RFC-3161-Zeitstempel: %s", c.TimestampedAt.Format("02.01.2006 15:04:05 MST"))
|
||||
pdf.Ln(4)
|
||||
|
||||
pdf.SetFont("Helvetica", "I", 8)
|
||||
paragraph(c.Disclaimer)
|
||||
|
||||
if err := pdf.Error(); err != nil {
|
||||
return fmt.Errorf("dossier: pdf aufbauen: %w", err)
|
||||
}
|
||||
if err := pdf.Output(w); err != nil {
|
||||
return fmt.Errorf("dossier: pdf schreiben: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func yesNo(b bool) string {
|
||||
if b {
|
||||
return "ja"
|
||||
}
|
||||
return "nein"
|
||||
}
|
||||
|
||||
// unicodeTranslator übersetzt UTF-8 (z. B. Umlaute) in die cp1252-
|
||||
// Kodierung der Helvetica-Kernschriftart. Die Map-Datei ist eingebettet,
|
||||
// damit Deklarix trotz PDF-Erzeugung ein einzelnes Binary bleibt.
|
||||
func unicodeTranslator() (func(string) string, error) {
|
||||
data, err := assetsFS.ReadFile("assets/cp1252.map")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fpdf.UnicodeTranslator(bytes.NewReader(data))
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package dossier_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/dossier"
|
||||
)
|
||||
|
||||
func TestRenderProducesValidPDF(t *testing.T) {
|
||||
content, err := dossier.BuildContent(validData(t))
|
||||
if err != nil {
|
||||
t.Fatalf("BuildContent: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := dossier.Render(&buf, content); err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
|
||||
out := buf.Bytes()
|
||||
if !bytes.HasPrefix(out, []byte("%PDF-")) {
|
||||
t.Fatalf("output does not start with %%PDF- header: %q", out[:min(20, len(out))])
|
||||
}
|
||||
if !bytes.Contains(out, []byte("%%EOF")) {
|
||||
t.Fatal("output does not contain the expected PDF EOF trailer")
|
||||
}
|
||||
if len(out) < 500 {
|
||||
t.Fatalf("output suspiciously small (%d bytes) for a multi-section dossier", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateProducesValidPDF(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := dossier.Generate(&buf, validData(t)); err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if !bytes.HasPrefix(buf.Bytes(), []byte("%PDF-")) {
|
||||
t.Fatal("Generate output does not start with the expected PDF header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePropagatesBuildContentErrors(t *testing.T) {
|
||||
data := validData(t)
|
||||
data.Submission.Platform = ""
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := dossier.Generate(&buf, data); err == nil {
|
||||
t.Fatal("expected Generate to propagate a BuildContent error, got nil")
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
// Package evidence bildet die Beweiskette: Hashing von Assets und
|
||||
// Metadaten, RFC-3161-Zeitstempel über diesen Hash. Das Append-only-
|
||||
// Prinzip selbst wird von internal/store (Postgres-Trigger) erzwungen —
|
||||
// dieses Paket liefert nur die Bausteine, die evidence_package braucht.
|
||||
package evidence
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// HashBytes berechnet den SHA-256-Digest über beliebige Bytes, z. B.
|
||||
// ein Asset wie Screenshot oder Video.
|
||||
func HashBytes(data []byte) []byte {
|
||||
sum := sha256.Sum256(data)
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// HashMetadata berechnet den SHA-256-Digest über die kanonisierte JSON-
|
||||
// Repräsentation von v. "Kanonisiert" heißt hier: encoding/json sortiert
|
||||
// Objektschlüssel bereits deterministisch, und Struct-Felder behalten
|
||||
// ihre Deklarationsreihenfolge — für unsere eigenen, fest definierten
|
||||
// Go-Typen ist das schon eine stabile, reproduzierbare Serialisierung,
|
||||
// ohne dass es eine eigene Kanonisierungs-Bibliothek bräuchte.
|
||||
func HashMetadata(v any) ([]byte, error) {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("evidence: metadata marshal: %w", err)
|
||||
}
|
||||
return HashBytes(data), nil
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package evidence_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/evidence"
|
||||
)
|
||||
|
||||
func TestHashBytesIsDeterministic(t *testing.T) {
|
||||
data := []byte("ein beispiel-asset")
|
||||
a := evidence.HashBytes(data)
|
||||
b := evidence.HashBytes(data)
|
||||
if !bytes.Equal(a, b) {
|
||||
t.Fatalf("HashBytes ist nicht deterministisch: %x != %x", a, b)
|
||||
}
|
||||
if len(a) != 32 {
|
||||
t.Fatalf("expected 32-byte SHA-256 digest, got %d bytes", len(a))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashBytesDiffersForDifferentInput(t *testing.T) {
|
||||
a := evidence.HashBytes([]byte("foo"))
|
||||
b := evidence.HashBytes([]byte("bar"))
|
||||
if bytes.Equal(a, b) {
|
||||
t.Fatal("expected different hashes for different input, got the same")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashMetadataIsStableAcrossMapKeyOrder(t *testing.T) {
|
||||
m1 := map[string]any{"a": 1, "b": 2, "c": 3}
|
||||
m2 := map[string]any{"c": 3, "a": 1, "b": 2}
|
||||
|
||||
h1, err := evidence.HashMetadata(m1)
|
||||
if err != nil {
|
||||
t.Fatalf("HashMetadata(m1): %v", err)
|
||||
}
|
||||
h2, err := evidence.HashMetadata(m2)
|
||||
if err != nil {
|
||||
t.Fatalf("HashMetadata(m2): %v", err)
|
||||
}
|
||||
if !bytes.Equal(h1, h2) {
|
||||
t.Fatal("HashMetadata should be stable across map key insertion order")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashMetadataDiffersForDifferentContent(t *testing.T) {
|
||||
h1, err := evidence.HashMetadata(map[string]any{"a": 1})
|
||||
if err != nil {
|
||||
t.Fatalf("HashMetadata: %v", err)
|
||||
}
|
||||
h2, err := evidence.HashMetadata(map[string]any{"a": 2})
|
||||
if err != nil {
|
||||
t.Fatalf("HashMetadata: %v", err)
|
||||
}
|
||||
if bytes.Equal(h1, h2) {
|
||||
t.Fatal("expected different hashes for different metadata content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashMetadataRejectsUnmarshalableValue(t *testing.T) {
|
||||
// Kanäle können nicht als JSON serialisiert werden — muss einen
|
||||
// Fehler liefern statt still einen falschen Hash zurückzugeben.
|
||||
if _, err := evidence.HashMetadata(make(chan int)); err == nil {
|
||||
t.Fatal("expected error for unmarshalable value, got nil")
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package evidence
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/digitorus/timestamp"
|
||||
)
|
||||
|
||||
// TimestampTime liefert den im RFC-3161-Token bescheinigten Zeitpunkt,
|
||||
// z. B. für die Anzeige im Nachweis-Dossier.
|
||||
func TimestampTime(token []byte) (time.Time, error) {
|
||||
ts, err := timestamp.Parse(token)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("evidence: timestamp token parse: %w", err)
|
||||
}
|
||||
return ts.Time, nil
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package evidence_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/evidence"
|
||||
)
|
||||
|
||||
func TestTimestampTimeRejectsGarbage(t *testing.T) {
|
||||
if _, err := evidence.TimestampTime([]byte("not a timestamp token")); err == nil {
|
||||
t.Fatal("expected error for garbage token bytes, got nil")
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package evidence
|
||||
|
||||
import "context"
|
||||
|
||||
// Timestamper fragt einen RFC-3161-Zeitstempel für einen Hash (i. d. R.
|
||||
// aus HashBytes/HashMetadata) bei einer Time-Stamp Authority (TSA) an
|
||||
// und liefert den rohen Zeitstempel-Token zurück, wie er unverändert in
|
||||
// evidence_package.timestamp_token gespeichert wird.
|
||||
//
|
||||
// Es gibt hier bewusst noch keine konkrete Implementierung: welche TSA
|
||||
// verwendet wird, ist in CLAUDE.md als offener Punkt vermerkt (freie
|
||||
// TSA vs. eIDAS-qualifizierter Zeitstempeldienst — das betrifft direkt
|
||||
// die Beweiskraft des Archivs und ist keine rein technische
|
||||
// Entscheidung). Sobald das geklärt ist, implementiert ein konkreter
|
||||
// Typ dieses Interface.
|
||||
type Timestamper interface {
|
||||
Timestamp(ctx context.Context, hash []byte) (token []byte, err error)
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
package evidence
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
|
||||
"github.com/digitorus/timestamp"
|
||||
)
|
||||
|
||||
// DefaultTSAURL ist eine freie, RFC-3161-konforme Time-Stamp Authority.
|
||||
//
|
||||
// NICHT eIDAS-qualifiziert — reicht, um die Beweiskette technisch
|
||||
// funktionsfähig zu haben, aber vor echtem Kundeneinsatz auf einen
|
||||
// eIDAS-qualifizierten Zeitstempeldienst umstellen (z. B. D-Trust,
|
||||
// Bundesdruckerei), der eine gesetzliche Vermutungswirkung nach
|
||||
// eIDAS Art. 41 hat. Siehe CLAUDE.md, Offene Punkte.
|
||||
const DefaultTSAURL = "https://freetsa.org/tsr"
|
||||
|
||||
// HTTPTimestamper implementiert Timestamper gegen eine RFC-3161-TSA
|
||||
// über HTTP (application/timestamp-query, siehe RFC 3161 Abschnitt 3.4).
|
||||
type HTTPTimestamper struct {
|
||||
url string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// TimestamperOption konfiguriert einen HTTPTimestamper.
|
||||
type TimestamperOption func(*HTTPTimestamper)
|
||||
|
||||
// WithTimestamperHTTPClient überschreibt den verwendeten *http.Client
|
||||
// (für Tests).
|
||||
func WithTimestamperHTTPClient(hc *http.Client) TimestamperOption {
|
||||
return func(t *HTTPTimestamper) { t.httpClient = hc }
|
||||
}
|
||||
|
||||
// NewHTTPTimestamper erstellt einen Timestamper. Ein leerer url-Wert
|
||||
// verwendet DefaultTSAURL.
|
||||
func NewHTTPTimestamper(url string, opts ...TimestamperOption) *HTTPTimestamper {
|
||||
if url == "" {
|
||||
url = DefaultTSAURL
|
||||
}
|
||||
t := &HTTPTimestamper{url: url, httpClient: http.DefaultClient}
|
||||
for _, opt := range opts {
|
||||
opt(t)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// Timestamp fragt einen RFC-3161-Zeitstempel für hash (ein SHA-256-
|
||||
// Digest) an und liefert den rohen TimeStampToken zurück. Die Antwort
|
||||
// wird gegen den angefragten Hash und die gesendete Nonce geprüft,
|
||||
// bevor der Token akzeptiert wird — eine TSA-Antwort, die nicht zum
|
||||
// eigenen Request passt, ist kein gültiger Zeitstempel für diesen Hash.
|
||||
func (t *HTTPTimestamper) Timestamp(ctx context.Context, hash []byte) ([]byte, error) {
|
||||
if len(hash) != crypto.SHA256.Size() {
|
||||
return nil, fmt.Errorf("evidence: timestamp erwartet einen SHA-256-Digest (%d Bytes), bekam %d", crypto.SHA256.Size(), len(hash))
|
||||
}
|
||||
|
||||
nonce, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 64))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("evidence: nonce generieren: %w", err)
|
||||
}
|
||||
|
||||
req := timestamp.Request{
|
||||
HashAlgorithm: crypto.SHA256,
|
||||
HashedMessage: hash,
|
||||
Nonce: nonce,
|
||||
Certificates: true,
|
||||
}
|
||||
reqBytes, err := req.Marshal()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("evidence: timestamp request marshal: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, t.url, bytes.NewReader(reqBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("evidence: request bauen: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/timestamp-query")
|
||||
|
||||
resp, err := t.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("evidence: TSA-Request fehlgeschlagen: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("evidence: TSA-Response lesen: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("evidence: TSA-Status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
ts, err := timestamp.ParseResponse(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("evidence: TSA-Response parse: %w", err)
|
||||
}
|
||||
|
||||
if ts.HashAlgorithm != crypto.SHA256 || !bytes.Equal(ts.HashedMessage, hash) {
|
||||
return nil, fmt.Errorf("evidence: TSA-Antwort passt nicht zum angefragten Hash")
|
||||
}
|
||||
if ts.Nonce == nil || ts.Nonce.Cmp(nonce) != 0 {
|
||||
return nil, fmt.Errorf("evidence: TSA-Antwort hat falsche oder fehlende Nonce")
|
||||
}
|
||||
|
||||
return ts.RawToken, nil
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
package evidence_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/digitorus/timestamp"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/evidence"
|
||||
)
|
||||
|
||||
func TestTimestampRejectsWrongHashSize(t *testing.T) {
|
||||
called := false
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ts := evidence.NewHTTPTimestamper(server.URL)
|
||||
_, err := ts.Timestamp(context.Background(), []byte("zu kurz"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for a non-SHA-256-sized hash, got nil")
|
||||
}
|
||||
if called {
|
||||
t.Fatal("expected no HTTP call for an invalid hash size")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimestampSendsValidRequest prüft serverseitig, dass Timestamp()
|
||||
// einen tatsächlich gültigen, RFC-3161-konformen Request schickt (parsbar,
|
||||
// korrekter Hash, korrekter Algorithmus, Nonce gesetzt) — ohne dafür eine
|
||||
// echte signierte Antwort fälschen zu müssen.
|
||||
func TestTimestampSendsValidRequest(t *testing.T) {
|
||||
hash := evidence.HashBytes([]byte("test-content"))
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if ct := r.Header.Get("Content-Type"); ct != "application/timestamp-query" {
|
||||
t.Errorf("Content-Type = %q, want application/timestamp-query", ct)
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Errorf("read request body: %v", err)
|
||||
}
|
||||
|
||||
req, err := timestamp.ParseRequest(body)
|
||||
if err != nil {
|
||||
t.Errorf("ParseRequest: %v", err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(req.HashedMessage, hash) {
|
||||
t.Errorf("HashedMessage = %x, want %x", req.HashedMessage, hash)
|
||||
}
|
||||
if req.Nonce == nil {
|
||||
t.Error("expected a nonce to be set on the request")
|
||||
}
|
||||
|
||||
// Keine echte TSA hier — nur die Request-Validierung interessiert
|
||||
// dieser Test. Ein absichtlich ungültiger Response-Body lässt
|
||||
// Timestamp() mit einem Parse-Fehler zurückkommen, was für diesen
|
||||
// Test in Ordnung ist.
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("not a valid timestamp response"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ts := evidence.NewHTTPTimestamper(server.URL)
|
||||
_, err := ts.Timestamp(context.Background(), hash)
|
||||
if err == nil {
|
||||
t.Fatal("expected a parse error for the fake response, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimestampRejectsNon200(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("tsa down"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ts := evidence.NewHTTPTimestamper(server.URL)
|
||||
hash := evidence.HashBytes([]byte("x"))
|
||||
if _, err := ts.Timestamp(context.Background(), hash); err == nil {
|
||||
t.Fatal("expected error for non-200 TSA status, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimestampRejectsMalformedResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("garbage, not ASN.1 DER"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ts := evidence.NewHTTPTimestamper(server.URL)
|
||||
hash := evidence.HashBytes([]byte("x"))
|
||||
if _, err := ts.Timestamp(context.Background(), hash); err == nil {
|
||||
t.Fatal("expected error for malformed TSA response, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimestampIntegration ruft die echte Standard-TSA (FreeTSA.org) auf.
|
||||
// Läuft nur, wenn DEKLARIX_TSA_INTEGRATION gesetzt ist — kein Netzwerkzugriff
|
||||
// in normalen Testläufen (siehe internal/store für dasselbe Muster mit
|
||||
// DATABASE_URL).
|
||||
func TestTimestampIntegration(t *testing.T) {
|
||||
if os.Getenv("DEKLARIX_TSA_INTEGRATION") == "" {
|
||||
t.Skip("DEKLARIX_TSA_INTEGRATION nicht gesetzt, überspringe echten TSA-Aufruf")
|
||||
}
|
||||
|
||||
hash := evidence.HashBytes([]byte(t.Name()))
|
||||
ts := evidence.NewHTTPTimestamper("")
|
||||
|
||||
token, err := ts.Timestamp(context.Background(), hash)
|
||||
if err != nil {
|
||||
t.Fatalf("Timestamp: %v", err)
|
||||
}
|
||||
|
||||
parsed, err := timestamp.Parse(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse(token): %v", err)
|
||||
}
|
||||
if !bytes.Equal(parsed.HashedMessage, hash) {
|
||||
t.Fatalf("token hash = %x, want %x", parsed.HashedMessage, hash)
|
||||
}
|
||||
if parsed.Time.IsZero() {
|
||||
t.Fatal("expected a non-zero timestamp time")
|
||||
}
|
||||
|
||||
tm, err := evidence.TimestampTime(token)
|
||||
if err != nil {
|
||||
t.Fatalf("TimestampTime: %v", err)
|
||||
}
|
||||
if !tm.Equal(parsed.Time) {
|
||||
t.Fatalf("TimestampTime() = %v, want %v", tm, parsed.Time)
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
// Package extract ist Stufe 1 aus dem Kernprinzip: es bestimmt
|
||||
// ausschließlich beobachtbare Fakten zu einem Beitrag. Es bewertet
|
||||
// nichts — das Urteil fällt ausschließlich das Regelwerk in
|
||||
// internal/rules.
|
||||
//
|
||||
// Die Gegenleistung (Consideration) wird NICHT aus dem Text geraten —
|
||||
// aus reinem Keyword-Matching lässt sich eine verschwiegene
|
||||
// Zusammenarbeit nicht von einem echten organischen Post unterscheiden
|
||||
// (beide sehen textlich identisch aus). Nur wer den Beitrag einreicht,
|
||||
// weiß, ob eine Gegenleistung vorlag, darum kommt dieser Wert vom
|
||||
// Aufrufer (siehe Input.Consideration). Was diese Stufe zuverlässig
|
||||
// automatisieren kann, ist reine Zeichenketten-Logik: steht ein
|
||||
// Kennzeichnungswort in der Caption, und steht es vor der
|
||||
// "mehr anzeigen"-Kürzung der Plattform.
|
||||
package extract
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
)
|
||||
|
||||
// EngineVersion wird zusammen mit jeder Extraktion gespeichert (siehe
|
||||
// Datenmodell: extraction.model_version) — hochzählen bei jeder
|
||||
// inhaltlichen Änderung an der Erkennungslogik unten.
|
||||
const EngineVersion = "regelbasiert-v1"
|
||||
|
||||
// PromptVersion wird ebenfalls gespeichert (extraction.prompt_version).
|
||||
// Es gibt kein Sprachmodell und keinen Prompt mehr, aber die Spalte
|
||||
// bleibt (keine neue Migration nur für einen Namenswechsel) — der Wert
|
||||
// markiert weiterhin den Stand der Extraktionslogik.
|
||||
const PromptVersion = "v1"
|
||||
|
||||
// disclosureKeywords sind Kennzeichnungshinweise, nach denen in der
|
||||
// Caption gesucht wird — bewusst großzügig (auch rechtlich unzureichende
|
||||
// wie "#ad", siehe rules/OPEN.md/Recherche zu OLG Celle/Kammergericht
|
||||
// Berlin): ob ein Wortlaut rechtlich ausreicht, entscheidet das
|
||||
// Regelwerk anhand von DisclosureWording, nicht diese Erkennung.
|
||||
var disclosureKeywords = []string{
|
||||
"werbung", "anzeige", "bezahlte partnerschaft", "paid partnership",
|
||||
"#ad", "#werbung", "#anzeige", "#sponsored", "#sponsoredby", "#sponsoredpost",
|
||||
}
|
||||
|
||||
// truncationThresholds sind ungefähre Zeichen-Schwellen, ab denen
|
||||
// Instagram/TikTok eine Caption in der Zeitleiste hinter "... mehr" /
|
||||
// "mehr anzeigen" kürzen. Plattformen ändern das ohne Ankündigung — vor
|
||||
// echtem Kundeneinsatz stichprobenartig nachprüfen, ob die Werte noch
|
||||
// stimmen (siehe CLAUDE.md, Offene Punkte).
|
||||
var truncationThresholds = map[string]int{
|
||||
"instagram": 125,
|
||||
"tiktok": 150,
|
||||
}
|
||||
|
||||
const defaultTruncationThreshold = 125
|
||||
|
||||
// Input ist, was Stufe 1 braucht. Platform, Jurisdiction UND
|
||||
// Consideration kommen alle vom Aufrufer — keines davon lässt sich aus
|
||||
// der Caption zuverlässig ableiten oder ist Sache dieser Stufe.
|
||||
type Input struct {
|
||||
Platform string
|
||||
Jurisdiction string
|
||||
Consideration string // "bezahlt" | "sachbezug" | "keine" | "unklar"
|
||||
Caption string
|
||||
}
|
||||
|
||||
// Result ist die Ausgabe von Extract: die für das Regelwerk
|
||||
// aufbereiteten Facts, plus RawJSON — die vollständige, unveränderte
|
||||
// Aufzeichnung dessen, was diese Stufe bestimmt hat. RawJSON gehört
|
||||
// unverändert in extraction.payload (siehe Datenmodell); Facts ist eine
|
||||
// abgeleitete Sicht darauf und nicht der Beweis-Eintrag selbst.
|
||||
type Result struct {
|
||||
Facts rules.Facts
|
||||
RawJSON []byte
|
||||
}
|
||||
|
||||
// Engine bestimmt die Facts für einen Beitrag über deterministische
|
||||
// Zeichenketten-Logik — kein externer Dienst, keine Netzwerk-Abhängigkeit.
|
||||
type Engine struct{}
|
||||
|
||||
// NewEngine erstellt eine Engine.
|
||||
func NewEngine() *Engine {
|
||||
return &Engine{}
|
||||
}
|
||||
|
||||
// ModelVersion liefert EngineVersion (siehe Datenmodell: extraction.model_version).
|
||||
func (e *Engine) ModelVersion() string { return EngineVersion }
|
||||
|
||||
// Extract bestimmt die Facts für in. Die Gegenleistung wird validiert,
|
||||
// nicht erraten — ein leerer oder ungültiger Wert ist ein Fehler, keine
|
||||
// Lücke, die stillschweigend als "keine" interpretiert wird (das wäre
|
||||
// hier besonders gefährlich: es würde unentdeckte Schleichwerbung
|
||||
// systematisch als unauffällig durchwinken).
|
||||
func (e *Engine) Extract(ctx context.Context, in Input) (Result, error) {
|
||||
if in.Caption == "" {
|
||||
return Result{}, fmt.Errorf("extract: caption ist leer")
|
||||
}
|
||||
consideration, err := parseConsideration(in.Consideration)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("extract: %w", err)
|
||||
}
|
||||
|
||||
present, wording, index := detectDisclosure(in.Caption)
|
||||
beforeCut := present && index < truncationThreshold(in.Platform)
|
||||
|
||||
args := extractionArgs{
|
||||
Consideration: string(consideration),
|
||||
DisclosurePresent: present,
|
||||
DisclosureWording: wording,
|
||||
DisclosureBeforeCut: beforeCut,
|
||||
}
|
||||
raw, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("extract: payload marshal: %w", err)
|
||||
}
|
||||
|
||||
return Result{
|
||||
Facts: rules.Facts{
|
||||
Platform: in.Platform,
|
||||
Jurisdiction: in.Jurisdiction,
|
||||
Consideration: consideration,
|
||||
DisclosurePresent: present,
|
||||
DisclosureWording: wording,
|
||||
DisclosureBeforeCut: beforeCut,
|
||||
},
|
||||
RawJSON: raw,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// detectDisclosure sucht die am frühesten in caption vorkommende
|
||||
// Kennzeichnung aus disclosureKeywords (case-insensitive) und liefert
|
||||
// deren Wortlaut in Original-Schreibweise plus Byte-Index.
|
||||
func detectDisclosure(caption string) (present bool, wording string, index int) {
|
||||
lower := strings.ToLower(caption)
|
||||
bestIdx := -1
|
||||
bestLen := 0
|
||||
for _, kw := range disclosureKeywords {
|
||||
if idx := strings.Index(lower, kw); idx != -1 && (bestIdx == -1 || idx < bestIdx) {
|
||||
bestIdx = idx
|
||||
bestLen = len(kw)
|
||||
}
|
||||
}
|
||||
if bestIdx == -1 {
|
||||
return false, "", -1
|
||||
}
|
||||
return true, caption[bestIdx : bestIdx+bestLen], bestIdx
|
||||
}
|
||||
|
||||
func truncationThreshold(platform string) int {
|
||||
if t, ok := truncationThresholds[platform]; ok {
|
||||
return t
|
||||
}
|
||||
return defaultTruncationThreshold
|
||||
}
|
||||
|
||||
// ParsePayload rekonstruiert Facts aus einem zuvor gespeicherten
|
||||
// RawJSON-Payload (z. B. aus extraction.payload). platform/jurisdiction
|
||||
// müssen erneut mitgegeben werden — sie sind, wie bei Extract, nie Teil
|
||||
// des gespeicherten Payloads.
|
||||
func ParsePayload(payload []byte, platform, jurisdiction string) (rules.Facts, error) {
|
||||
var args extractionArgs
|
||||
if err := json.Unmarshal(payload, &args); err != nil {
|
||||
return rules.Facts{}, fmt.Errorf("extract: payload parse: %w", err)
|
||||
}
|
||||
|
||||
consideration, err := parseConsideration(args.Consideration)
|
||||
if err != nil {
|
||||
return rules.Facts{}, fmt.Errorf("extract: %w", err)
|
||||
}
|
||||
|
||||
return rules.Facts{
|
||||
Platform: platform,
|
||||
Jurisdiction: jurisdiction,
|
||||
Consideration: consideration,
|
||||
DisclosurePresent: args.DisclosurePresent,
|
||||
DisclosureWording: args.DisclosureWording,
|
||||
DisclosureBeforeCut: args.DisclosureBeforeCut,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// extractionArgs ist die gespeicherte Form eines Extraktionsergebnisses.
|
||||
type extractionArgs struct {
|
||||
Consideration string `json:"gegenleistung"`
|
||||
DisclosurePresent bool `json:"kennzeichnung_vorhanden"`
|
||||
DisclosureWording string `json:"kennzeichnung_wortlaut"`
|
||||
DisclosureBeforeCut bool `json:"kennzeichnung_vor_kuerzung"`
|
||||
}
|
||||
|
||||
func parseConsideration(s string) (rules.Consideration, error) {
|
||||
switch rules.Consideration(s) {
|
||||
case rules.ConsiderationPaid, rules.ConsiderationInKind, rules.ConsiderationNone, rules.ConsiderationUnclear:
|
||||
return rules.Consideration(s), nil
|
||||
default:
|
||||
return "", fmt.Errorf("ungültiger gegenleistung-Wert %q — muss vom Einreichenden angegeben werden", s)
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
package extract_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/extract"
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
)
|
||||
|
||||
func TestExtractRejectsEmptyCaption(t *testing.T) {
|
||||
e := extract.NewEngine()
|
||||
if _, err := e.Extract(context.Background(), extract.Input{Platform: "instagram", Consideration: "bezahlt"}); err == nil {
|
||||
t.Fatal("expected error for empty caption, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRejectsMissingConsideration(t *testing.T) {
|
||||
e := extract.NewEngine()
|
||||
if _, err := e.Extract(context.Background(), extract.Input{Platform: "instagram", Caption: "irgendein Text"}); err == nil {
|
||||
t.Fatal("expected error for missing consideration, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRejectsInvalidConsideration(t *testing.T) {
|
||||
e := extract.NewEngine()
|
||||
_, err := e.Extract(context.Background(), extract.Input{
|
||||
Platform: "instagram", Caption: "Text", Consideration: "vielleicht",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for an out-of-enum consideration value, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDetectsDisclosureKeyword(t *testing.T) {
|
||||
e := extract.NewEngine()
|
||||
result, err := e.Extract(context.Background(), extract.Input{
|
||||
Platform: "instagram", Consideration: "bezahlt",
|
||||
Caption: "Werbung: Schaut euch dieses tolle Produkt an!",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
}
|
||||
if !result.Facts.DisclosurePresent {
|
||||
t.Error("expected DisclosurePresent = true")
|
||||
}
|
||||
if result.Facts.DisclosureWording != "Werbung" {
|
||||
t.Errorf("DisclosureWording = %q, want Werbung (original casing preserved)", result.Facts.DisclosureWording)
|
||||
}
|
||||
if !result.Facts.DisclosureBeforeCut {
|
||||
t.Error("expected DisclosureBeforeCut = true (Werbung is at index 0)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractIsCaseInsensitive(t *testing.T) {
|
||||
e := extract.NewEngine()
|
||||
result, err := e.Extract(context.Background(), extract.Input{
|
||||
Platform: "instagram", Consideration: "bezahlt", Caption: "WERBUNG fuer ein Produkt",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
}
|
||||
if !result.Facts.DisclosurePresent {
|
||||
t.Fatal("expected case-insensitive match to find the keyword")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDetectsDisclosureAfterTruncationCut(t *testing.T) {
|
||||
e := extract.NewEngine()
|
||||
// 130 Fuellzeichen vor "Werbung" -> jenseits der Instagram-Schwelle (125).
|
||||
padding := strings.Repeat("x", 130)
|
||||
result, err := e.Extract(context.Background(), extract.Input{
|
||||
Platform: "instagram", Consideration: "bezahlt", Caption: padding + " Werbung",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
}
|
||||
if !result.Facts.DisclosurePresent {
|
||||
t.Fatal("expected the keyword to still be found even though it's late in the caption")
|
||||
}
|
||||
if result.Facts.DisclosureBeforeCut {
|
||||
t.Error("expected DisclosureBeforeCut = false when the keyword appears after the platform's truncation threshold")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractNoDisclosureFound(t *testing.T) {
|
||||
e := extract.NewEngine()
|
||||
result, err := e.Extract(context.Background(), extract.Input{
|
||||
Platform: "instagram", Consideration: "keine", Caption: "Ein ganz normaler Tag im Park.",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
}
|
||||
if result.Facts.DisclosurePresent {
|
||||
t.Error("expected DisclosurePresent = false when no keyword is present")
|
||||
}
|
||||
if result.Facts.DisclosureBeforeCut {
|
||||
t.Error("expected DisclosureBeforeCut = false when there is no disclosure at all")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPassesThroughUnclearConsideration(t *testing.T) {
|
||||
e := extract.NewEngine()
|
||||
result, err := e.Extract(context.Background(), extract.Input{
|
||||
Platform: "instagram", Consideration: "unklar", Caption: "Text ohne klare Angabe",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
}
|
||||
if result.Facts.Consideration != rules.ConsiderationUnclear {
|
||||
t.Fatalf("Consideration = %q, want unklar", result.Facts.Consideration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDifferentPlatformThresholds(t *testing.T) {
|
||||
e := extract.NewEngine()
|
||||
// 140 Fuellzeichen: unter der TikTok-Schwelle (150), aber ueber der
|
||||
// Instagram-Schwelle (125) -> gleiche Caption, unterschiedliches Ergebnis.
|
||||
padding := strings.Repeat("x", 140)
|
||||
caption := padding + " Werbung"
|
||||
|
||||
igResult, err := e.Extract(context.Background(), extract.Input{Platform: "instagram", Consideration: "bezahlt", Caption: caption})
|
||||
if err != nil {
|
||||
t.Fatalf("Extract (instagram): %v", err)
|
||||
}
|
||||
ttResult, err := e.Extract(context.Background(), extract.Input{Platform: "tiktok", Consideration: "bezahlt", Caption: caption})
|
||||
if err != nil {
|
||||
t.Fatalf("Extract (tiktok): %v", err)
|
||||
}
|
||||
|
||||
if igResult.Facts.DisclosureBeforeCut {
|
||||
t.Error("expected DisclosureBeforeCut = false for instagram at this length")
|
||||
}
|
||||
if !ttResult.Facts.DisclosureBeforeCut {
|
||||
t.Error("expected DisclosureBeforeCut = true for tiktok at this length")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRawJSONRoundTripsThroughParsePayload(t *testing.T) {
|
||||
e := extract.NewEngine()
|
||||
result, err := e.Extract(context.Background(), extract.Input{
|
||||
Platform: "instagram", Jurisdiction: "DE", Consideration: "sachbezug",
|
||||
Caption: "Anzeige: dieses Produkt wurde mir geschenkt",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
}
|
||||
|
||||
facts, err := extract.ParsePayload(result.RawJSON, "instagram", "DE")
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePayload: %v", err)
|
||||
}
|
||||
if facts != result.Facts {
|
||||
t.Fatalf("ParsePayload(Extract().RawJSON) = %+v, want %+v", facts, result.Facts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePayloadRejectsInvalidConsideration(t *testing.T) {
|
||||
payload := []byte(`{"gegenleistung":"vielleicht","kennzeichnung_vorhanden":false,"kennzeichnung_wortlaut":"","kennzeichnung_vor_kuerzung":false}`)
|
||||
if _, err := extract.ParsePayload(payload, "tiktok", "DE"); err == nil {
|
||||
t.Fatal("expected error for out-of-enum stored payload, got nil")
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,264 @@
|
||||
package rules
|
||||
|
||||
// Finding ist das Ergebnis einer einzelnen zutreffenden Regel.
|
||||
type Finding struct {
|
||||
RuleID string
|
||||
RuleVersion int
|
||||
Severity Severity
|
||||
Title string
|
||||
Fix string
|
||||
Sources []string
|
||||
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
|
||||
}
|
||||
|
||||
// Evaluate prüft alle Regeln gegen f und liefert ein Finding für jede
|
||||
// zutreffende Regel. Reihenfolge folgt der Reihenfolge von rules.
|
||||
//
|
||||
// Ist f.Consideration "unklar", wird KEINE Bewertung abgegeben —
|
||||
// needsClarification ist dann true und findings ist immer leer. Das ist
|
||||
// Absicht (Kernprinzip): eine unsichere Extraktion erzeugt eine
|
||||
// Rückfrage an den Nutzer, niemals eine stille "keine Findings"-
|
||||
// Bewertung, die wie "alles in Ordnung" aussähe.
|
||||
func Evaluate(rules []Rule, f Facts) (findings []Finding, needsClarification bool) {
|
||||
if f.Consideration == ConsiderationUnclear {
|
||||
return nil, true
|
||||
}
|
||||
|
||||
for _, r := range rules {
|
||||
if r.Jurisdiction != f.Jurisdiction {
|
||||
continue
|
||||
}
|
||||
if r.Condition.Matches(f) {
|
||||
findings = append(findings, Finding{
|
||||
RuleID: r.ID,
|
||||
RuleVersion: r.Version,
|
||||
Severity: r.Severity,
|
||||
Title: r.Title,
|
||||
Fix: r.Fix,
|
||||
Sources: r.Sources,
|
||||
})
|
||||
}
|
||||
}
|
||||
return findings, false
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package rules
|
||||
|
||||
// Consideration ist die Gegenleistung für einen Beitrag, wie sie die
|
||||
// Extraktion (Stufe 1) liefert.
|
||||
type Consideration string
|
||||
|
||||
const (
|
||||
ConsiderationPaid Consideration = "bezahlt"
|
||||
ConsiderationInKind Consideration = "sachbezug"
|
||||
ConsiderationNone Consideration = "keine"
|
||||
ConsiderationUnclear Consideration = "unklar"
|
||||
)
|
||||
|
||||
// Facts sind die Fakten aus der Extraktion (Stufe 1), auf denen die
|
||||
// Regelauswertung (Stufe 2) urteilt. Die Extraktion liefert diese Werte,
|
||||
// sie bewertet sie nicht — das Urteil fällt ausschließlich das Regelwerk.
|
||||
//
|
||||
// Jurisdiction ist keine vom Modell extrahierte Tatsache (aus Caption/
|
||||
// Bild lässt sich keine Rechtsordnung ablesen), sondern kommt vom
|
||||
// Aufrufer — analog zu Platform. Aktuell ist "DE" der einzig unterstützte
|
||||
// Wert; siehe Rule.Jurisdiction.
|
||||
type Facts struct {
|
||||
Platform string `json:"platform"`
|
||||
Jurisdiction string `json:"jurisdiction"`
|
||||
Consideration Consideration `json:"consideration"`
|
||||
DisclosurePresent bool `json:"disclosure_present"`
|
||||
DisclosureWording string `json:"disclosure_wording"`
|
||||
DisclosureBeforeCut bool `json:"disclosure_before_cut"`
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package rules_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
)
|
||||
|
||||
// goldenCase spiegelt eine Datei aus testdata/golden/: die extrahierten
|
||||
// Fakten eines Beispielbeitrags plus die Findings, die das Regelwerk
|
||||
// dafür liefern muss. Das ist das eigentliche Asset des Projekts, nicht
|
||||
// die UI — siehe CLAUDE.md.
|
||||
type goldenCase struct {
|
||||
Name string `json:"name"`
|
||||
Facts rules.Facts `json:"facts"`
|
||||
ExpectedFindings []goldenFinding `json:"expected_findings"`
|
||||
ExpectNeedsClarification bool `json:"expect_needs_clarification"`
|
||||
}
|
||||
|
||||
type goldenFinding struct {
|
||||
RuleID string `json:"rule_id"`
|
||||
RuleVersion int `json:"rule_version"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
|
||||
const (
|
||||
rulesDir = "../../rules"
|
||||
goldenDir = "../../testdata/golden"
|
||||
)
|
||||
|
||||
func TestGolden(t *testing.T) {
|
||||
ruleSet, err := rules.Load(os.DirFS(rulesDir))
|
||||
if err != nil {
|
||||
t.Fatalf("Load rules: %v", err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(goldenDir)
|
||||
if err != nil {
|
||||
t.Fatalf("read golden dir: %v", err)
|
||||
}
|
||||
|
||||
found := 0
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
found++
|
||||
entry := entry
|
||||
t.Run(entry.Name(), func(t *testing.T) {
|
||||
data, err := os.ReadFile(filepath.Join(goldenDir, entry.Name()))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", entry.Name(), err)
|
||||
}
|
||||
var gc goldenCase
|
||||
if err := json.Unmarshal(data, &gc); err != nil {
|
||||
t.Fatalf("parse %s: %v", entry.Name(), err)
|
||||
}
|
||||
|
||||
got, needsClarification := rules.Evaluate(ruleSet, gc.Facts)
|
||||
|
||||
if needsClarification != gc.ExpectNeedsClarification {
|
||||
t.Fatalf("%s: needsClarification = %v, want %v", gc.Name, needsClarification, gc.ExpectNeedsClarification)
|
||||
}
|
||||
|
||||
gotKeys := make([]string, 0, len(got))
|
||||
for _, f := range got {
|
||||
gotKeys = append(gotKeys, findingKey(f.RuleID, f.RuleVersion, string(f.Severity)))
|
||||
}
|
||||
wantKeys := make([]string, 0, len(gc.ExpectedFindings))
|
||||
for _, ef := range gc.ExpectedFindings {
|
||||
wantKeys = append(wantKeys, findingKey(ef.RuleID, ef.RuleVersion, ef.Severity))
|
||||
}
|
||||
sort.Strings(gotKeys)
|
||||
sort.Strings(wantKeys)
|
||||
|
||||
if !equalStrings(gotKeys, wantKeys) {
|
||||
t.Fatalf("%s: got findings %v, want %v", gc.Name, gotKeys, wantKeys)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if found == 0 {
|
||||
t.Fatal("no golden cases found in " + goldenDir)
|
||||
}
|
||||
}
|
||||
|
||||
func findingKey(ruleID string, version int, severity string) string {
|
||||
return ruleID + "/" + strconv.Itoa(version) + "/" + severity
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -3,50 +3,109 @@ package rules
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Load liest alle *.yaml-Dateien aus fsys (nicht rekursiv) und parst sie
|
||||
// als Regeln. Ein Fehler in einer Datei (Parse-Fehler, fehlende ID/
|
||||
// Version) bricht das Laden komplett ab, statt die Datei stillschweigend
|
||||
// zu überspringen — ein halb geladenes Regelwerk ist gefährlicher als
|
||||
// ein Start, der mit einem klaren Fehler abbricht.
|
||||
func Load(fsys fs.FS) ([]Rule, error) {
|
||||
entries, err := fs.ReadDir(fsys, ".")
|
||||
// LoadDatenklasse lädt und validiert die Datenklasse-Ableitungstabelle.
|
||||
func LoadDatenklasse(fsys fs.FS, path string) (DatenklasseRegelwerk, error) {
|
||||
var rw DatenklasseRegelwerk
|
||||
data, err := fs.ReadFile(fsys, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rules: read dir: %w", err)
|
||||
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse lesen: %w", err)
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var result []Rule
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".yaml" {
|
||||
continue
|
||||
}
|
||||
data, err := fs.ReadFile(fsys, entry.Name())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rules: read %s: %w", entry.Name(), err)
|
||||
}
|
||||
var r Rule
|
||||
if err := yaml.Unmarshal(data, &r); err != nil {
|
||||
return nil, fmt.Errorf("rules: parse %s: %w", entry.Name(), err)
|
||||
}
|
||||
if r.ID == "" {
|
||||
return nil, fmt.Errorf("rules: %s: missing id", entry.Name())
|
||||
}
|
||||
if r.Version == 0 {
|
||||
return nil, fmt.Errorf("rules: %s: missing version", entry.Name())
|
||||
}
|
||||
if r.Jurisdiction == "" {
|
||||
return nil, fmt.Errorf("rules: %s: missing land (jurisdiction)", entry.Name())
|
||||
}
|
||||
if seen[r.ID] {
|
||||
return nil, fmt.Errorf("rules: %s: duplicate rule id %s", entry.Name(), r.ID)
|
||||
}
|
||||
seen[r.ID] = true
|
||||
result = append(result, r)
|
||||
if err := yaml.Unmarshal(data, &rw); err != nil {
|
||||
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse parsen: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
if rw.Version < 1 {
|
||||
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: version fehlt oder ungültig")
|
||||
}
|
||||
if len(rw.Stufen) == 0 {
|
||||
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: keine stufen definiert")
|
||||
}
|
||||
seenIDs := map[string]bool{}
|
||||
seenRaenge := map[int]string{}
|
||||
for _, st := range rw.Stufen {
|
||||
if st.ID == "" {
|
||||
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: stufe ohne id")
|
||||
}
|
||||
if seenIDs[st.ID] {
|
||||
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: doppelte stufe-id %q", st.ID)
|
||||
}
|
||||
seenIDs[st.ID] = true
|
||||
if other, ok := seenRaenge[st.Rang]; ok {
|
||||
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: stufen %q und %q teilen sich rang %d — \"höchste Stufe gewinnt\" braucht eindeutige Ränge", st.ID, other, st.Rang)
|
||||
}
|
||||
seenRaenge[st.Rang] = st.ID
|
||||
if len(st.Ausloeser) == 0 {
|
||||
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: stufe %q hat keine ausloeser", st.ID)
|
||||
}
|
||||
}
|
||||
return rw, nil
|
||||
}
|
||||
|
||||
// LoadEinstufung lädt und validiert die KI-VO-Einstufungstabelle.
|
||||
func LoadEinstufung(fsys fs.FS, path string) (EinstufungRegelwerk, error) {
|
||||
var rw EinstufungRegelwerk
|
||||
data, err := fs.ReadFile(fsys, path)
|
||||
if err != nil {
|
||||
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung lesen: %w", err)
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &rw); err != nil {
|
||||
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung parsen: %w", err)
|
||||
}
|
||||
if rw.Version < 1 {
|
||||
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: version fehlt oder ungültig")
|
||||
}
|
||||
if len(rw.Stufen) == 0 {
|
||||
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: keine stufen definiert")
|
||||
}
|
||||
seenIDs := map[string]bool{}
|
||||
for i, st := range rw.Stufen {
|
||||
if st.ID == "" {
|
||||
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: stufe ohne id")
|
||||
}
|
||||
if seenIDs[st.ID] {
|
||||
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: doppelte stufe-id %q", st.ID)
|
||||
}
|
||||
seenIDs[st.ID] = true
|
||||
// Die letzte Stufe darf eine Auffangregel ohne Bedingungen sein
|
||||
// (z. B. "minimal"), alle davor brauchen mindestens eine Variante.
|
||||
if len(st.Varianten) == 0 && i != len(rw.Stufen)-1 {
|
||||
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: stufe %q hat keine varianten (nur die letzte Stufe darf eine Auffangregel sein)", st.ID)
|
||||
}
|
||||
}
|
||||
return rw, nil
|
||||
}
|
||||
|
||||
// LoadAnforderungen lädt und validiert die Anforderungsprofil-Tabelle.
|
||||
func LoadAnforderungen(fsys fs.FS, path string) (AnforderungsRegelwerk, error) {
|
||||
var rw AnforderungsRegelwerk
|
||||
data, err := fs.ReadFile(fsys, path)
|
||||
if err != nil {
|
||||
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen lesen: %w", err)
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &rw); err != nil {
|
||||
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen parsen: %w", err)
|
||||
}
|
||||
if rw.Version < 1 {
|
||||
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen: version fehlt oder ungültig")
|
||||
}
|
||||
if len(rw.Anforderungen) == 0 {
|
||||
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen: keine anforderungen definiert")
|
||||
}
|
||||
seenIDs := map[string]bool{}
|
||||
for _, a := range rw.Anforderungen {
|
||||
if a.ID == "" {
|
||||
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen: anforderung ohne id")
|
||||
}
|
||||
if seenIDs[a.ID] {
|
||||
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen: doppelte anforderung-id %q", a.ID)
|
||||
}
|
||||
seenIDs[a.ID] = true
|
||||
if len(a.AusDatenklassen) == 0 && len(a.AusEinstufungen) == 0 {
|
||||
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen: anforderung %q hat weder aus_datenklassen noch aus_einstufungen", a.ID)
|
||||
}
|
||||
}
|
||||
return rw, nil
|
||||
}
|
||||
|
||||
132
internal/rules/loader_test.go
Normal file
132
internal/rules/loader_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package rules_test
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
)
|
||||
|
||||
func realRulesFS(t *testing.T) fs.FS {
|
||||
t.Helper()
|
||||
return os.DirFS("../../rules")
|
||||
}
|
||||
|
||||
func TestLoadDatenklasseRealFile(t *testing.T) {
|
||||
rw, err := rules.LoadDatenklasse(realRulesFS(t), "datenklasse.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDatenklasse: %v", err)
|
||||
}
|
||||
if rw.Version != 1 {
|
||||
t.Fatalf("Version = %d, want 1", rw.Version)
|
||||
}
|
||||
if len(rw.Stufen) != 6 {
|
||||
t.Fatalf("expected 6 Stufen, got %d", len(rw.Stufen))
|
||||
}
|
||||
byID := map[string]rules.DatenklasseStufe{}
|
||||
for _, st := range rw.Stufen {
|
||||
byID[st.ID] = st
|
||||
}
|
||||
for _, id := range []string{"oeffentlich", "intern", "auftragsdaten", "personenbezogen", "berufsgeheimnis", "besondere_kategorie"} {
|
||||
if _, ok := byID[id]; !ok {
|
||||
t.Errorf("expected Stufe %q to be defined", id)
|
||||
}
|
||||
}
|
||||
if byID["besondere_kategorie"].Rang <= byID["personenbezogen"].Rang {
|
||||
t.Error("expected besondere_kategorie to outrank personenbezogen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDatenklasseRejectsDuplicateRang(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"datenklasse.yaml": &fstest.MapFile{Data: []byte(`
|
||||
version: 1
|
||||
stufen:
|
||||
- {id: a, rang: 1, ausloeser: [b1]}
|
||||
- {id: b, rang: 1, ausloeser: [b2]}
|
||||
`)},
|
||||
}
|
||||
if _, err := rules.LoadDatenklasse(fsys, "datenklasse.yaml"); err == nil {
|
||||
t.Fatal("expected an error for duplicate rang values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDatenklasseRejectsMissingVersion(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"datenklasse.yaml": &fstest.MapFile{Data: []byte(`
|
||||
stufen:
|
||||
- {id: a, rang: 1, ausloeser: [b1]}
|
||||
`)},
|
||||
}
|
||||
if _, err := rules.LoadDatenklasse(fsys, "datenklasse.yaml"); err == nil {
|
||||
t.Fatal("expected an error for a missing version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEinstufungRealFile(t *testing.T) {
|
||||
rw, err := rules.LoadEinstufung(realRulesFS(t), "kivo_einstufung.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadEinstufung: %v", err)
|
||||
}
|
||||
if len(rw.Stufen) != 4 {
|
||||
t.Fatalf("expected 4 Stufen, got %d", len(rw.Stufen))
|
||||
}
|
||||
if rw.Stufen[0].ID != "verboten" {
|
||||
t.Fatalf("expected 'verboten' to be checked first (K.-o.-Prüfung), got %q", rw.Stufen[0].ID)
|
||||
}
|
||||
if rw.Stufen[len(rw.Stufen)-1].ID != "minimal" {
|
||||
t.Fatalf("expected 'minimal' as the last (catch-all) Stufe, got %q", rw.Stufen[len(rw.Stufen)-1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEinstufungRejectsNonLastStufeWithoutVarianten(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"kivo_einstufung.yaml": &fstest.MapFile{Data: []byte(`
|
||||
version: 1
|
||||
stufen:
|
||||
- {id: verboten, varianten: []}
|
||||
- {id: minimal, varianten: []}
|
||||
`)},
|
||||
}
|
||||
if _, err := rules.LoadEinstufung(fsys, "kivo_einstufung.yaml"); err == nil {
|
||||
t.Fatal("expected an error when a non-last Stufe has no Varianten")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAnforderungenRealFile(t *testing.T) {
|
||||
rw, err := rules.LoadAnforderungen(realRulesFS(t), "anforderungen.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAnforderungen: %v", err)
|
||||
}
|
||||
wantIDs := []string{
|
||||
"avv_erforderlich", "eu_verarbeitung", "kein_training_auf_eingabe",
|
||||
"loeschfrist_max_tage", "menschliche_aufsicht", "kennzeichnungspflicht", "dsfa_erforderlich",
|
||||
}
|
||||
if len(rw.Anforderungen) != len(wantIDs) {
|
||||
t.Fatalf("expected %d Anforderungen, got %d", len(wantIDs), len(rw.Anforderungen))
|
||||
}
|
||||
byID := map[string]bool{}
|
||||
for _, a := range rw.Anforderungen {
|
||||
byID[a.ID] = true
|
||||
}
|
||||
for _, id := range wantIDs {
|
||||
if !byID[id] {
|
||||
t.Errorf("expected Anforderung %q to be defined", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAnforderungenRejectsAnforderungWithoutTrigger(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"anforderungen.yaml": &fstest.MapFile{Data: []byte(`
|
||||
version: 1
|
||||
anforderungen:
|
||||
- {id: x}
|
||||
`)},
|
||||
}
|
||||
if _, err := rules.LoadAnforderungen(fsys, "anforderungen.yaml"); err == nil {
|
||||
t.Fatal("expected an error for an Anforderung without any trigger")
|
||||
}
|
||||
}
|
||||
68
internal/rules/regelwerk.go
Normal file
68
internal/rules/regelwerk.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Package rules lädt das Bewertungsregelwerk (Datenklasse-Ableitung,
|
||||
// KI-VO-Einstufung, Anforderungsprofil) aus versionierten YAML-Dateien.
|
||||
// Regelbasiert, nicht ML — die Regeln liegen als Daten vor, nicht im
|
||||
// Code (siehe CLAUDE.md). Dieses Paket lädt und validiert nur die
|
||||
// Struktur; die eigentliche Auswertung gegen Fragebogen-Antworten ist
|
||||
// bewusst noch nicht Teil von Phase 1 (siehe rules/OPEN.md und die
|
||||
// Baureihenfolge in CLAUDE.md — "Ableitungen und harte Filter" ist ein
|
||||
// eigener, späterer Schritt, sobald der Fragebogen aus Phase 2 die
|
||||
// exakten Fakten-Feldnamen festlegt).
|
||||
package rules
|
||||
|
||||
// DatenklasseStufe ist eine mögliche Datenklasse mit ihren Auslösern aus
|
||||
// Fragebogen-Abschnitt B. Rang bestimmt, welche Stufe gewinnt, wenn
|
||||
// mehrere Auslöser gleichzeitig zutreffen ("höchste zutreffende Stufe
|
||||
// gewinnt") — höherer Rang gewinnt.
|
||||
type DatenklasseStufe struct {
|
||||
ID string `yaml:"id"`
|
||||
Rang int `yaml:"rang"`
|
||||
Ausloeser []string `yaml:"ausloeser"`
|
||||
Folge string `yaml:"folge"`
|
||||
}
|
||||
|
||||
// DatenklasseRegelwerk ist die vollständige Ableitungstabelle für
|
||||
// Fragebogen-Abschnitt B.
|
||||
type DatenklasseRegelwerk struct {
|
||||
Version int `yaml:"version"`
|
||||
Stufen []DatenklasseStufe `yaml:"stufen"`
|
||||
}
|
||||
|
||||
// EinstufungVariante ist eine mögliche Kombination von Fragebogen-Fakten
|
||||
// (Abschnitt C), die zu einer KI-VO-Einstufung führt — mehrere Varianten
|
||||
// sind ODER-verknüpft, die Felder innerhalb einer Variante UND-verknüpft.
|
||||
// Bewusst als freie Schlüssel-Werte-Paare statt fester Go-Felder: welche
|
||||
// Fakten-Schlüssel es gibt, legt der Fragebogen aus Phase 2 fest, nicht
|
||||
// dieses Paket.
|
||||
type EinstufungVariante map[string]any
|
||||
|
||||
// EinstufungStufe ist eine mögliche KI-VO-Einstufung.
|
||||
type EinstufungStufe struct {
|
||||
ID string `yaml:"id"`
|
||||
Quelle string `yaml:"quelle,omitempty"`
|
||||
Varianten []EinstufungVariante `yaml:"varianten"`
|
||||
}
|
||||
|
||||
// EinstufungRegelwerk ist die vollständige Ableitungstabelle für
|
||||
// Fragebogen-Abschnitt C. Die Reihenfolge der Stufen ist Prüfreihenfolge
|
||||
// (erste zutreffende Stufe gewinnt) — siehe Bewertungslogik, Schritt 1
|
||||
// (K.-o.-Prüfung: "verboten" muss zuerst geprüft werden).
|
||||
type EinstufungRegelwerk struct {
|
||||
Version int `yaml:"version"`
|
||||
Stufen []EinstufungStufe `yaml:"stufen"`
|
||||
}
|
||||
|
||||
// AnforderungsRegel leitet eine einzelne Anforderung aus Datenklasse
|
||||
// und/oder KI-VO-Einstufung ab (z. B. avv_erforderlich).
|
||||
type AnforderungsRegel struct {
|
||||
ID string `yaml:"id"`
|
||||
AusDatenklassen []string `yaml:"aus_datenklassen,omitempty"`
|
||||
AusEinstufungen []string `yaml:"aus_einstufungen,omitempty"`
|
||||
Beschreibung string `yaml:"beschreibung,omitempty"`
|
||||
}
|
||||
|
||||
// AnforderungsRegelwerk ist die vollständige Mapping-Tabelle
|
||||
// Datenklasse/Einstufung -> Anforderungsprofil.
|
||||
type AnforderungsRegelwerk struct {
|
||||
Version int `yaml:"version"`
|
||||
Anforderungen []AnforderungsRegel `yaml:"anforderungen"`
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package rules
|
||||
|
||||
// Severity ist die Schwere eines Findings.
|
||||
type Severity string
|
||||
|
||||
const (
|
||||
SeverityLow Severity = "niedrig"
|
||||
SeverityMedium Severity = "mittel"
|
||||
SeverityHigh Severity = "hoch"
|
||||
)
|
||||
|
||||
// Rule ist eine versionierte Regel aus einer YAML-Datei in rules/.
|
||||
// Regel-IDs werden nie umbenannt oder wiederverwendet — Änderungen an
|
||||
// einer Regel erhöhen die Version.
|
||||
//
|
||||
// Jurisdiction ordnet eine Regel genau einer Rechtsordnung zu (aktuell
|
||||
// nur "DE" — deutsche Rechtslage zuerst). Damit lassen sich künftige
|
||||
// AT/CH-Regeln als zusätzliche Dateien ergänzen, ohne bestehende Regeln
|
||||
// anzufassen: eine Regel gilt nie für mehrere Rechtsordnungen gleichzeitig,
|
||||
// auch wenn sich Gesetzestexte ähneln — jede Rechtsordnung bekommt ihre
|
||||
// eigene, einzeln geprüfte Fundstelle.
|
||||
type Rule struct {
|
||||
ID string `yaml:"id"`
|
||||
Version int `yaml:"version"`
|
||||
Jurisdiction string `yaml:"land"`
|
||||
Title string `yaml:"titel"`
|
||||
Condition Condition `yaml:"bedingung"`
|
||||
Severity Severity `yaml:"schwere"`
|
||||
Sources []string `yaml:"fundstelle"`
|
||||
Fix string `yaml:"korrektur"`
|
||||
}
|
||||
|
||||
// Condition ist eine flache UND-Bedingung über Facts. Ein nil/leeres
|
||||
// Feld bedeutet "keine Einschränkung durch dieses Feld".
|
||||
type Condition struct {
|
||||
Consideration []Consideration `yaml:"gegenleistung,omitempty"`
|
||||
DisclosurePresent *bool `yaml:"kennzeichnung_vorhanden,omitempty"`
|
||||
DisclosureBeforeCut *bool `yaml:"kennzeichnung_vor_kuerzung,omitempty"`
|
||||
}
|
||||
|
||||
// Matches prüft, ob f alle gesetzten Bedingungsfelder erfüllt.
|
||||
func (c Condition) Matches(f Facts) bool {
|
||||
if len(c.Consideration) > 0 {
|
||||
found := false
|
||||
for _, allowed := range c.Consideration {
|
||||
if allowed == f.Consideration {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if c.DisclosurePresent != nil && *c.DisclosurePresent != f.DisclosurePresent {
|
||||
return false
|
||||
}
|
||||
if c.DisclosureBeforeCut != nil && *c.DisclosureBeforeCut != f.DisclosureBeforeCut {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package rules_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
)
|
||||
|
||||
func TestLoadRejectsMissingID(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"bad.yaml": &fstest.MapFile{Data: []byte("version: 1\ntitel: x\nschwere: hoch\n")},
|
||||
}
|
||||
if _, err := rules.Load(fsys); err == nil {
|
||||
t.Fatal("expected error for rule without id, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsMissingVersion(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"bad.yaml": &fstest.MapFile{Data: []byte("id: WK-999\ntitel: x\nschwere: hoch\n")},
|
||||
}
|
||||
if _, err := rules.Load(fsys); err == nil {
|
||||
t.Fatal("expected error for rule without version, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsMissingJurisdiction(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"bad.yaml": &fstest.MapFile{Data: []byte("id: WK-999\nversion: 1\ntitel: x\nschwere: hoch\n")},
|
||||
}
|
||||
if _, err := rules.Load(fsys); err == nil {
|
||||
t.Fatal("expected error for rule without land (jurisdiction), got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsDuplicateID(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"a.yaml": &fstest.MapFile{Data: []byte("id: WK-999\nversion: 1\nland: DE\ntitel: x\nschwere: hoch\n")},
|
||||
"b.yaml": &fstest.MapFile{Data: []byte("id: WK-999\nversion: 2\nland: DE\ntitel: y\nschwere: hoch\n")},
|
||||
}
|
||||
if _, err := rules.Load(fsys); err == nil {
|
||||
t.Fatal("expected error for duplicate rule id, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadIgnoresNonYAMLFiles(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"a.yaml": &fstest.MapFile{Data: []byte("id: WK-999\nversion: 1\nland: DE\ntitel: x\nschwere: hoch\n")},
|
||||
"README.md": &fstest.MapFile{Data: []byte("not a rule")},
|
||||
}
|
||||
got, err := rules.Load(fsys)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 rule, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionMatches(t *testing.T) {
|
||||
yes := true
|
||||
no := false
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
cond rules.Condition
|
||||
fact rules.Facts
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "consideration list matches",
|
||||
cond: rules.Condition{Consideration: []rules.Consideration{rules.ConsiderationPaid}},
|
||||
fact: rules.Facts{Consideration: rules.ConsiderationPaid},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "consideration list does not match",
|
||||
cond: rules.Condition{Consideration: []rules.Consideration{rules.ConsiderationPaid}},
|
||||
fact: rules.Facts{Consideration: rules.ConsiderationNone},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "disclosure present must match",
|
||||
cond: rules.Condition{DisclosurePresent: &no},
|
||||
fact: rules.Facts{DisclosurePresent: true},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "unset fields impose no constraint",
|
||||
cond: rules.Condition{},
|
||||
fact: rules.Facts{Consideration: rules.ConsiderationUnclear},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "all constraints must hold (AND)",
|
||||
cond: rules.Condition{
|
||||
Consideration: []rules.Consideration{rules.ConsiderationPaid},
|
||||
DisclosurePresent: &yes,
|
||||
DisclosureBeforeCut: &no,
|
||||
},
|
||||
fact: rules.Facts{Consideration: rules.ConsiderationPaid, DisclosurePresent: true, DisclosureBeforeCut: false},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.cond.Matches(tc.fact); got != tc.want {
|
||||
t.Fatalf("Matches() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateFiltersByJurisdiction(t *testing.T) {
|
||||
rule := rules.Rule{
|
||||
ID: "WK-TEST",
|
||||
Version: 1,
|
||||
Jurisdiction: "AT",
|
||||
Condition: rules.Condition{Consideration: []rules.Consideration{rules.ConsiderationPaid}},
|
||||
Severity: rules.SeverityHigh,
|
||||
}
|
||||
fact := rules.Facts{Jurisdiction: "DE", Consideration: rules.ConsiderationPaid}
|
||||
|
||||
findings, needsClarification := rules.Evaluate([]rules.Rule{rule}, fact)
|
||||
if needsClarification {
|
||||
t.Fatal("needsClarification = true, want false")
|
||||
}
|
||||
if len(findings) != 0 {
|
||||
t.Fatalf("expected no findings for a rule from a different jurisdiction, got %v", findings)
|
||||
}
|
||||
}
|
||||
85
internal/store/abteilung.go
Normal file
85
internal/store/abteilung.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Abteilung ist Stammdatum für den Fragebogen (Feld A.abteilung).
|
||||
type Abteilung struct {
|
||||
ID string
|
||||
AccountID string
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateAbteilung legt eine Abteilung für einen Mandanten an.
|
||||
func (s *Store) CreateAbteilung(ctx context.Context, accountID, name string) (Abteilung, error) {
|
||||
var a Abteilung
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO abteilung (account_id, name) VALUES ($1, $2)
|
||||
RETURNING id, account_id, name, created_at
|
||||
`, accountID, name).Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt)
|
||||
if err != nil {
|
||||
return Abteilung{}, fmt.Errorf("store: create abteilung: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ListAbteilungenForAccount liefert alle Abteilungen eines Mandanten,
|
||||
// alphabetisch — als Auswahlliste für den Fragebogen.
|
||||
func (s *Store) ListAbteilungenForAccount(ctx context.Context, accountID string) ([]Abteilung, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, account_id, name, created_at FROM abteilung
|
||||
WHERE account_id = $1 ORDER BY name
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list abteilungen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Abteilung
|
||||
for rows.Next() {
|
||||
var a Abteilung
|
||||
if err := rows.Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan abteilung: %w", err)
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list abteilungen: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetAbteilung liest eine Abteilung anhand ihrer ID.
|
||||
func (s *Store) GetAbteilung(ctx context.Context, id string) (Abteilung, error) {
|
||||
var a Abteilung
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, account_id, name, created_at FROM abteilung WHERE id = $1
|
||||
`, id).Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Abteilung{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Abteilung{}, fmt.Errorf("store: get abteilung: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// DeleteAbteilung entfernt eine Abteilung (z. B. versehentlich doppelt
|
||||
// angelegt).
|
||||
func (s *Store) DeleteAbteilung(ctx context.Context, id string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `DELETE FROM abteilung WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete abteilung: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
62
internal/store/abteilung_test.go
Normal file
62
internal/store/abteilung_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func TestAbteilungCRUD(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
a, err := s.CreateAbteilung(ctx, accID, "IT")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAbteilung: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.GetAbteilung(ctx, a.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAbteilung: %v", err)
|
||||
}
|
||||
if got.Name != "IT" || got.AccountID != accID {
|
||||
t.Fatalf("GetAbteilung = %+v, unerwartete Werte", got)
|
||||
}
|
||||
|
||||
list, err := s.ListAbteilungenForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAbteilungenForAccount: %v", err)
|
||||
}
|
||||
if len(list) != 1 || list[0].ID != a.ID {
|
||||
t.Fatalf("ListAbteilungenForAccount = %+v, want exactly the created Abteilung", list)
|
||||
}
|
||||
|
||||
if err := s.DeleteAbteilung(ctx, a.ID); err != nil {
|
||||
t.Fatalf("DeleteAbteilung: %v", err)
|
||||
}
|
||||
if _, err := s.GetAbteilung(ctx, a.ID); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err nach Delete = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbteilungIsolatedPerAccount(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accA := testAccountID(t, s)
|
||||
accB := testAccountID(t, s)
|
||||
|
||||
if _, err := s.CreateAbteilung(ctx, accA, "Marketing"); err != nil {
|
||||
t.Fatalf("CreateAbteilung: %v", err)
|
||||
}
|
||||
|
||||
list, err := s.ListAbteilungenForAccount(ctx, accB)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAbteilungenForAccount: %v", err)
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("expected no Abteilungen for Mandant B, got %+v", list)
|
||||
}
|
||||
}
|
||||
@@ -2,25 +2,43 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Account ist ein Mandant (Creator, Agentur, Marke oder Kanzlei als
|
||||
// eigene Organisation). Jeder Beitrag gehört genau einem Account.
|
||||
// Account ist ein Mandant (ein Unternehmen, das die Antragsprüfung
|
||||
// nutzt). Jeder Antrag gehört genau einem Account. EinladungToken ist
|
||||
// der Sammellink für die Mitarbeiter-Selbstanmeldung (Ebene 1,
|
||||
// "Einladung annehmen") — ein Token pro Account, per Admin erneuerbar.
|
||||
type Account struct {
|
||||
ID string
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
ID string
|
||||
Name string
|
||||
EinladungToken string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateAccount legt einen neuen Mandanten an.
|
||||
func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) {
|
||||
const accountColumns = `id, name, einladung_token, created_at`
|
||||
|
||||
func scanAccount(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (Account, error) {
|
||||
var a Account
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
err := row.Scan(&a.ID, &a.Name, &a.EinladungToken, &a.CreatedAt)
|
||||
return a, err
|
||||
}
|
||||
|
||||
// CreateAccount legt einen neuen Mandanten an. einladung_token wird von
|
||||
// der Datenbank per DEFAULT erzeugt (siehe Migration 0013).
|
||||
func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO account (name) VALUES ($1)
|
||||
RETURNING id, name, created_at
|
||||
`, name).Scan(&a.ID, &a.Name, &a.CreatedAt)
|
||||
RETURNING `+accountColumns,
|
||||
name,
|
||||
)
|
||||
a, err := scanAccount(row)
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("store: create account: %w", err)
|
||||
}
|
||||
@@ -29,12 +47,66 @@ func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error)
|
||||
|
||||
// GetAccount liest einen Mandanten anhand seiner ID.
|
||||
func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
|
||||
var a Account
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, name, created_at FROM account WHERE id = $1
|
||||
`, id).Scan(&a.ID, &a.Name, &a.CreatedAt)
|
||||
row := s.Pool.QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE id = $1`, id)
|
||||
a, err := scanAccount(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Account{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("store: get account: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// GetAccountByEinladungToken liest den Mandanten zu einem
|
||||
// Einladungslink — für die öffentliche Mitarbeiter-Selbstanmeldung
|
||||
// (Ebene 1, kein Login nötig). Liefert ErrNotFound bei unbekanntem
|
||||
// oder bereits erneuertem (damit ungültig gewordenem) Token.
|
||||
func (s *Store) GetAccountByEinladungToken(ctx context.Context, token string) (Account, error) {
|
||||
row := s.Pool.QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE einladung_token = $1`, token)
|
||||
a, err := scanAccount(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Account{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("store: get account by einladung token: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// RegenerateEinladungToken ersetzt den Einladungslink eines Mandanten
|
||||
// durch einen neuen — der alte Link wird damit sofort ungültig (z. B.
|
||||
// wenn er versehentlich außerhalb des Unternehmens geteilt wurde).
|
||||
func (s *Store) RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `UPDATE account SET einladung_token = $2 WHERE id = $1`, accountID, newToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: regenerate einladung token: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListAccounts liefert alle Mandanten, neueste zuerst — für den
|
||||
// Admin-Bereich (Accounts-Verwaltung).
|
||||
func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) {
|
||||
rows, err := s.Pool.Query(ctx, `SELECT `+accountColumns+` FROM account ORDER BY created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list accounts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Account
|
||||
for rows.Next() {
|
||||
a, err := scanAccount(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan account: %w", err)
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list accounts: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
138
internal/store/admin_test.go
Normal file
138
internal/store/admin_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAppUserRoleAllowsBetreiber(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
u, err := s.CreateUser(ctx, accID, "admin@example.com", "hash", "betreiber")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser mit role=betreiber: %v", err)
|
||||
}
|
||||
if u.Role != "betreiber" {
|
||||
t.Fatalf("Role = %q, want betreiber", u.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppUserRoleRejectsUnknownRole(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
if _, err := s.CreateUser(ctx, accID, "unbekannt@example.com", "hash", "kanzlei"); err == nil {
|
||||
t.Fatal("expected the old role 'kanzlei' to be rejected after the product pivot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAccounts(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
before, err := s.ListAccounts(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAccounts: %v", err)
|
||||
}
|
||||
acc, err := s.CreateAccount(ctx, "Neuer Mandant fuer ListAccounts")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
after, err := s.ListAccounts(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAccounts: %v", err)
|
||||
}
|
||||
if len(after) != len(before)+1 {
|
||||
t.Fatalf("expected exactly one more account, got %d -> %d", len(before), len(after))
|
||||
}
|
||||
found := false
|
||||
for _, a := range after {
|
||||
if a.ID == acc.ID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected the newly created account in ListAccounts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListUsersForAccount(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
otherAccID := testAccountID(t, s)
|
||||
|
||||
if _, err := s.CreateUser(ctx, accID, "eins@example.com", "hash", "mitarbeiter"); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
if _, err := s.CreateUser(ctx, accID, "zwei@example.com", "hash", "verantwortlicher"); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
if _, err := s.CreateUser(ctx, otherAccID, "fremd@example.com", "hash", "mitarbeiter"); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
list, err := s.ListUsersForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListUsersForAccount: %v", err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("expected exactly 2 users for this account, got %d: %+v", len(list), list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditLogCreateAndList(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
admin, err := s.CreateUser(ctx, accID, "admin-audit@example.com", "hash", "betreiber")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
entry, err := s.CreateAuditEntry(ctx, admin.ID, "werkzeug.aktualisiert", "werkzeug", accID, "manuell geprüft")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAuditEntry: %v", err)
|
||||
}
|
||||
if entry.ActorUserID != admin.ID {
|
||||
t.Fatalf("ActorUserID = %q, want %q", entry.ActorUserID, admin.ID)
|
||||
}
|
||||
|
||||
list, err := s.ListAuditLog(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAuditLog: %v", err)
|
||||
}
|
||||
if len(list) == 0 {
|
||||
t.Fatal("expected at least one audit entry")
|
||||
}
|
||||
if list[0].ID != entry.ID {
|
||||
t.Fatalf("expected the newest entry first, got %+v", list[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditLogIsAppendOnly(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
admin, err := s.CreateUser(ctx, accID, "admin-appendonly@example.com", "hash", "betreiber")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
entry, err := s.CreateAuditEntry(ctx, admin.ID, "werkzeug.aktualisiert", "werkzeug", accID, "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAuditEntry: %v", err)
|
||||
}
|
||||
|
||||
_, err = s.Pool.Exec(ctx, `UPDATE audit_log SET action = 'geaendert' WHERE id = $1`, entry.ID)
|
||||
if err == nil {
|
||||
t.Fatal("expected UPDATE on audit_log to be rejected by the append-only trigger")
|
||||
}
|
||||
_, err = s.Pool.Exec(ctx, `DELETE FROM audit_log WHERE id = $1`, entry.ID)
|
||||
if err == nil {
|
||||
t.Fatal("expected DELETE on audit_log to be rejected by the append-only trigger")
|
||||
}
|
||||
}
|
||||
162
internal/store/antrag.go
Normal file
162
internal/store/antrag.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Antrag ist ein Vorhaben aus dem Fragebogen (Abschnitt A) mit den
|
||||
// vollständigen Antworten aus B/C/D als JSON — der Fragebogen ist
|
||||
// adaptiv (Folgefragen hängen von vorherigen Antworten ab), ein starres
|
||||
// Spaltenschema könnte das nicht abbilden. Antworten ist bewusst
|
||||
// []byte (rohes JSON), nicht ein aufgelöster Go-Typ — die Struktur der
|
||||
// Antworten ist Sache von internal/rules (Stufe 2), store speichert nur.
|
||||
type Antrag struct {
|
||||
ID string
|
||||
AccountID string
|
||||
ErstellerUserID string
|
||||
AbteilungID *string
|
||||
Titel string
|
||||
Beschreibung string
|
||||
Ergebnis string
|
||||
Haeufigkeit string
|
||||
Antworten []byte
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
const antragColumns = `id, account_id, ersteller_user_id, abteilung_id, titel, beschreibung,
|
||||
ergebnis, haeufigkeit, antworten, status, created_at, updated_at`
|
||||
|
||||
func scanAntrag(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (Antrag, error) {
|
||||
var a Antrag
|
||||
err := row.Scan(
|
||||
&a.ID, &a.AccountID, &a.ErstellerUserID, &a.AbteilungID, &a.Titel, &a.Beschreibung,
|
||||
&a.Ergebnis, &a.Haeufigkeit, &a.Antworten, &a.Status, &a.CreatedAt, &a.UpdatedAt,
|
||||
)
|
||||
return a, err
|
||||
}
|
||||
|
||||
// CreateAntrag legt einen neuen Antrag im Status "entwurf" an.
|
||||
func (s *Store) CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (Antrag, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO antrag (account_id, ersteller_user_id, abteilung_id, titel)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING `+antragColumns,
|
||||
accountID, erstellerUserID, abteilungID, titel,
|
||||
)
|
||||
a, err := scanAntrag(row)
|
||||
if err != nil {
|
||||
return Antrag{}, fmt.Errorf("store: create antrag: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// GetAntrag liest einen Antrag anhand seiner ID — ohne Mandanten-Prüfung,
|
||||
// das ist Sache des Aufrufers (siehe Antrag.AccountID).
|
||||
func (s *Store) GetAntrag(ctx context.Context, id string) (Antrag, error) {
|
||||
row := s.Pool.QueryRow(ctx, `SELECT `+antragColumns+` FROM antrag WHERE id = $1`, id)
|
||||
a, err := scanAntrag(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Antrag{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Antrag{}, fmt.Errorf("store: get antrag: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// UpdateAntragFelder aktualisiert die Fragebogen-Felder eines Antrags —
|
||||
// solange er im Entwurf ist, kann der Fragebogen adaptiv weiter
|
||||
// ausgefüllt werden (Sache der Anwendungsschicht, store erzwingt den
|
||||
// Status hier nicht).
|
||||
func (s *Store) UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (Antrag, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
UPDATE antrag SET
|
||||
titel = $2, beschreibung = $3, ergebnis = $4, haeufigkeit = $5, antworten = $6, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING `+antragColumns,
|
||||
id, titel, beschreibung, ergebnis, haeufigkeit, antworten,
|
||||
)
|
||||
a, err := scanAntrag(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Antrag{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Antrag{}, fmt.Errorf("store: update antrag felder: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// SetAntragStatus setzt den Status eines Antrags (entwurf -> eingereicht
|
||||
// -> entschieden). Antrag ist, anders als bewertung/entscheidung, NICHT
|
||||
// append-only — der Lebenszyklus ist eine normale Zustandsänderung.
|
||||
func (s *Store) SetAntragStatus(ctx context.Context, id, status string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `UPDATE antrag SET status = $2, updated_at = now() WHERE id = $1`, id, status)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: set antrag status: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListAntraegeForUser liefert alle Anträge, die ein bestimmter Nutzer
|
||||
// gestellt hat, neueste zuerst — für Ebene 2 ("eigene Anträge
|
||||
// einsehen"), im Unterschied zu ListAntraegeForAccount, das alle
|
||||
// Anträge eines Mandanten liefert (Ebene 3, Posteingang).
|
||||
func (s *Store) ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]Antrag, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT `+antragColumns+` FROM antrag WHERE ersteller_user_id = $1 ORDER BY created_at DESC
|
||||
`, erstellerUserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list antraege for user: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Antrag
|
||||
for rows.Next() {
|
||||
a, err := scanAntrag(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan antrag: %w", err)
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list antraege for user: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListAntraegeForAccount liefert alle Anträge eines Mandanten, neueste
|
||||
// zuerst.
|
||||
func (s *Store) ListAntraegeForAccount(ctx context.Context, accountID string) ([]Antrag, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT `+antragColumns+` FROM antrag WHERE account_id = $1 ORDER BY created_at DESC
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list antraege for account: %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 account: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
133
internal/store/antrag_test.go
Normal file
133
internal/store/antrag_test.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func testUserID(t *testing.T, s *store.Store, accountID string) string {
|
||||
t.Helper()
|
||||
u, err := s.CreateUser(context.Background(), accountID, "mitarbeiter-"+accountID+"@example.com", "hash", "mitarbeiter")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
return u.ID
|
||||
}
|
||||
|
||||
func TestAntragCRUD(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
abt, err := s.CreateAbteilung(ctx, accID, "Vertrieb")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAbteilung: %v", err)
|
||||
}
|
||||
|
||||
a, err := s.CreateAntrag(ctx, accID, userID, &abt.ID, "Angebotstexte generieren")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAntrag: %v", err)
|
||||
}
|
||||
if a.Status != "entwurf" {
|
||||
t.Fatalf("Status = %q, want entwurf", a.Status)
|
||||
}
|
||||
if a.AbteilungID == nil || *a.AbteilungID != abt.ID {
|
||||
t.Fatalf("AbteilungID = %v, want %q", a.AbteilungID, abt.ID)
|
||||
}
|
||||
|
||||
got, err := s.GetAntrag(ctx, a.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAntrag: %v", err)
|
||||
}
|
||||
if got.Titel != "Angebotstexte generieren" {
|
||||
t.Fatalf("Titel = %q, unerwartet", got.Titel)
|
||||
}
|
||||
|
||||
updated, err := s.UpdateAntragFelder(ctx, a.ID, "Angebotstexte generieren", "KI schreibt Angebotstexte", "Fertiger Text zur Freigabe", "gelegentlich", []byte(`{"b1":"nein"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateAntragFelder: %v", err)
|
||||
}
|
||||
if updated.Beschreibung != "KI schreibt Angebotstexte" || updated.Haeufigkeit != "gelegentlich" {
|
||||
t.Fatalf("UpdateAntragFelder = %+v, unerwartete Werte", updated)
|
||||
}
|
||||
|
||||
if err := s.SetAntragStatus(ctx, a.ID, "eingereicht"); err != nil {
|
||||
t.Fatalf("SetAntragStatus: %v", err)
|
||||
}
|
||||
got, err = s.GetAntrag(ctx, a.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAntrag nach Statuswechsel: %v", err)
|
||||
}
|
||||
if got.Status != "eingereicht" {
|
||||
t.Fatalf("Status nach SetAntragStatus = %q, want eingereicht", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAntragNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
_, err := s.GetAntrag(context.Background(), "00000000-0000-0000-0000-000000000000")
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAntragStatusNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
err := s.SetAntragStatus(context.Background(), "00000000-0000-0000-0000-000000000000", "eingereicht")
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAntraegeForUserOnlyReturnsOwnAntraege(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userA := testUserID(t, s, accID)
|
||||
userB, err := s.CreateUser(ctx, accID, "zweiter-mitarbeiter-"+accID+"@example.com", "hash", "mitarbeiter")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
if _, err := s.CreateAntrag(ctx, accID, userA, nil, "Antrag von A"); err != nil {
|
||||
t.Fatalf("CreateAntrag (A): %v", err)
|
||||
}
|
||||
if _, err := s.CreateAntrag(ctx, accID, userB.ID, nil, "Antrag von B"); err != nil {
|
||||
t.Fatalf("CreateAntrag (B): %v", err)
|
||||
}
|
||||
|
||||
listA, err := s.ListAntraegeForUser(ctx, userA)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAntraegeForUser: %v", err)
|
||||
}
|
||||
if len(listA) != 1 || listA[0].Titel != "Antrag von A" {
|
||||
t.Fatalf("ListAntraegeForUser(A) = %+v, want exactly Antrag von A", listA)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAntraegeForAccountIsolatesTenants(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accA := testAccountID(t, s)
|
||||
accB := testAccountID(t, s)
|
||||
userA := testUserID(t, s, accA)
|
||||
userB := testUserID(t, s, accB)
|
||||
|
||||
if _, err := s.CreateAntrag(ctx, accA, userA, nil, "Antrag A"); err != nil {
|
||||
t.Fatalf("CreateAntrag (A): %v", err)
|
||||
}
|
||||
if _, err := s.CreateAntrag(ctx, accB, userB, nil, "Antrag B"); err != nil {
|
||||
t.Fatalf("CreateAntrag (B): %v", err)
|
||||
}
|
||||
|
||||
listA, err := s.ListAntraegeForAccount(ctx, accA)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAntraegeForAccount (A): %v", err)
|
||||
}
|
||||
if len(listA) != 1 || listA[0].Titel != "Antrag A" {
|
||||
t.Fatalf("ListAntraegeForAccount (A) = %+v, want exactly Antrag A", listA)
|
||||
}
|
||||
}
|
||||
61
internal/store/auditlog.go
Normal file
61
internal/store/auditlog.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuditEntry ist ein Protokolleintrag einer Admin-Aktion. Append-only:
|
||||
// siehe Migration 0004 — ein Protokoll, das man ändern kann, ist kein
|
||||
// Nachweis mehr, aus demselben Grund wie bei finding/extraction.
|
||||
type AuditEntry struct {
|
||||
ID string
|
||||
ActorUserID string
|
||||
Action string
|
||||
TargetType string
|
||||
TargetID string
|
||||
Details string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateAuditEntry protokolliert eine Admin-Aktion.
|
||||
func (s *Store) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (AuditEntry, error) {
|
||||
var e AuditEntry
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO audit_log (actor_user_id, action, target_type, target_id, details)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, actor_user_id, action, target_type, target_id, details, created_at
|
||||
`, actorUserID, action, targetType, targetID, details).Scan(
|
||||
&e.ID, &e.ActorUserID, &e.Action, &e.TargetType, &e.TargetID, &e.Details, &e.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return AuditEntry{}, fmt.Errorf("store: create audit entry: %w", err)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// ListAuditLog liefert die letzten Protokolleinträge, neueste zuerst.
|
||||
func (s *Store) ListAuditLog(ctx context.Context, limit int) ([]AuditEntry, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, actor_user_id, action, target_type, target_id, details, created_at
|
||||
FROM audit_log ORDER BY created_at DESC LIMIT $1
|
||||
`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list audit log: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []AuditEntry
|
||||
for rows.Next() {
|
||||
var e AuditEntry
|
||||
if err := rows.Scan(&e.ID, &e.ActorUserID, &e.Action, &e.TargetType, &e.TargetID, &e.Details, &e.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan audit entry: %w", err)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list audit log: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -25,6 +25,56 @@ func TestAccountCRUD(t *testing.T) {
|
||||
if got.Name != "Beispiel Agentur GmbH" {
|
||||
t.Fatalf("Name = %q, want Beispiel Agentur GmbH", got.Name)
|
||||
}
|
||||
if got.EinladungToken == "" {
|
||||
t.Error("expected a newly created account to have a non-empty EinladungToken")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAccountByEinladungToken(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.GetAccountByEinladungToken(ctx, acc.EinladungToken)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccountByEinladungToken: %v", err)
|
||||
}
|
||||
if got.ID != acc.ID {
|
||||
t.Fatalf("GetAccountByEinladungToken returned a different account")
|
||||
}
|
||||
|
||||
_, err = s.GetAccountByEinladungToken(ctx, "unbekanntes-token")
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound for an unknown token", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegenerateEinladungToken(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
alterToken := acc.EinladungToken
|
||||
|
||||
if err := s.RegenerateEinladungToken(ctx, acc.ID, "ein-neues-token"); err != nil {
|
||||
t.Fatalf("RegenerateEinladungToken: %v", err)
|
||||
}
|
||||
|
||||
if _, err := s.GetAccountByEinladungToken(ctx, alterToken); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("expected the old token to be invalid, got err = %v", err)
|
||||
}
|
||||
got, err := s.GetAccountByEinladungToken(ctx, "ein-neues-token")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccountByEinladungToken (neu): %v", err)
|
||||
}
|
||||
if got.ID != acc.ID {
|
||||
t.Fatalf("GetAccountByEinladungToken (neu) returned a different account")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserCRUD(t *testing.T) {
|
||||
@@ -32,7 +82,7 @@ func TestUserCRUD(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
user, err := s.CreateUser(ctx, accID, "team@example.com", "bcrypt-hash", "agentur")
|
||||
user, err := s.CreateUser(ctx, accID, "team@example.com", "bcrypt-hash", "mitarbeiter")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
@@ -52,6 +102,49 @@ func TestUserCRUD(t *testing.T) {
|
||||
if byID.Email != "team@example.com" || byID.AccountID != accID {
|
||||
t.Fatalf("GetUser = %+v, unerwartete Werte", byID)
|
||||
}
|
||||
if !byID.Active {
|
||||
t.Error("expected a newly created user to be Active by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetUserActive(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
user, err := s.CreateUser(ctx, accID, "deaktivierbar@example.com", "bcrypt-hash", "mitarbeiter")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
if err := s.SetUserActive(ctx, user.ID, false); err != nil {
|
||||
t.Fatalf("SetUserActive (false): %v", err)
|
||||
}
|
||||
deaktiviert, err := s.GetUser(ctx, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUser: %v", err)
|
||||
}
|
||||
if deaktiviert.Active {
|
||||
t.Fatal("expected the user to be inactive")
|
||||
}
|
||||
|
||||
if err := s.SetUserActive(ctx, user.ID, true); err != nil {
|
||||
t.Fatalf("SetUserActive (true): %v", err)
|
||||
}
|
||||
reaktiviert, err := s.GetUser(ctx, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUser: %v", err)
|
||||
}
|
||||
if !reaktiviert.Active {
|
||||
t.Fatal("expected the user to be active again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetUserActiveNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
err := s.SetUserActive(context.Background(), "00000000-0000-0000-0000-000000000000", false)
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserByEmailNotFound(t *testing.T) {
|
||||
@@ -67,10 +160,10 @@ func TestUserEmailIsUnique(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
if _, err := s.CreateUser(ctx, accID, "doppelt@example.com", "hash1", "creator"); err != nil {
|
||||
if _, err := s.CreateUser(ctx, accID, "doppelt@example.com", "hash1", "mitarbeiter"); err != nil {
|
||||
t.Fatalf("CreateUser (1): %v", err)
|
||||
}
|
||||
if _, err := s.CreateUser(ctx, accID, "doppelt@example.com", "hash2", "creator"); err == nil {
|
||||
if _, err := s.CreateUser(ctx, accID, "doppelt@example.com", "hash2", "mitarbeiter"); err == nil {
|
||||
t.Fatal("expected error for a duplicate email, got nil")
|
||||
}
|
||||
}
|
||||
@@ -80,7 +173,7 @@ func TestSessionCRUD(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
user, err := s.CreateUser(ctx, accID, "session@example.com", "hash", "marke")
|
||||
user, err := s.CreateUser(ctx, accID, "session@example.com", "hash", "verantwortlicher")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func openTestStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
url := testDatabaseURL(t)
|
||||
if err := store.Migrate(url); err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
s, err := store.Open(context.Background(), url)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(s.Close)
|
||||
return s
|
||||
}
|
||||
|
||||
// testAccountID legt einen Mandanten an und liefert dessen ID — jede
|
||||
// Submission braucht seit der Auth-Migration einen Account.
|
||||
func testAccountID(t *testing.T, s *store.Store) string {
|
||||
t.Helper()
|
||||
acc, err := s.CreateAccount(context.Background(), "Test-Mandant")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
return acc.ID
|
||||
}
|
||||
|
||||
func TestSubmissionCRUD(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
accID := testAccountID(t, s)
|
||||
created, err := s.CreateSubmission(ctx, accID, "instagram", "reel", "Werbung fuer ein Produkt")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSubmission: %v", err)
|
||||
}
|
||||
if created.Status != "draft" {
|
||||
t.Fatalf("Status = %q, want draft", created.Status)
|
||||
}
|
||||
|
||||
got, err := s.GetSubmission(ctx, created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubmission: %v", err)
|
||||
}
|
||||
if got.Platform != "instagram" || got.Caption != "Werbung fuer ein Produkt" {
|
||||
t.Fatalf("GetSubmission = %+v, unerwartete Werte", got)
|
||||
}
|
||||
|
||||
if err := s.SetSubmissionStatus(ctx, created.ID, "checked"); err != nil {
|
||||
t.Fatalf("SetSubmissionStatus: %v", err)
|
||||
}
|
||||
got, err = s.GetSubmission(ctx, created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubmission nach Statuswechsel: %v", err)
|
||||
}
|
||||
if got.Status != "checked" {
|
||||
t.Fatalf("Status nach SetSubmissionStatus = %q, want checked", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSubmissionNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
if _, err := s.GetSubmission(context.Background(), "00000000-0000-0000-0000-000000000000"); err == nil {
|
||||
t.Fatal("expected error for a nonexistent submission, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetSubmissionStatusNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
err := s.SetSubmissionStatus(context.Background(), "00000000-0000-0000-0000-000000000000", "checked")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when updating a nonexistent submission, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionCRUD(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
accID := testAccountID(t, s)
|
||||
sub, err := s.CreateSubmission(ctx, accID, "tiktok", "video", "...")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSubmission: %v", err)
|
||||
}
|
||||
|
||||
payload := []byte(`{"gegenleistung":"bezahlt"}`)
|
||||
ext, err := s.CreateExtraction(ctx, sub.ID, payload, "claude-sonnet-5", "v1")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateExtraction: %v", err)
|
||||
}
|
||||
// JSONB normalisiert die Textform (z. B. Leerzeichen nach ':'), der Inhalt
|
||||
// muss aber semantisch identisch bleiben — kein Byte-Vergleich.
|
||||
var gotPayload, wantPayload map[string]any
|
||||
if err := json.Unmarshal(ext.Payload, &gotPayload); err != nil {
|
||||
t.Fatalf("unmarshal stored payload: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(payload, &wantPayload); err != nil {
|
||||
t.Fatalf("unmarshal input payload: %v", err)
|
||||
}
|
||||
if gotPayload["gegenleistung"] != wantPayload["gegenleistung"] {
|
||||
t.Fatalf("stored payload = %v, want %v", gotPayload, wantPayload)
|
||||
}
|
||||
|
||||
got, err := s.GetLatestExtraction(ctx, sub.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestExtraction: %v", err)
|
||||
}
|
||||
if got.ID != ext.ID {
|
||||
t.Fatalf("GetLatestExtraction returned a different row than CreateExtraction")
|
||||
}
|
||||
|
||||
// Eine zweite Extraktion (erneute Pruefung) muss die "latest" sein.
|
||||
payload2 := []byte(`{"gegenleistung":"keine"}`)
|
||||
ext2, err := s.CreateExtraction(ctx, sub.ID, payload2, "claude-sonnet-5", "v1")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateExtraction (2): %v", err)
|
||||
}
|
||||
got, err = s.GetLatestExtraction(ctx, sub.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestExtraction (2): %v", err)
|
||||
}
|
||||
if got.ID != ext2.ID {
|
||||
t.Fatalf("GetLatestExtraction did not return the most recent extraction")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindingCRUDAndSupersedes(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
accID := testAccountID(t, s)
|
||||
sub, err := s.CreateSubmission(ctx, accID, "instagram", "reel", "...")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSubmission: %v", err)
|
||||
}
|
||||
|
||||
old, err := s.CreateFinding(ctx, sub.ID, nil, "WK-004", 1, "hoch", "alte Fassung", "alte Korrektur", []string{"§ 5a Abs. 4 UWG"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFinding (old): %v", err)
|
||||
}
|
||||
|
||||
current, err := s.ListCurrentFindings(ctx, sub.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCurrentFindings: %v", err)
|
||||
}
|
||||
if len(current) != 1 || current[0].ID != old.ID {
|
||||
t.Fatalf("expected exactly the old finding before any correction, got %+v", current)
|
||||
}
|
||||
if len(current[0].Sources) != 1 || current[0].Sources[0] != "§ 5a Abs. 4 UWG" {
|
||||
t.Fatalf("Sources = %v, want [§ 5a Abs. 4 UWG]", current[0].Sources)
|
||||
}
|
||||
|
||||
// Korrektur: neue Zeile, die die alte per supersedes ersetzt.
|
||||
_, err = s.Pool.Exec(ctx, `
|
||||
INSERT INTO finding (submission_id, rule_id, rule_version, severity, title, fix, sources, supersedes)
|
||||
VALUES ($1, 'WK-004', 2, 'hoch', 'korrigierte Fassung', 'neue Korrektur', '{}', $2)
|
||||
`, sub.ID, old.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("insert superseding finding: %v", err)
|
||||
}
|
||||
|
||||
current, err = s.ListCurrentFindings(ctx, sub.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCurrentFindings nach Korrektur: %v", err)
|
||||
}
|
||||
if len(current) != 1 {
|
||||
t.Fatalf("expected exactly one current finding after a correction, got %d: %+v", len(current), current)
|
||||
}
|
||||
if current[0].Title != "korrigierte Fassung" {
|
||||
t.Fatalf("expected the corrected finding to be current, got %+v", current[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvidencePackageCRUD(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
accID := testAccountID(t, s)
|
||||
sub, err := s.CreateSubmission(ctx, accID, "instagram", "reel", "...")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSubmission: %v", err)
|
||||
}
|
||||
|
||||
token := []byte("fake-rfc3161-token-bytes")
|
||||
pkg, err := s.CreateEvidencePackage(ctx, sub.ID, "/var/lib/deklarix/dossiers/x.pdf", "deadbeef", token)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateEvidencePackage: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.GetLatestEvidencePackage(ctx, sub.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLatestEvidencePackage: %v", err)
|
||||
}
|
||||
if got.ID != pkg.ID || string(got.TimestampToken) != string(token) {
|
||||
t.Fatalf("GetLatestEvidencePackage = %+v, unerwartete Werte", got)
|
||||
}
|
||||
|
||||
// Append-only: ein UPDATE muss vom Trigger abgelehnt werden.
|
||||
_, err = s.Pool.Exec(ctx, `UPDATE evidence_package SET sha256 = 'geaendert' WHERE id = $1`, pkg.ID)
|
||||
if err == nil {
|
||||
t.Fatal("expected UPDATE on evidence_package to be rejected, but it succeeded")
|
||||
}
|
||||
}
|
||||
146
internal/store/entscheidung.go
Normal file
146
internal/store/entscheidung.go
Normal file
@@ -0,0 +1,146 @@
|
||||
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
|
||||
}
|
||||
|
||||
// ListAktiveGenehmigungenForAccount liefert alle Genehmigungen
|
||||
// ("genehmigt"/"genehmigt_mit_auflagen") eines Mandanten — Grundlage
|
||||
// für die Wiedervorlage (Schritt 7): abgelaufene/bald ablaufende
|
||||
// Genehmigungen und solche, deren zugrunde liegendes Werkzeug sich seit
|
||||
// der Entscheidung im Katalog geändert hat, muss der/die Verantwortliche
|
||||
// erneut prüfen. entscheidung trägt selbst kein account_id — der Bezug
|
||||
// zum Mandanten läuft über den zugehörigen antrag.
|
||||
func (s *Store) ListAktiveGenehmigungenForAccount(ctx context.Context, accountID string) ([]Entscheidung, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT e.id, e.antrag_id, e.bewertung_id, e.entscheider_user_id, e.entscheidung,
|
||||
e.werkzeug_id, e.werkzeug_snapshot, e.begruendung, e.gueltig_bis, e.created_at
|
||||
FROM entscheidung e
|
||||
JOIN antrag a ON a.id = e.antrag_id
|
||||
WHERE a.account_id = $1 AND e.entscheidung IN ('genehmigt', 'genehmigt_mit_auflagen')
|
||||
ORDER BY e.created_at DESC
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list aktive genehmigungen for account: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Entscheidung
|
||||
for rows.Next() {
|
||||
e, err := scanEntscheidung(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan entscheidung: %w", err)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list aktive genehmigungen for account: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
153
internal/store/entscheidung_test.go
Normal file
153
internal/store/entscheidung_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAktiveGenehmigungenForAccount(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
|
||||
antragGenehmigt, b1 := testAntragMitBewertung(t, s, accID, userID)
|
||||
if _, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
|
||||
AntragID: antragGenehmigt.ID, BewertungID: b1.ID, EntscheiderUserID: userID, Entscheidung: "genehmigt",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateEntscheidung (genehmigt): %v", err)
|
||||
}
|
||||
|
||||
antragAbgelehnt, b2 := testAntragMitBewertung(t, s, accID, userID)
|
||||
if _, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
|
||||
AntragID: antragAbgelehnt.ID, BewertungID: b2.ID, EntscheiderUserID: userID, Entscheidung: "abgelehnt",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateEntscheidung (abgelehnt): %v", err)
|
||||
}
|
||||
|
||||
list, err := s.ListAktiveGenehmigungenForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAktiveGenehmigungenForAccount: %v", err)
|
||||
}
|
||||
if len(list) != 1 || list[0].AntragID != antragGenehmigt.ID {
|
||||
t.Fatalf("ListAktiveGenehmigungenForAccount = %+v, want exactly the genehmigt entscheidung", list)
|
||||
}
|
||||
|
||||
otherAcc := testAccountID(t, s)
|
||||
otherList, err := s.ListAktiveGenehmigungenForAccount(ctx, otherAcc)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAktiveGenehmigungenForAccount (other): %v", err)
|
||||
}
|
||||
if len(otherList) != 0 {
|
||||
t.Fatalf("expected no genehmigungen for a different tenant, got %+v", otherList)
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EvidencePackage ist das Ergebnis der Archivierung eines Beitrags:
|
||||
// Dossier-Pfad, Hash der kanonisierten Metadaten und RFC-3161-Token.
|
||||
// Append-only: siehe Migration.
|
||||
type EvidencePackage struct {
|
||||
ID string
|
||||
SubmissionID string
|
||||
DossierPath string
|
||||
SHA256 string
|
||||
TimestampToken []byte
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateEvidencePackage speichert ein EvidencePackage.
|
||||
func (s *Store) CreateEvidencePackage(ctx context.Context, submissionID, dossierPath, sha256Hex string, timestampToken []byte) (EvidencePackage, error) {
|
||||
var e EvidencePackage
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO evidence_package (submission_id, dossier_path, sha256, timestamp_token)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, submission_id, dossier_path, sha256, timestamp_token, created_at
|
||||
`, submissionID, dossierPath, sha256Hex, timestampToken).Scan(
|
||||
&e.ID, &e.SubmissionID, &e.DossierPath, &e.SHA256, &e.TimestampToken, &e.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return EvidencePackage{}, fmt.Errorf("store: create evidence package: %w", err)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// GetLatestEvidencePackage liefert das zuletzt erzeugte EvidencePackage
|
||||
// für einen Beitrag.
|
||||
func (s *Store) GetLatestEvidencePackage(ctx context.Context, submissionID string) (EvidencePackage, error) {
|
||||
var e EvidencePackage
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, submission_id, dossier_path, sha256, timestamp_token, created_at
|
||||
FROM evidence_package
|
||||
WHERE submission_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`, submissionID).Scan(
|
||||
&e.ID, &e.SubmissionID, &e.DossierPath, &e.SHA256, &e.TimestampToken, &e.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return EvidencePackage{}, fmt.Errorf("store: get latest evidence package: %w", err)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Extraction ist das Ergebnis der Stufe-1-Extraktion für einen Beitrag.
|
||||
// Payload ist das rohe, vom Modell gelieferte JSON — nicht eine
|
||||
// abgeleitete Repräsentation. Append-only: siehe Migration.
|
||||
type Extraction struct {
|
||||
ID string
|
||||
SubmissionID string
|
||||
Payload []byte
|
||||
ModelVersion string
|
||||
PromptVersion string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateExtraction speichert eine Extraktion. payload ist das rohe
|
||||
// JSON, wie es das Modell zurückgegeben hat (siehe extract.Result.RawJSON).
|
||||
func (s *Store) CreateExtraction(ctx context.Context, submissionID string, payload []byte, modelVersion, promptVersion string) (Extraction, error) {
|
||||
var e Extraction
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO extraction (submission_id, payload, model_version, prompt_version)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, submission_id, payload, model_version, prompt_version, created_at
|
||||
`, submissionID, payload, modelVersion, promptVersion).Scan(
|
||||
&e.ID, &e.SubmissionID, &e.Payload, &e.ModelVersion, &e.PromptVersion, &e.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Extraction{}, fmt.Errorf("store: create extraction: %w", err)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// GetLatestExtraction liest die zuletzt erzeugte Extraktion für einen
|
||||
// Beitrag (append-only: es kann mehrere geben, z. B. bei einer erneuten
|
||||
// Prüfung — die aktuellste zählt).
|
||||
func (s *Store) GetLatestExtraction(ctx context.Context, submissionID string) (Extraction, error) {
|
||||
var e Extraction
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, submission_id, payload, model_version, prompt_version, created_at
|
||||
FROM extraction
|
||||
WHERE submission_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`, submissionID).Scan(
|
||||
&e.ID, &e.SubmissionID, &e.Payload, &e.ModelVersion, &e.PromptVersion, &e.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Extraction{}, fmt.Errorf("store: get latest extraction: %w", err)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Finding ist das Ergebnis einer Regel für einen Beitrag. Append-only:
|
||||
// siehe Migration. ExtractionID ist optional (nil wenn ein Finding nicht
|
||||
// direkt aus einer Extraktion, sondern z. B. manuell erzeugt wurde).
|
||||
// Title/Fix/Sources sind zum Zeitpunkt der Regelauswertung fixiert
|
||||
// gespeichert (nicht nur rule_id/rule_version referenziert), weil ein
|
||||
// späteres Update der Regel-YAML eine ältere Version sonst nicht mehr
|
||||
// nachträglich auflösen könnte — der Wortlaut zum Zeitpunkt des
|
||||
// Findings ist der Beweis, kein Verweis darauf.
|
||||
type Finding struct {
|
||||
ID string
|
||||
SubmissionID string
|
||||
ExtractionID *string
|
||||
RuleID string
|
||||
RuleVersion int
|
||||
Severity string
|
||||
Title string
|
||||
Fix string
|
||||
Sources []string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateFinding speichert ein Finding. sources ist NOT NULL in der DB
|
||||
// (TEXT[]) — ein nil-Slice (z. B. eine Regel ohne fundstelle-Eintrag)
|
||||
// würde als SQL-NULL ankommen und mit einer wenig hilfreichen Constraint-
|
||||
// Fehlermeldung abgelehnt; hier stattdessen auf eine leere Liste normiert.
|
||||
func (s *Store) CreateFinding(ctx context.Context, submissionID string, extractionID *string, ruleID string, ruleVersion int, severity, title, fix string, sources []string) (Finding, error) {
|
||||
if sources == nil {
|
||||
sources = []string{}
|
||||
}
|
||||
var f Finding
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO finding (submission_id, extraction_id, rule_id, rule_version, severity, title, fix, sources)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, submission_id, extraction_id, rule_id, rule_version, severity, title, fix, sources, created_at
|
||||
`, submissionID, extractionID, ruleID, ruleVersion, severity, title, fix, sources).Scan(
|
||||
&f.ID, &f.SubmissionID, &f.ExtractionID, &f.RuleID, &f.RuleVersion, &f.Severity, &f.Title, &f.Fix, &f.Sources, &f.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Finding{}, fmt.Errorf("store: create finding: %w", err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// ListCurrentFindings liefert die aktuell gültigen Findings eines
|
||||
// Beitrags — Zeilen, die von keiner anderen Zeile per supersedes
|
||||
// ersetzt wurden (siehe Migrationskommentar zu finding.supersedes).
|
||||
func (s *Store) ListCurrentFindings(ctx context.Context, submissionID string) ([]Finding, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT f.id, f.submission_id, f.extraction_id, f.rule_id, f.rule_version, f.severity, f.title, f.fix, f.sources, f.created_at
|
||||
FROM finding f
|
||||
WHERE f.submission_id = $1
|
||||
AND NOT EXISTS (SELECT 1 FROM finding f2 WHERE f2.supersedes = f.id)
|
||||
ORDER BY f.created_at
|
||||
`, submissionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list current findings: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var findings []Finding
|
||||
for rows.Next() {
|
||||
var f Finding
|
||||
if err := rows.Scan(&f.ID, &f.SubmissionID, &f.ExtractionID, &f.RuleID, &f.RuleVersion, &f.Severity, &f.Title, &f.Fix, &f.Sources, &f.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan finding: %w", err)
|
||||
}
|
||||
findings = append(findings, f)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list current findings: %w", err)
|
||||
}
|
||||
return findings, nil
|
||||
}
|
||||
6
internal/store/migrations/0004_admin.down.sql
Normal file
6
internal/store/migrations/0004_admin.down.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
DROP TRIGGER audit_log_append_only ON audit_log;
|
||||
DROP TABLE audit_log;
|
||||
ALTER TABLE account DROP COLUMN verified;
|
||||
ALTER TABLE app_user DROP CONSTRAINT app_user_role_check;
|
||||
ALTER TABLE app_user ADD CONSTRAINT app_user_role_check
|
||||
CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei'));
|
||||
36
internal/store/migrations/0004_admin.up.sql
Normal file
36
internal/store/migrations/0004_admin.up.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- "admin" ist eine fünfte app_user-Rolle: Betreiber-Personal (Netcell-IT),
|
||||
-- nicht an einen Mandanten-Geschäftszweck (creator/agentur/marke/kanzlei)
|
||||
-- gebunden, sondern zuständig für die Plattform selbst (Accounts,
|
||||
-- Kanzlei-Verzeichnis, Audit-Log). Bewusst KEIN eigenes account_role-Feld
|
||||
-- getrennt von app_user.role — ein Admin-Login ist genauso ein app_user
|
||||
-- wie jeder andere, nur mit einer anderen Rolle. Es gibt bewusst keine
|
||||
-- Selbstregistrierung für "admin" über /register (siehe internal/web) —
|
||||
-- der erste Admin wird per SQL angelegt (siehe CLAUDE.md).
|
||||
ALTER TABLE app_user DROP CONSTRAINT app_user_role_check;
|
||||
ALTER TABLE app_user ADD CONSTRAINT app_user_role_check
|
||||
CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei', 'admin'));
|
||||
|
||||
-- verified markiert eine Kanzlei-Account als für das öffentliche,
|
||||
-- kostenlose Kanzlei-Verzeichnis freigegeben (siehe CLAUDE.md, § 49b
|
||||
-- Abs. 3 BRAO: keine Sachvorteile, kein Routing — das Verzeichnis ist
|
||||
-- eine reine Auflistung, keine Vermittlung). Nur für Accounts mit
|
||||
-- mindestens einem Nutzer der Rolle "kanzlei" sinnvoll; das erzwingt die
|
||||
-- Anwendungsschicht, nicht die Datenbank.
|
||||
ALTER TABLE account ADD COLUMN verified BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- Audit-Log für Admin-Aktionen: append-only aus demselben Grund wie
|
||||
-- finding/extraction/evidence_package — ein Protokoll, das man ändern
|
||||
-- kann, ist kein Nachweis mehr.
|
||||
CREATE TABLE audit_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
actor_user_id UUID NOT NULL REFERENCES app_user (id),
|
||||
action TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
details TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TRIGGER audit_log_append_only
|
||||
BEFORE UPDATE OR DELETE ON audit_log
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
@@ -0,0 +1 @@
|
||||
DROP TRIGGER asset_append_only ON asset;
|
||||
9
internal/store/migrations/0005_asset_append_only.up.sql
Normal file
9
internal/store/migrations/0005_asset_append_only.up.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- asset war in 0001 ohne append-only-Trigger angelegt, weil bis jetzt
|
||||
-- nichts Assets tatsächlich schrieb. Ein hochgeladenes Standbild ist
|
||||
-- Teil der Beweiskette (SHA-256, siehe CLAUDE.md) genau wie extraction/
|
||||
-- finding/evidence_package — es nachträglich austauschen zu können,
|
||||
-- würde denselben Grund unterlaufen, aus dem diese Tabellen append-only
|
||||
-- sind.
|
||||
CREATE TRIGGER asset_append_only
|
||||
BEFORE UPDATE OR DELETE ON asset
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE platform_connection;
|
||||
21
internal/store/migrations/0006_platform_connection.up.sql
Normal file
21
internal/store/migrations/0006_platform_connection.up.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- Ein Kunde kann seinen eigenen Instagram- oder TikTok-Account per
|
||||
-- OAuth verbinden (siehe internal/socialconnect), damit die
|
||||
-- Beweissicherung einen veröffentlichten Beitrag künftig direkt
|
||||
-- abrufen kann statt ihn manuell hochzuladen. Anders als
|
||||
-- extraction/finding/asset ist das NICHT append-only: Tokens laufen ab
|
||||
-- und werden erneuert, eine Verbindung kann getrennt und neu
|
||||
-- hergestellt werden — das ist ein normaler Konfigurationszustand, kein
|
||||
-- Beweis-Eintrag. Ein Account hat höchstens eine Verbindung pro
|
||||
-- Plattform (erneutes Verbinden ersetzt die alte, siehe
|
||||
-- ON CONFLICT in UpsertPlatformConnection).
|
||||
CREATE TABLE platform_connection (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account (id),
|
||||
platform TEXT NOT NULL CHECK (platform IN ('instagram', 'tiktok')),
|
||||
platform_user_id TEXT NOT NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
refresh_token TEXT NOT NULL DEFAULT '',
|
||||
expires_at TIMESTAMPTZ,
|
||||
connected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (account_id, platform)
|
||||
);
|
||||
1
internal/store/migrations/0007_asset_purpose.down.sql
Normal file
1
internal/store/migrations/0007_asset_purpose.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE asset DROP COLUMN purpose;
|
||||
9
internal/store/migrations/0007_asset_purpose.up.sql
Normal file
9
internal/store/migrations/0007_asset_purpose.up.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- Ein Beitrag kann mehr als ein Standbild bekommen: das ursprüngliche
|
||||
-- Beweisfoto beim Prüfen (purpose='initial') und später ein Nachweis
|
||||
-- flüchtiger Kennzahlen (purpose='insights') — Instagram hält Story-
|
||||
-- Insights nach eigener Aussage nur 24 Stunden vor, danach sind sie
|
||||
-- auch über den offiziellen Datenexport nicht mehr zu bekommen.
|
||||
-- Default 'initial' erhält die Bedeutung aller vor dieser Migration
|
||||
-- angelegten Zeilen unverändert.
|
||||
ALTER TABLE asset ADD COLUMN purpose TEXT NOT NULL DEFAULT 'initial'
|
||||
CHECK (purpose IN ('initial', 'insights'));
|
||||
104
internal/store/migrations/0008_pivot_ki_antragspruefung.down.sql
Normal file
104
internal/store/migrations/0008_pivot_ki_antragspruefung.down.sql
Normal file
@@ -0,0 +1,104 @@
|
||||
-- Best-effort-Rückbau auf die Schema-Form des alten Werberecht-Produkts
|
||||
-- (Stand nach Migration 0007) — reine Struktur, keine Daten. Ein
|
||||
-- kompletter Produktwechsel wird in der Praxis nicht zurückgerollt,
|
||||
-- diese Datei existiert nur, damit "migrate down" nicht bricht.
|
||||
|
||||
DROP TABLE IF EXISTS antrag;
|
||||
DROP TABLE IF EXISTS werkzeug_sperre;
|
||||
DROP TABLE IF EXISTS werkzeug;
|
||||
DROP TABLE IF EXISTS abteilung;
|
||||
|
||||
ALTER TABLE app_user DROP CONSTRAINT app_user_role_check;
|
||||
ALTER TABLE app_user ADD CONSTRAINT app_user_role_check
|
||||
CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei', 'admin'));
|
||||
|
||||
ALTER TABLE account ADD COLUMN verified BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE TABLE submission (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
platform TEXT NOT NULL CHECK (platform IN ('instagram', 'tiktok', 'youtube', 'linkedin')),
|
||||
post_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'checked', 'published', 'archived')),
|
||||
caption TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
account_id UUID NOT NULL REFERENCES account (id)
|
||||
);
|
||||
|
||||
CREATE TABLE asset (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||
kind TEXT NOT NULL CHECK (kind IN ('image', 'video', 'file')),
|
||||
path TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
purpose TEXT NOT NULL DEFAULT 'initial' CHECK (purpose IN ('initial', 'insights'))
|
||||
);
|
||||
CREATE TRIGGER asset_append_only
|
||||
BEFORE UPDATE OR DELETE ON asset
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
|
||||
CREATE TABLE extraction (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||
payload JSONB NOT NULL,
|
||||
model_version TEXT NOT NULL,
|
||||
prompt_version TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TRIGGER extraction_append_only
|
||||
BEFORE UPDATE OR DELETE ON extraction
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
|
||||
CREATE TABLE finding (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||
extraction_id UUID REFERENCES extraction (id),
|
||||
rule_id TEXT NOT NULL,
|
||||
rule_version INTEGER NOT NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('niedrig', 'mittel', 'hoch')),
|
||||
supersedes UUID REFERENCES finding (id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
title TEXT NOT NULL,
|
||||
fix TEXT NOT NULL,
|
||||
sources TEXT[] NOT NULL DEFAULT '{}'
|
||||
);
|
||||
CREATE INDEX finding_supersedes_idx ON finding (supersedes) WHERE supersedes IS NOT NULL;
|
||||
CREATE TRIGGER finding_append_only
|
||||
BEFORE UPDATE OR DELETE ON finding
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
|
||||
CREATE TABLE evidence_package (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||
dossier_path TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL,
|
||||
timestamp_token BYTEA NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TRIGGER evidence_package_append_only
|
||||
BEFORE UPDATE OR DELETE ON evidence_package
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
|
||||
CREATE TABLE participant (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||
role TEXT NOT NULL CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei')),
|
||||
name TEXT NOT NULL,
|
||||
vorgegeben BOOLEAN NOT NULL DEFAULT false,
|
||||
freigegeben BOOLEAN NOT NULL DEFAULT false,
|
||||
approved_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE platform_connection (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account (id),
|
||||
platform TEXT NOT NULL CHECK (platform IN ('instagram', 'tiktok')),
|
||||
platform_user_id TEXT NOT NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
refresh_token TEXT NOT NULL DEFAULT '',
|
||||
expires_at TIMESTAMPTZ,
|
||||
connected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (account_id, platform)
|
||||
);
|
||||
117
internal/store/migrations/0008_pivot_ki_antragspruefung.up.sql
Normal file
117
internal/store/migrations/0008_pivot_ki_antragspruefung.up.sql
Normal file
@@ -0,0 +1,117 @@
|
||||
-- Produktwechsel: Deklarix war eine Kennzeichnungsprüfung für
|
||||
-- Werbe-Content (UWG/MStV), wird jetzt eine KI-Antragsprüfung
|
||||
-- (Fragebogen -> Datenklasse/KI-VO-Einstufung -> Werkzeug-Katalog ->
|
||||
-- Entscheidung). Alles, was ausschließlich für das alte Werberecht-
|
||||
-- Produkt existierte, wird entfernt. account/app_user/session/
|
||||
-- audit_log bleiben — Mandantentrennung, Login und
|
||||
-- Protokollierungsprinzip sind produktunabhängig.
|
||||
|
||||
DROP TABLE IF EXISTS platform_connection;
|
||||
DROP TABLE IF EXISTS asset;
|
||||
DROP TABLE IF EXISTS participant;
|
||||
DROP TABLE IF EXISTS evidence_package;
|
||||
DROP TABLE IF EXISTS finding;
|
||||
DROP TABLE IF EXISTS extraction;
|
||||
DROP TABLE IF EXISTS submission;
|
||||
|
||||
-- "verified"/Kanzlei-Verzeichnis gab es nur für die alte Berufsrecht-
|
||||
-- Sonderrolle "kanzlei".
|
||||
ALTER TABLE account DROP COLUMN IF EXISTS verified;
|
||||
|
||||
-- Fünf Rollen (Ebenen 2-5 der Frontend-Spezifikation):
|
||||
-- mitarbeiter Ebene 2 — stellt Anträge, sieht nur eigene
|
||||
-- verantwortlicher Ebene 3 — KI-Verantwortlicher, volles Entscheidungsrecht
|
||||
-- pruefer Ebene 3 — identische Sicht wie verantwortlicher, aber
|
||||
-- ohne Entscheidungsrecht (reine Prüfsicht)
|
||||
-- admin Ebene 4 — Mandanten-Verwaltung (Nutzer, Abteilungen,
|
||||
-- Anmeldeverfahren, eigene Werkzeug-Freigaben) — bezogen
|
||||
-- auf GENAU EINEN Mandanten, nicht plattformweit
|
||||
-- betreiber Ebene 5 — Netcell-IT-Personal, plattformweit
|
||||
-- (Werkzeugkatalog, Regelwerk, Mandantenverwaltung);
|
||||
-- entspricht der alten "admin"-Rolle vor dieser Migration
|
||||
--
|
||||
-- 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;
|
||||
|
||||
-- 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
|
||||
CHECK (role IN ('mitarbeiter', 'verantwortlicher', 'pruefer', 'admin', 'betreiber'));
|
||||
|
||||
-- Stammdaten: Abteilungen zur Auswahl im Fragebogen (Feld A.abteilung).
|
||||
CREATE TABLE abteilung (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account (id),
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (account_id, name)
|
||||
);
|
||||
|
||||
-- Werkzeugkatalog: der eigentliche Wert des Produkts. account_id NULL
|
||||
-- markiert einen zentral gepflegten, für alle Mandanten identischen
|
||||
-- Katalogeintrag; ein gesetzter account_id ist eine mandantenspezifische
|
||||
-- Ergänzung (siehe Spezifikation "jeder Mandant kann zusätzlich eigene
|
||||
-- Einträge ... führen"). letzte_pruefung und quelle sind Pflicht — jede
|
||||
-- Zusicherung im Katalog muss belegbar sein, siehe CLAUDE.md.
|
||||
CREATE TABLE werkzeug (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID REFERENCES account (id),
|
||||
name TEXT NOT NULL,
|
||||
anbieter TEXT NOT NULL,
|
||||
verarbeitungsort TEXT NOT NULL CHECK (verarbeitungsort IN ('EU', 'USA', 'gemischt', 'on-prem')),
|
||||
avv_verfuegbar BOOLEAN NOT NULL DEFAULT false,
|
||||
avv_url TEXT NOT NULL DEFAULT '',
|
||||
training_opt_out BOOLEAN NOT NULL DEFAULT false,
|
||||
training_standard BOOLEAN NOT NULL DEFAULT false,
|
||||
aufbewahrung_tage INTEGER NOT NULL DEFAULT 0,
|
||||
zertifizierungen TEXT[] NOT NULL DEFAULT '{}',
|
||||
geeignete_zwecke TEXT[] NOT NULL DEFAULT '{}',
|
||||
einschraenkungen TEXT[] NOT NULL DEFAULT '{}',
|
||||
letzte_pruefung TIMESTAMPTZ NOT NULL,
|
||||
quelle TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Ein Mandant kann einen (auch zentralen) Katalogeintrag für sich
|
||||
-- sperren, ohne den zentralen Katalog selbst zu verändern.
|
||||
CREATE TABLE werkzeug_sperre (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account (id),
|
||||
werkzeug_id UUID NOT NULL REFERENCES werkzeug (id),
|
||||
grund TEXT NOT NULL,
|
||||
gesperrt_am TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (account_id, werkzeug_id)
|
||||
);
|
||||
|
||||
-- Antrag: das Vorhaben aus Fragebogen-Abschnitt A, plus die vollständigen
|
||||
-- Antworten aus B/C/D als JSON (adaptiver Fragebogen — welche Folgefragen
|
||||
-- beantwortet wurden, hängt von vorherigen Antworten ab, ein starres
|
||||
-- Spaltenschema würde das nicht abbilden). status ist eine normale
|
||||
-- Zustandsänderung (wie submission es früher war), kein Beweis-Eintrag.
|
||||
CREATE TABLE antrag (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account (id),
|
||||
ersteller_user_id UUID NOT NULL REFERENCES app_user (id),
|
||||
abteilung_id UUID REFERENCES abteilung (id),
|
||||
titel TEXT NOT NULL,
|
||||
beschreibung TEXT NOT NULL DEFAULT '',
|
||||
ergebnis TEXT NOT NULL DEFAULT '',
|
||||
haeufigkeit TEXT NOT NULL DEFAULT 'einmalig'
|
||||
CHECK (haeufigkeit IN ('einmalig', 'gelegentlich', 'taeglich', 'automatisiert')),
|
||||
antworten JSONB NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'entwurf'
|
||||
CHECK (status IN ('entwurf', 'eingereicht', 'entschieden')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
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();
|
||||
1
internal/store/migrations/0011_registereintrag.down.sql
Normal file
1
internal/store/migrations/0011_registereintrag.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE registereintrag;
|
||||
27
internal/store/migrations/0011_registereintrag.up.sql
Normal file
27
internal/store/migrations/0011_registereintrag.up.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
-- Registereintrag: wird automatisch bei jeder Genehmigung erzeugt
|
||||
-- (genehmigt / genehmigt_mit_auflagen), siehe CLAUDE.md, Entscheidung/
|
||||
-- Register/Wiedervorlage. Bewusst denormalisiert (eigene Textspalten
|
||||
-- statt Joins über bewertung/entscheidung/werkzeug) — ein Registereintrag
|
||||
-- ist ein für sich lesbarer, exportierbarer Nachweis, der nicht von
|
||||
-- einem späteren Katalog- oder Abteilungs-Wandel abhängen darf.
|
||||
-- Append-only wie bewertung/entscheidung.
|
||||
CREATE TABLE registereintrag (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account (id),
|
||||
antrag_id UUID NOT NULL REFERENCES antrag (id),
|
||||
entscheidung_id UUID NOT NULL REFERENCES entscheidung (id),
|
||||
zweck TEXT NOT NULL,
|
||||
abteilung TEXT NOT NULL DEFAULT '',
|
||||
werkzeug TEXT NOT NULL,
|
||||
datenklasse TEXT NOT NULL,
|
||||
einstufung TEXT NOT NULL,
|
||||
auflagen TEXT[] NOT NULL DEFAULT '{}',
|
||||
verantwortlicher TEXT NOT NULL,
|
||||
entschieden_am TIMESTAMPTZ NOT NULL,
|
||||
gueltig_bis TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TRIGGER registereintrag_append_only
|
||||
BEFORE UPDATE OR DELETE ON registereintrag
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
1
internal/store/migrations/0012_app_user_active.down.sql
Normal file
1
internal/store/migrations/0012_app_user_active.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE app_user DROP COLUMN active;
|
||||
7
internal/store/migrations/0012_app_user_active.up.sql
Normal file
7
internal/store/migrations/0012_app_user_active.up.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- Nutzer können nicht gelöscht werden (app_user wird von antrag,
|
||||
-- session, bewertung [über antrag], entscheidung, audit_log,
|
||||
-- registereintrag [über antrag] per Foreign Key referenziert — ein
|
||||
-- Hard-Delete würde die Historie zerstören). Stattdessen: deaktivieren.
|
||||
-- Ein deaktivierter Nutzer kann sich nicht mehr anmelden, bleibt aber
|
||||
-- als Akteur in Anträgen/Entscheidungen/Audit-Log nachvollziehbar.
|
||||
ALTER TABLE app_user ADD COLUMN active BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE account DROP COLUMN einladung_token;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Sammellink für die Mitarbeiter-Selbstanmeldung (Ebene 1, "Einladung
|
||||
-- annehmen" — siehe CLAUDE.md, Onboarding: "Einladungslink [Sammellink,
|
||||
-- Selbstanmeldung, Abteilung beim ersten Antrag]"). Ein Token pro
|
||||
-- Account, kein Ablaufdatum, per Admin erneuerbar (macht den alten
|
||||
-- Link ungültig). DEFAULT gen_random_uuid()::text befüllt bestehende
|
||||
-- Zeilen automatisch und liefert künftigen INSERTs einen Wert, ohne
|
||||
-- dass store.CreateAccount etwas ändern muss.
|
||||
ALTER TABLE account ADD COLUMN einladung_token TEXT UNIQUE NOT NULL DEFAULT gen_random_uuid()::text;
|
||||
@@ -1,121 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Participant ist ein Beteiligter an einer Submission (Verantwortungs-
|
||||
// matrix). Anders als finding/extraction/evidence_package ist participant
|
||||
// NICHT append-only — wer vorgegeben/freigegeben hat, kann sich klären
|
||||
// oder korrigieren, ohne dass das ein Beweis-Eintrag ist.
|
||||
type Participant struct {
|
||||
ID string
|
||||
SubmissionID string
|
||||
Role string
|
||||
Name string
|
||||
Vorgegeben bool
|
||||
Freigegeben bool
|
||||
ApprovedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateParticipant fügt einen Beteiligten zu einer Submission hinzu.
|
||||
func (s *Store) CreateParticipant(ctx context.Context, submissionID, role, name string, vorgegeben, freigegeben bool) (Participant, error) {
|
||||
var p Participant
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO participant (submission_id, role, name, vorgegeben, freigegeben, approved_at)
|
||||
VALUES ($1, $2, $3, $4, $5, CASE WHEN $5 THEN now() ELSE NULL END)
|
||||
RETURNING id, submission_id, role, name, vorgegeben, freigegeben, approved_at, created_at
|
||||
`, submissionID, role, name, vorgegeben, freigegeben).Scan(
|
||||
&p.ID, &p.SubmissionID, &p.Role, &p.Name, &p.Vorgegeben, &p.Freigegeben, &p.ApprovedAt, &p.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Participant{}, fmt.Errorf("store: create participant: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// ListParticipants liefert alle Beteiligten einer Submission.
|
||||
func (s *Store) ListParticipants(ctx context.Context, submissionID string) ([]Participant, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, submission_id, role, name, vorgegeben, freigegeben, approved_at, created_at
|
||||
FROM participant WHERE submission_id = $1 ORDER BY created_at
|
||||
`, submissionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list participants: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var participants []Participant
|
||||
for rows.Next() {
|
||||
var p Participant
|
||||
if err := rows.Scan(&p.ID, &p.SubmissionID, &p.Role, &p.Name, &p.Vorgegeben, &p.Freigegeben, &p.ApprovedAt, &p.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan participant: %w", err)
|
||||
}
|
||||
participants = append(participants, p)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list participants: %w", err)
|
||||
}
|
||||
return participants, nil
|
||||
}
|
||||
|
||||
// GetParticipant liest einen Beteiligten anhand seiner ID — u. a. um vor
|
||||
// einem Update/Delete zu prüfen, zu welcher Submission (und damit zu
|
||||
// welchem Account) er gehört.
|
||||
func (s *Store) GetParticipant(ctx context.Context, id string) (Participant, error) {
|
||||
var p Participant
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, submission_id, role, name, vorgegeben, freigegeben, approved_at, created_at
|
||||
FROM participant WHERE id = $1
|
||||
`, id).Scan(&p.ID, &p.SubmissionID, &p.Role, &p.Name, &p.Vorgegeben, &p.Freigegeben, &p.ApprovedAt, &p.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Participant{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Participant{}, fmt.Errorf("store: get participant: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// UpdateParticipant setzt vorgegeben/freigegeben. approved_at wird beim
|
||||
// ersten Wechsel zu freigegeben=true gesetzt und danach nicht mehr
|
||||
// verändert (er hält fest, wann zuerst freigegeben wurde).
|
||||
func (s *Store) UpdateParticipant(ctx context.Context, id string, vorgegeben, freigegeben bool) (Participant, error) {
|
||||
var p Participant
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
UPDATE participant
|
||||
SET vorgegeben = $2,
|
||||
freigegeben = $3,
|
||||
approved_at = CASE WHEN $3 AND approved_at IS NULL THEN now() ELSE approved_at END
|
||||
WHERE id = $1
|
||||
RETURNING id, submission_id, role, name, vorgegeben, freigegeben, approved_at, created_at
|
||||
`, id, vorgegeben, freigegeben).Scan(
|
||||
&p.ID, &p.SubmissionID, &p.Role, &p.Name, &p.Vorgegeben, &p.Freigegeben, &p.ApprovedAt, &p.CreatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Participant{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Participant{}, fmt.Errorf("store: update participant: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// DeleteParticipant entfernt einen Beteiligten (z. B. versehentlich
|
||||
// falsch angelegt).
|
||||
func (s *Store) DeleteParticipant(ctx context.Context, id string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `DELETE FROM participant WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete participant: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func TestParticipantCRUD(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
sub, err := s.CreateSubmission(ctx, accID, "instagram", "reel", "...")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSubmission: %v", err)
|
||||
}
|
||||
|
||||
p, err := s.CreateParticipant(ctx, sub.ID, "creator", "Max Mustermann", false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateParticipant: %v", err)
|
||||
}
|
||||
if p.ApprovedAt != nil {
|
||||
t.Fatalf("ApprovedAt should be nil when freigegeben=false, got %v", p.ApprovedAt)
|
||||
}
|
||||
|
||||
list, err := s.ListParticipants(ctx, sub.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListParticipants: %v", err)
|
||||
}
|
||||
if len(list) != 1 || list[0].ID != p.ID {
|
||||
t.Fatalf("ListParticipants = %+v, want exactly the created participant", list)
|
||||
}
|
||||
|
||||
got, err := s.GetParticipant(ctx, p.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetParticipant: %v", err)
|
||||
}
|
||||
if got.SubmissionID != sub.ID {
|
||||
t.Fatalf("GetParticipant.SubmissionID = %q, want %q", got.SubmissionID, sub.ID)
|
||||
}
|
||||
|
||||
updated, err := s.UpdateParticipant(ctx, p.ID, true, true)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateParticipant: %v", err)
|
||||
}
|
||||
if !updated.Vorgegeben || !updated.Freigegeben {
|
||||
t.Fatalf("UpdateParticipant did not apply new flags: %+v", updated)
|
||||
}
|
||||
if updated.ApprovedAt == nil {
|
||||
t.Fatal("expected ApprovedAt to be set once freigegeben became true")
|
||||
}
|
||||
firstApproval := *updated.ApprovedAt
|
||||
|
||||
// Ein erneutes Update (weiterhin freigegeben) darf approved_at nicht
|
||||
// verschieben — es haelt fest, wann ZUERST freigegeben wurde.
|
||||
updated2, err := s.UpdateParticipant(ctx, p.ID, true, true)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateParticipant (2): %v", err)
|
||||
}
|
||||
if !updated2.ApprovedAt.Equal(firstApproval) {
|
||||
t.Fatalf("ApprovedAt changed on a no-op update: %v -> %v", firstApproval, *updated2.ApprovedAt)
|
||||
}
|
||||
|
||||
if err := s.DeleteParticipant(ctx, p.ID); err != nil {
|
||||
t.Fatalf("DeleteParticipant: %v", err)
|
||||
}
|
||||
if _, err := s.GetParticipant(ctx, p.ID); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err after delete = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateParticipantNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
_, err := s.UpdateParticipant(context.Background(), "00000000-0000-0000-0000-000000000000", true, true)
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteParticipantNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
err := s.DeleteParticipant(context.Background(), "00000000-0000-0000-0000-000000000000")
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSubmissionsForAccount(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
subNoFindings, err := s.CreateSubmission(ctx, accID, "instagram", "reel", "organic")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSubmission: %v", err)
|
||||
}
|
||||
subWithFinding, err := s.CreateSubmission(ctx, accID, "tiktok", "video", "unmarked ad")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSubmission: %v", err)
|
||||
}
|
||||
// sources bewusst nil statt []string{} — CreateFinding muss das
|
||||
// selbst abfangen (siehe Kommentar dort), nicht der Aufrufer.
|
||||
if _, err := s.CreateFinding(ctx, subWithFinding.ID, nil, "WK-001", 1, "hoch", "t", "f", nil); err != nil {
|
||||
t.Fatalf("CreateFinding: %v", err)
|
||||
}
|
||||
|
||||
// Andere Mandanten duerfen nicht auftauchen.
|
||||
otherAccID := testAccountID(t, s)
|
||||
if _, err := s.CreateSubmission(ctx, otherAccID, "instagram", "reel", "anderer Mandant"); err != nil {
|
||||
t.Fatalf("CreateSubmission (other account): %v", err)
|
||||
}
|
||||
|
||||
list, err := s.ListSubmissionsForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSubmissionsForAccount: %v", err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("expected 2 submissions for this account, got %d: %+v", len(list), list)
|
||||
}
|
||||
|
||||
byID := map[string]store.SubmissionSummary{}
|
||||
for _, s := range list {
|
||||
byID[s.ID] = s
|
||||
}
|
||||
if byID[subNoFindings.ID].FindingCount != 0 || byID[subNoFindings.ID].HighestSeverity != "" {
|
||||
t.Errorf("subNoFindings summary = %+v, want 0 findings and no severity", byID[subNoFindings.ID])
|
||||
}
|
||||
if byID[subWithFinding.ID].FindingCount != 1 || byID[subWithFinding.ID].HighestSeverity != "hoch" {
|
||||
t.Errorf("subWithFinding summary = %+v, want 1 finding, severity hoch", byID[subWithFinding.ID])
|
||||
}
|
||||
}
|
||||
106
internal/store/registereintrag.go
Normal file
106
internal/store/registereintrag.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Registereintrag wird automatisch bei jeder Genehmigung erzeugt
|
||||
// (siehe CLAUDE.md, Entscheidung/Register/Wiedervorlage). Append-only
|
||||
// — siehe Migration. Bewusst denormalisiert: eigene Textspalten statt
|
||||
// Joins über bewertung/entscheidung/werkzeug, damit ein einmal
|
||||
// erzeugter Eintrag nicht von einem späteren Katalog- oder Abteilungs-
|
||||
// Wandel abhängt.
|
||||
type Registereintrag struct {
|
||||
ID string
|
||||
AccountID string
|
||||
AntragID string
|
||||
EntscheidungID string
|
||||
Zweck string
|
||||
Abteilung string
|
||||
Werkzeug string
|
||||
Datenklasse string
|
||||
Einstufung string
|
||||
Auflagen []string
|
||||
Verantwortlicher string
|
||||
EntschiedenAm time.Time
|
||||
GueltigBis *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
const registereintragColumns = `id, account_id, antrag_id, entscheidung_id, zweck, abteilung, werkzeug,
|
||||
datenklasse, einstufung, auflagen, verantwortlicher, entschieden_am, gueltig_bis, created_at`
|
||||
|
||||
func scanRegistereintrag(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (Registereintrag, error) {
|
||||
var e Registereintrag
|
||||
err := row.Scan(
|
||||
&e.ID, &e.AccountID, &e.AntragID, &e.EntscheidungID, &e.Zweck, &e.Abteilung, &e.Werkzeug,
|
||||
&e.Datenklasse, &e.Einstufung, &e.Auflagen, &e.Verantwortlicher, &e.EntschiedenAm, &e.GueltigBis, &e.CreatedAt,
|
||||
)
|
||||
return e, err
|
||||
}
|
||||
|
||||
// RegistereintragInput bündelt die Felder eines neuen Registereintrags.
|
||||
type RegistereintragInput struct {
|
||||
AccountID string
|
||||
AntragID string
|
||||
EntscheidungID string
|
||||
Zweck string
|
||||
Abteilung string
|
||||
Werkzeug string
|
||||
Datenklasse string
|
||||
Einstufung string
|
||||
Auflagen []string
|
||||
Verantwortlicher string
|
||||
EntschiedenAm time.Time
|
||||
GueltigBis *time.Time
|
||||
}
|
||||
|
||||
// CreateRegistereintrag speichert einen Registereintrag.
|
||||
func (s *Store) CreateRegistereintrag(ctx context.Context, in RegistereintragInput) (Registereintrag, error) {
|
||||
if in.Auflagen == nil {
|
||||
in.Auflagen = []string{}
|
||||
}
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO registereintrag (
|
||||
account_id, antrag_id, entscheidung_id, zweck, abteilung, werkzeug,
|
||||
datenklasse, einstufung, auflagen, verantwortlicher, entschieden_am, gueltig_bis
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING `+registereintragColumns,
|
||||
in.AccountID, in.AntragID, in.EntscheidungID, in.Zweck, in.Abteilung, in.Werkzeug,
|
||||
in.Datenklasse, in.Einstufung, in.Auflagen, in.Verantwortlicher, in.EntschiedenAm, in.GueltigBis,
|
||||
)
|
||||
e, err := scanRegistereintrag(row)
|
||||
if err != nil {
|
||||
return Registereintrag{}, fmt.Errorf("store: create registereintrag: %w", err)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// ListRegistereintraegeForAccount liefert alle Registereinträge eines
|
||||
// Mandanten, neueste zuerst.
|
||||
func (s *Store) ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]Registereintrag, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT `+registereintragColumns+` FROM registereintrag WHERE account_id = $1 ORDER BY entschieden_am DESC
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list registereintraege for account: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Registereintrag
|
||||
for rows.Next() {
|
||||
e, err := scanRegistereintrag(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan registereintrag: %w", err)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list registereintraege for account: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
100
internal/store/registereintrag_test.go
Normal file
100
internal/store/registereintrag_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func testRegistereintragInput(accountID, antragID, entscheidungID string) store.RegistereintragInput {
|
||||
gueltigBis := time.Now().Add(365 * 24 * time.Hour).Truncate(time.Millisecond)
|
||||
return store.RegistereintragInput{
|
||||
AccountID: accountID, AntragID: antragID, EntscheidungID: entscheidungID,
|
||||
Zweck: "Angebotstexte generieren", Abteilung: "Vertrieb", Werkzeug: "ChatGPT Enterprise",
|
||||
Datenklasse: "personenbezogen", Einstufung: "minimal",
|
||||
Auflagen: []string{"AVV erforderlich", "kein Training auf Eingaben"},
|
||||
Verantwortlicher: "verantwortlicher@example.com", EntschiedenAm: time.Now().Truncate(time.Millisecond),
|
||||
GueltigBis: &gueltigBis,
|
||||
}
|
||||
}
|
||||
|
||||
func testEntscheidungID(t *testing.T, s *store.Store, antragID, bewertungID, userID string) string {
|
||||
t.Helper()
|
||||
e, err := s.CreateEntscheidung(context.Background(), store.EntscheidungInput{
|
||||
AntragID: antragID, BewertungID: bewertungID, EntscheiderUserID: userID, Entscheidung: "genehmigt",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateEntscheidung: %v", err)
|
||||
}
|
||||
return e.ID
|
||||
}
|
||||
|
||||
func TestRegistereintragCreateAndList(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
antrag, bewertung := testAntragMitBewertung(t, s, accID, userID)
|
||||
entscheidungID := testEntscheidungID(t, s, antrag.ID, bewertung.ID, userID)
|
||||
|
||||
e, err := s.CreateRegistereintrag(ctx, testRegistereintragInput(accID, antrag.ID, entscheidungID))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRegistereintrag: %v", err)
|
||||
}
|
||||
if e.Werkzeug != "ChatGPT Enterprise" || len(e.Auflagen) != 2 {
|
||||
t.Fatalf("CreateRegistereintrag = %+v, unerwartete Werte", e)
|
||||
}
|
||||
|
||||
list, err := s.ListRegistereintraegeForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListRegistereintraegeForAccount: %v", err)
|
||||
}
|
||||
if len(list) != 1 || list[0].ID != e.ID {
|
||||
t.Fatalf("ListRegistereintraegeForAccount = %+v, want exactly one entry", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistereintragIsolatesTenants(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accA := testAccountID(t, s)
|
||||
userA := testUserID(t, s, accA)
|
||||
antragA, bewertungA := testAntragMitBewertung(t, s, accA, userA)
|
||||
entscheidungA := testEntscheidungID(t, s, antragA.ID, bewertungA.ID, userA)
|
||||
if _, err := s.CreateRegistereintrag(ctx, testRegistereintragInput(accA, antragA.ID, entscheidungA)); err != nil {
|
||||
t.Fatalf("CreateRegistereintrag (A): %v", err)
|
||||
}
|
||||
|
||||
accB := testAccountID(t, s)
|
||||
list, err := s.ListRegistereintraegeForAccount(ctx, accB)
|
||||
if err != nil {
|
||||
t.Fatalf("ListRegistereintraegeForAccount (B): %v", err)
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("expected no registereintraege for a different tenant, got %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistereintragIsAppendOnly(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
userID := testUserID(t, s, accID)
|
||||
antrag, bewertung := testAntragMitBewertung(t, s, accID, userID)
|
||||
entscheidungID := testEntscheidungID(t, s, antrag.ID, bewertung.ID, userID)
|
||||
e, err := s.CreateRegistereintrag(ctx, testRegistereintragInput(accID, antrag.ID, entscheidungID))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRegistereintrag: %v", err)
|
||||
}
|
||||
|
||||
_, err = s.Pool.Exec(ctx, `UPDATE registereintrag SET werkzeug = 'geaendert' WHERE id = $1`, e.ID)
|
||||
if err == nil {
|
||||
t.Fatal("expected UPDATE on registereintrag to be rejected by the append-only trigger")
|
||||
}
|
||||
_, err = s.Pool.Exec(ctx, `DELETE FROM registereintrag WHERE id = $1`, e.ID)
|
||||
if err == nil {
|
||||
t.Fatal("expected DELETE on registereintrag to be rejected by the append-only trigger")
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,30 @@ func testDatabaseURL(t *testing.T) string {
|
||||
return url
|
||||
}
|
||||
|
||||
func openTestStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
url := testDatabaseURL(t)
|
||||
if err := store.Migrate(url); err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
s, err := store.Open(context.Background(), url)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(s.Close)
|
||||
return s
|
||||
}
|
||||
|
||||
// testAccountID legt einen Mandanten an und liefert dessen ID.
|
||||
func testAccountID(t *testing.T, s *store.Store) string {
|
||||
t.Helper()
|
||||
acc, err := s.CreateAccount(context.Background(), "Test-Mandant")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
return acc.ID
|
||||
}
|
||||
|
||||
func TestMigrateAndOpen(t *testing.T) {
|
||||
url := testDatabaseURL(t)
|
||||
|
||||
@@ -36,56 +60,11 @@ func TestMigrateAndOpen(t *testing.T) {
|
||||
err = s.Pool.QueryRow(context.Background(), `
|
||||
SELECT count(*) FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = ANY($1)
|
||||
`, []string{"submission", "asset", "extraction", "finding", "evidence_package", "participant"}).Scan(&tableCount)
|
||||
`, []string{"account", "app_user", "session", "audit_log", "abteilung", "werkzeug", "werkzeug_sperre", "antrag"}).Scan(&tableCount)
|
||||
if err != nil {
|
||||
t.Fatalf("query tables: %v", err)
|
||||
}
|
||||
if tableCount != 6 {
|
||||
t.Fatalf("expected 6 tables, got %d", tableCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindingIsAppendOnly(t *testing.T) {
|
||||
url := testDatabaseURL(t)
|
||||
|
||||
if err := store.Migrate(url); err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
|
||||
s, err := store.Open(context.Background(), url)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
acc, err := s.CreateAccount(ctx, "Test-Mandant")
|
||||
if err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
|
||||
var submissionID string
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO submission (account_id, platform, post_type) VALUES ($1, 'instagram', 'reel')
|
||||
RETURNING id
|
||||
`, acc.ID).Scan(&submissionID)
|
||||
if err != nil {
|
||||
t.Fatalf("insert submission: %v", err)
|
||||
}
|
||||
|
||||
var findingID string
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO finding (submission_id, rule_id, rule_version, severity, title, fix, sources)
|
||||
VALUES ($1, 'WK-004', 3, 'hoch', 'Testfeststellung', 'Testkorrektur', '{}')
|
||||
RETURNING id
|
||||
`, submissionID).Scan(&findingID)
|
||||
if err != nil {
|
||||
t.Fatalf("insert finding: %v", err)
|
||||
}
|
||||
|
||||
_, err = s.Pool.Exec(ctx, `UPDATE finding SET title = 'geändert' WHERE id = $1`, findingID)
|
||||
if err == nil {
|
||||
t.Fatal("expected UPDATE on finding to be rejected, but it succeeded")
|
||||
if tableCount != 8 {
|
||||
t.Fatalf("expected 8 tables, got %d", tableCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Submission ist ein eingereichter Beitrag. AccountID ist der Mandant,
|
||||
// dem der Beitrag gehört (Mandantentrennung) — jede Abfrage, die einen
|
||||
// Beitrag ausliefert, muss AccountID gegen den angemeldeten Account
|
||||
// prüfen (siehe internal/web-Middleware), store selbst erzwingt das
|
||||
// nicht auf Zeilenebene.
|
||||
type Submission struct {
|
||||
ID string
|
||||
AccountID string
|
||||
Platform string
|
||||
PostType string
|
||||
Caption string
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateSubmission legt einen neuen Beitrag für einen Mandanten an
|
||||
// (Status "draft").
|
||||
func (s *Store) CreateSubmission(ctx context.Context, accountID, platform, postType, caption string) (Submission, error) {
|
||||
var sub Submission
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO submission (account_id, platform, post_type, caption)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, account_id, platform, post_type, caption, status, created_at, updated_at
|
||||
`, accountID, platform, postType, caption).Scan(
|
||||
&sub.ID, &sub.AccountID, &sub.Platform, &sub.PostType, &sub.Caption, &sub.Status, &sub.CreatedAt, &sub.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Submission{}, fmt.Errorf("store: create submission: %w", err)
|
||||
}
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
// GetSubmission liest einen Beitrag anhand seiner ID — ohne
|
||||
// Mandanten-Prüfung, das ist Sache des Aufrufers (siehe Submission.AccountID).
|
||||
func (s *Store) GetSubmission(ctx context.Context, id string) (Submission, error) {
|
||||
var sub Submission
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, account_id, platform, post_type, caption, status, created_at, updated_at
|
||||
FROM submission WHERE id = $1
|
||||
`, id).Scan(
|
||||
&sub.ID, &sub.AccountID, &sub.Platform, &sub.PostType, &sub.Caption, &sub.Status, &sub.CreatedAt, &sub.UpdatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Submission{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Submission{}, fmt.Errorf("store: get submission: %w", err)
|
||||
}
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
// SubmissionSummary ist eine Submission plus einer Kurzfassung ihrer
|
||||
// aktuell gültigen Findings, wie sie eine Übersichtsliste braucht (ohne
|
||||
// für jede Zeile extra ListCurrentFindings aufzurufen).
|
||||
type SubmissionSummary struct {
|
||||
Submission
|
||||
FindingCount int
|
||||
HighestSeverity string // "" wenn keine Findings
|
||||
}
|
||||
|
||||
// ListSubmissionsForAccount liefert alle Beiträge eines Mandanten,
|
||||
// neueste zuerst, mit Findings-Kurzfassung.
|
||||
func (s *Store) ListSubmissionsForAccount(ctx context.Context, accountID string) ([]SubmissionSummary, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT
|
||||
s.id, s.account_id, s.platform, s.post_type, s.caption, s.status, s.created_at, s.updated_at,
|
||||
COUNT(f.id) AS finding_count,
|
||||
COALESCE(MAX(CASE f.severity WHEN 'hoch' THEN 3 WHEN 'mittel' THEN 2 WHEN 'niedrig' THEN 1 ELSE 0 END), 0) AS severity_rank
|
||||
FROM submission s
|
||||
LEFT JOIN finding f
|
||||
ON f.submission_id = s.id
|
||||
AND NOT EXISTS (SELECT 1 FROM finding f2 WHERE f2.supersedes = f.id)
|
||||
WHERE s.account_id = $1
|
||||
GROUP BY s.id
|
||||
ORDER BY s.created_at DESC
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list submissions for account: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []SubmissionSummary
|
||||
for rows.Next() {
|
||||
var sub SubmissionSummary
|
||||
var severityRank int
|
||||
if err := rows.Scan(
|
||||
&sub.ID, &sub.AccountID, &sub.Platform, &sub.PostType, &sub.Caption, &sub.Status, &sub.CreatedAt, &sub.UpdatedAt,
|
||||
&sub.FindingCount, &severityRank,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("store: scan submission summary: %w", err)
|
||||
}
|
||||
switch severityRank {
|
||||
case 3:
|
||||
sub.HighestSeverity = "hoch"
|
||||
case 2:
|
||||
sub.HighestSeverity = "mittel"
|
||||
case 1:
|
||||
sub.HighestSeverity = "niedrig"
|
||||
}
|
||||
out = append(out, sub)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list submissions for account: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetSubmissionStatus setzt den Status eines Beitrags (submission ist,
|
||||
// anders als extraction/finding/evidence_package, NICHT append-only —
|
||||
// der Lebenszyklus draft → checked → published → archived ist eine
|
||||
// normale Zustandsänderung, kein Beweis-Eintrag).
|
||||
func (s *Store) SetSubmissionStatus(ctx context.Context, id, status string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `
|
||||
UPDATE submission SET status = $2, updated_at = now() WHERE id = $1
|
||||
`, id, status)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: set submission status: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("store: set submission status: submission %s nicht gefunden", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -15,28 +15,41 @@ import (
|
||||
// Login: falsche E-Mail vs. Datenbankfehler).
|
||||
var ErrNotFound = errors.New("store: nicht gefunden")
|
||||
|
||||
// User ist ein Login innerhalb eines Account (Mandanten).
|
||||
// User ist ein Login innerhalb eines Account (Mandanten). Nutzer werden
|
||||
// nicht gelöscht (app_user wird von antrag/session/entscheidung/
|
||||
// audit_log per Foreign Key referenziert — ein Hard-Delete würde die
|
||||
// Historie zerstören), sondern über Active deaktiviert.
|
||||
type User struct {
|
||||
ID string
|
||||
AccountID string
|
||||
Email string
|
||||
PasswordHash string
|
||||
Role string
|
||||
Active bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
const userColumns = `id, account_id, email, password_hash, role, active, created_at`
|
||||
|
||||
func scanUser(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (User, error) {
|
||||
var u User
|
||||
err := row.Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.Active, &u.CreatedAt)
|
||||
return u, err
|
||||
}
|
||||
|
||||
// CreateUser legt einen neuen Nutzer innerhalb eines Accounts an.
|
||||
// passwordHash muss bereits gehasht sein (siehe internal/auth) — store
|
||||
// speichert nur, es hasht nicht selbst.
|
||||
func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (User, error) {
|
||||
var u User
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO app_user (account_id, email, password_hash, role)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, account_id, email, password_hash, role, created_at
|
||||
`, accountID, email, passwordHash, role).Scan(
|
||||
&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt,
|
||||
RETURNING `+userColumns,
|
||||
accountID, email, passwordHash, role,
|
||||
)
|
||||
u, err := scanUser(row)
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("store: create user: %w", err)
|
||||
}
|
||||
@@ -46,11 +59,8 @@ func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash,
|
||||
// GetUserByEmail liest einen Nutzer anhand seiner E-Mail-Adresse.
|
||||
// Liefert ErrNotFound, wenn keine E-Mail passt (kein Datenbankfehler).
|
||||
func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error) {
|
||||
var u User
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, account_id, email, password_hash, role, created_at
|
||||
FROM app_user WHERE email = $1
|
||||
`, email).Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt)
|
||||
row := s.Pool.QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE email = $1`, email)
|
||||
u, err := scanUser(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, ErrNotFound
|
||||
}
|
||||
@@ -62,11 +72,8 @@ func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error)
|
||||
|
||||
// GetUser liest einen Nutzer anhand seiner ID.
|
||||
func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
|
||||
var u User
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, account_id, email, password_hash, role, created_at
|
||||
FROM app_user WHERE id = $1
|
||||
`, id).Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt)
|
||||
row := s.Pool.QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE id = $1`, id)
|
||||
u, err := scanUser(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, ErrNotFound
|
||||
}
|
||||
@@ -75,3 +82,43 @@ func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// ListUsersForAccount liefert alle Logins eines Mandanten — für den
|
||||
// Admin-Bereich (Account-Detailansicht).
|
||||
func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]User, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT `+userColumns+` FROM app_user WHERE account_id = $1 ORDER BY created_at
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list users for account: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []User
|
||||
for rows.Next() {
|
||||
u, err := scanUser(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan user: %w", err)
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list users for account: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetUserActive (de-)aktiviert einen Login. Ein deaktivierter Nutzer
|
||||
// kann sich nicht mehr anmelden (siehe web.handleLogin), bleibt aber
|
||||
// als Akteur in bestehenden Anträgen/Entscheidungen/Audit-Log-Einträgen
|
||||
// nachvollziehbar — deshalb (de-)aktivieren statt löschen.
|
||||
func (s *Store) SetUserActive(ctx context.Context, id string, active bool) error {
|
||||
tag, err := s.Pool.Exec(ctx, `UPDATE app_user SET active = $2 WHERE id = $1`, id, active)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: set user active: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
301
internal/store/werkzeug.go
Normal file
301
internal/store/werkzeug.go
Normal file
@@ -0,0 +1,301 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Werkzeug ist ein Eintrag im KI-Werkzeugkatalog — der eigentliche Wert
|
||||
// des Produkts (siehe CLAUDE.md). AccountID nil markiert einen zentral
|
||||
// gepflegten, für alle Mandanten identischen Katalogeintrag; ein
|
||||
// gesetzter AccountID ist eine mandantenspezifische Ergänzung.
|
||||
// LetztePruefung und Quelle sind Pflicht — jede Zusicherung im Katalog
|
||||
// muss belegbar sein.
|
||||
type Werkzeug struct {
|
||||
ID string
|
||||
AccountID *string
|
||||
Name string
|
||||
Anbieter string
|
||||
Verarbeitungsort string
|
||||
AVVVerfuegbar bool
|
||||
AVVURL string
|
||||
TrainingOptOut bool
|
||||
TrainingStandard bool
|
||||
AufbewahrungTage int
|
||||
Zertifizierungen []string
|
||||
GeeigneteZwecke []string
|
||||
Einschraenkungen []string
|
||||
LetztePruefung time.Time
|
||||
Quelle string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// WerkzeugInput bündelt die Felder eines Werkzeug-Eintrags für
|
||||
// Create/Update — bei 14 Feldern lesbarer als eine positionale
|
||||
// Parameterliste.
|
||||
type WerkzeugInput struct {
|
||||
AccountID *string
|
||||
Name string
|
||||
Anbieter string
|
||||
Verarbeitungsort string
|
||||
AVVVerfuegbar bool
|
||||
AVVURL string
|
||||
TrainingOptOut bool
|
||||
TrainingStandard bool
|
||||
AufbewahrungTage int
|
||||
Zertifizierungen []string
|
||||
GeeigneteZwecke []string
|
||||
Einschraenkungen []string
|
||||
LetztePruefung time.Time
|
||||
Quelle string
|
||||
}
|
||||
|
||||
const werkzeugColumns = `id, account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url,
|
||||
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen, geeignete_zwecke,
|
||||
einschraenkungen, letzte_pruefung, quelle, created_at, updated_at`
|
||||
|
||||
func scanWerkzeug(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (Werkzeug, error) {
|
||||
var w Werkzeug
|
||||
err := row.Scan(
|
||||
&w.ID, &w.AccountID, &w.Name, &w.Anbieter, &w.Verarbeitungsort, &w.AVVVerfuegbar, &w.AVVURL,
|
||||
&w.TrainingOptOut, &w.TrainingStandard, &w.AufbewahrungTage, &w.Zertifizierungen, &w.GeeigneteZwecke,
|
||||
&w.Einschraenkungen, &w.LetztePruefung, &w.Quelle, &w.CreatedAt, &w.UpdatedAt,
|
||||
)
|
||||
return w, err
|
||||
}
|
||||
|
||||
// normalizeWerkzeugSlices ersetzt nil-Slices durch leere Slices — die
|
||||
// Spalten sind TEXT[] NOT NULL, ein nil-Slice (z. B. wenn eine
|
||||
// Einschränkung optional ist) käme sonst als SQL-NULL an und würde mit
|
||||
// einer wenig hilfreichen Constraint-Fehlermeldung abgelehnt (derselbe
|
||||
// Fall wie früher bei finding.sources).
|
||||
func normalizeWerkzeugSlices(in *WerkzeugInput) {
|
||||
if in.Zertifizierungen == nil {
|
||||
in.Zertifizierungen = []string{}
|
||||
}
|
||||
if in.GeeigneteZwecke == nil {
|
||||
in.GeeigneteZwecke = []string{}
|
||||
}
|
||||
if in.Einschraenkungen == nil {
|
||||
in.Einschraenkungen = []string{}
|
||||
}
|
||||
}
|
||||
|
||||
// CreateWerkzeug legt einen Katalogeintrag an.
|
||||
func (s *Store) CreateWerkzeug(ctx context.Context, in WerkzeugInput) (Werkzeug, error) {
|
||||
normalizeWerkzeugSlices(&in)
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO werkzeug (
|
||||
account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url,
|
||||
training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen,
|
||||
geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING `+werkzeugColumns,
|
||||
in.AccountID, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL,
|
||||
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen,
|
||||
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
|
||||
)
|
||||
w, err := scanWerkzeug(row)
|
||||
if err != nil {
|
||||
return Werkzeug{}, fmt.Errorf("store: create werkzeug: %w", err)
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// UpdateWerkzeug ersetzt die Felder eines bestehenden Katalogeintrags
|
||||
// (z. B. bei einer erneuten Prüfung der Zusicherungen). Werkzeug ist
|
||||
// bewusst nicht append-only — anders als ein Beweisstück ist der
|
||||
// Katalog ein gepflegter, sich änderender Datenbestand; eine Entscheidung
|
||||
// friert den zu diesem Zeitpunkt gültigen Datensatz stattdessen separat ein.
|
||||
func (s *Store) UpdateWerkzeug(ctx context.Context, id string, in WerkzeugInput) (Werkzeug, error) {
|
||||
normalizeWerkzeugSlices(&in)
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
UPDATE werkzeug SET
|
||||
name = $2, anbieter = $3, verarbeitungsort = $4, avv_verfuegbar = $5, avv_url = $6,
|
||||
training_opt_out = $7, training_standard = $8, aufbewahrung_tage = $9,
|
||||
zertifizierungen = $10, geeignete_zwecke = $11, einschraenkungen = $12,
|
||||
letzte_pruefung = $13, quelle = $14, updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING `+werkzeugColumns,
|
||||
id, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL,
|
||||
in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen,
|
||||
in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle,
|
||||
)
|
||||
w, err := scanWerkzeug(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Werkzeug{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Werkzeug{}, fmt.Errorf("store: update werkzeug: %w", err)
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// GetWerkzeug liest einen Katalogeintrag anhand seiner ID.
|
||||
func (s *Store) GetWerkzeug(ctx context.Context, id string) (Werkzeug, error) {
|
||||
row := s.Pool.QueryRow(ctx, `SELECT `+werkzeugColumns+` FROM werkzeug WHERE id = $1`, id)
|
||||
w, err := scanWerkzeug(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Werkzeug{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Werkzeug{}, fmt.Errorf("store: get werkzeug: %w", err)
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// DeleteWerkzeug entfernt einen Katalogeintrag.
|
||||
func (s *Store) DeleteWerkzeug(ctx context.Context, id string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `DELETE FROM werkzeug WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete werkzeug: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListWerkzeugeForAccount liefert den für einen Mandanten sichtbaren
|
||||
// Katalog: alle zentralen Einträge, die dieser Mandant nicht gesperrt
|
||||
// hat, plus seine eigenen mandantenspezifischen Ergänzungen.
|
||||
func (s *Store) ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]Werkzeug, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT `+werkzeugColumns+` FROM werkzeug w
|
||||
WHERE (w.account_id IS NULL AND NOT EXISTS (
|
||||
SELECT 1 FROM werkzeug_sperre ws WHERE ws.werkzeug_id = w.id AND ws.account_id = $1
|
||||
)) OR w.account_id = $1
|
||||
ORDER BY w.name
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list werkzeuge for account: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Werkzeug
|
||||
for rows.Next() {
|
||||
w, err := scanWerkzeug(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan werkzeug: %w", err)
|
||||
}
|
||||
out = append(out, w)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list werkzeuge for account: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListZentraleWerkzeuge liefert den vollständigen zentralen Katalog
|
||||
// (account_id IS NULL) — für den Admin-Bereich, unabhängig von
|
||||
// Mandanten-Sperrungen.
|
||||
func (s *Store) ListZentraleWerkzeuge(ctx context.Context) ([]Werkzeug, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT `+werkzeugColumns+` FROM werkzeug WHERE account_id IS NULL ORDER BY name
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list zentrale werkzeuge: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Werkzeug
|
||||
for rows.Next() {
|
||||
w, err := scanWerkzeug(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: scan werkzeug: %w", err)
|
||||
}
|
||||
out = append(out, w)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list zentrale werkzeuge: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CurrentKatalogVersion liefert eine reproduzierbare Kennung des
|
||||
// aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird
|
||||
// in jeder Bewertung/Entscheidung eingefroren, damit im Audit
|
||||
// nachvollziehbar bleibt, mit welchem Katalogstand ein Vorschlag
|
||||
// erzeugt wurde.
|
||||
func (s *Store) CurrentKatalogVersion(ctx context.Context) (string, error) {
|
||||
var count int
|
||||
var lastUpdate time.Time
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT count(*), COALESCE(MAX(updated_at), 'epoch'::timestamptz) FROM werkzeug
|
||||
`).Scan(&count, &lastUpdate)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("store: current katalog version: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("%d-%d", count, lastUpdate.Unix()), nil
|
||||
}
|
||||
|
||||
// WerkzeugSperre ist die Sperrung eines (auch zentralen) Katalogeintrags
|
||||
// durch einen einzelnen Mandanten — der zentrale Katalog selbst bleibt
|
||||
// dabei unverändert.
|
||||
type WerkzeugSperre struct {
|
||||
ID string
|
||||
AccountID string
|
||||
WerkzeugID string
|
||||
Grund string
|
||||
GesperrtAm time.Time
|
||||
}
|
||||
|
||||
// CreateWerkzeugSperre sperrt ein Werkzeug für einen Mandanten.
|
||||
func (s *Store) CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID, grund string) (WerkzeugSperre, error) {
|
||||
var sp WerkzeugSperre
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO werkzeug_sperre (account_id, werkzeug_id, grund)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, account_id, werkzeug_id, grund, gesperrt_am
|
||||
`, accountID, werkzeugID, grund).Scan(&sp.ID, &sp.AccountID, &sp.WerkzeugID, &sp.Grund, &sp.GesperrtAm)
|
||||
if err != nil {
|
||||
return WerkzeugSperre{}, fmt.Errorf("store: create werkzeug sperre: %w", err)
|
||||
}
|
||||
return sp, nil
|
||||
}
|
||||
|
||||
// DeleteWerkzeugSperre hebt eine Sperrung wieder auf.
|
||||
func (s *Store) DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `
|
||||
DELETE FROM werkzeug_sperre WHERE account_id = $1 AND werkzeug_id = $2
|
||||
`, accountID, werkzeugID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete werkzeug sperre: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListWerkzeugSperrenForAccount liefert alle Sperrungen eines Mandanten.
|
||||
func (s *Store) ListWerkzeugSperrenForAccount(ctx context.Context, accountID string) ([]WerkzeugSperre, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, account_id, werkzeug_id, grund, gesperrt_am
|
||||
FROM werkzeug_sperre WHERE account_id = $1 ORDER BY gesperrt_am DESC
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list werkzeug sperren: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []WerkzeugSperre
|
||||
for rows.Next() {
|
||||
var sp WerkzeugSperre
|
||||
if err := rows.Scan(&sp.ID, &sp.AccountID, &sp.WerkzeugID, &sp.Grund, &sp.GesperrtAm); err != nil {
|
||||
return nil, fmt.Errorf("store: scan werkzeug sperre: %w", err)
|
||||
}
|
||||
out = append(out, sp)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list werkzeug sperren: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
219
internal/store/werkzeug_test.go
Normal file
219
internal/store/werkzeug_test.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func testWerkzeugInput(accountID *string, name string) store.WerkzeugInput {
|
||||
return store.WerkzeugInput{
|
||||
AccountID: accountID,
|
||||
Name: name,
|
||||
Anbieter: "Beispiel-Anbieter GmbH",
|
||||
Verarbeitungsort: "EU",
|
||||
AVVVerfuegbar: true,
|
||||
AVVURL: "https://beispiel.example/avv",
|
||||
TrainingOptOut: true,
|
||||
TrainingStandard: true,
|
||||
AufbewahrungTage: 30,
|
||||
Zertifizierungen: []string{"ISO 27001"},
|
||||
GeeigneteZwecke: []string{"Textgenerierung"},
|
||||
Einschraenkungen: nil,
|
||||
LetztePruefung: time.Now().Add(-24 * time.Hour).Truncate(time.Millisecond),
|
||||
Quelle: "https://beispiel.example/beleg",
|
||||
}
|
||||
}
|
||||
|
||||
func TestWerkzeugCRUD(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
w, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "Zentrales Werkzeug"))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWerkzeug: %v", err)
|
||||
}
|
||||
if w.AccountID != nil {
|
||||
t.Fatalf("expected AccountID nil fuer zentralen Katalogeintrag, got %v", w.AccountID)
|
||||
}
|
||||
if len(w.Zertifizierungen) != 1 || w.Zertifizierungen[0] != "ISO 27001" {
|
||||
t.Fatalf("Zertifizierungen = %v, want [ISO 27001]", w.Zertifizierungen)
|
||||
}
|
||||
|
||||
got, err := s.GetWerkzeug(ctx, w.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWerkzeug: %v", err)
|
||||
}
|
||||
if got.Name != "Zentrales Werkzeug" {
|
||||
t.Fatalf("Name = %q, want Zentrales Werkzeug", got.Name)
|
||||
}
|
||||
|
||||
updateInput := testWerkzeugInput(nil, "Zentrales Werkzeug (aktualisiert)")
|
||||
updateInput.AufbewahrungTage = 14
|
||||
updated, err := s.UpdateWerkzeug(ctx, w.ID, updateInput)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateWerkzeug: %v", err)
|
||||
}
|
||||
if updated.Name != "Zentrales Werkzeug (aktualisiert)" || updated.AufbewahrungTage != 14 {
|
||||
t.Fatalf("UpdateWerkzeug = %+v, unerwartete Werte", updated)
|
||||
}
|
||||
|
||||
if err := s.DeleteWerkzeug(ctx, w.ID); err != nil {
|
||||
t.Fatalf("DeleteWerkzeug: %v", err)
|
||||
}
|
||||
if _, err := s.GetWerkzeug(ctx, w.ID); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err nach Delete = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateWerkzeugNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
_, err := s.UpdateWerkzeug(context.Background(), "00000000-0000-0000-0000-000000000000", testWerkzeugInput(nil, "x"))
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListWerkzeugeForAccountIncludesCentralAndOwnEntries(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
zentral, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "Zentral A"))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWerkzeug (zentral): %v", err)
|
||||
}
|
||||
eigenes, err := s.CreateWerkzeug(ctx, testWerkzeugInput(&accID, "Mandanten-eigenes Werkzeug"))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWerkzeug (eigenes): %v", err)
|
||||
}
|
||||
|
||||
// Anderer Mandant darf das eigene Werkzeug nicht sehen.
|
||||
otherAcc := testAccountID(t, s)
|
||||
otherList, err := s.ListWerkzeugeForAccount(ctx, otherAcc)
|
||||
if err != nil {
|
||||
t.Fatalf("ListWerkzeugeForAccount (other): %v", err)
|
||||
}
|
||||
for _, w := range otherList {
|
||||
if w.ID == eigenes.ID {
|
||||
t.Fatal("expected the other account's own werkzeug to stay isolated")
|
||||
}
|
||||
}
|
||||
|
||||
list, err := s.ListWerkzeugeForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListWerkzeugeForAccount: %v", err)
|
||||
}
|
||||
byID := map[string]bool{}
|
||||
for _, w := range list {
|
||||
byID[w.ID] = true
|
||||
}
|
||||
if !byID[zentral.ID] || !byID[eigenes.ID] {
|
||||
t.Fatalf("expected both the central and the own werkzeug, got %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWerkzeugSperreHidesCentralEntry(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
zentral, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "Zu sperrendes Werkzeug"))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWerkzeug: %v", err)
|
||||
}
|
||||
|
||||
if _, err := s.CreateWerkzeugSperre(ctx, accID, zentral.ID, "vom Mandanten intern verboten"); err != nil {
|
||||
t.Fatalf("CreateWerkzeugSperre: %v", err)
|
||||
}
|
||||
|
||||
list, err := s.ListWerkzeugeForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListWerkzeugeForAccount: %v", err)
|
||||
}
|
||||
for _, w := range list {
|
||||
if w.ID == zentral.ID {
|
||||
t.Fatal("expected the sperred central werkzeug to be hidden for this account")
|
||||
}
|
||||
}
|
||||
|
||||
// Der zentrale Katalog selbst bleibt fuer andere Mandanten sichtbar.
|
||||
otherAcc := testAccountID(t, s)
|
||||
otherList, err := s.ListWerkzeugeForAccount(ctx, otherAcc)
|
||||
if err != nil {
|
||||
t.Fatalf("ListWerkzeugeForAccount (other): %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, w := range otherList {
|
||||
if w.ID == zentral.ID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected the central werkzeug to remain visible for a different account")
|
||||
}
|
||||
|
||||
if err := s.DeleteWerkzeugSperre(ctx, accID, zentral.ID); err != nil {
|
||||
t.Fatalf("DeleteWerkzeugSperre: %v", err)
|
||||
}
|
||||
listAfter, err := s.ListWerkzeugeForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListWerkzeugeForAccount nach Entsperren: %v", err)
|
||||
}
|
||||
found = false
|
||||
for _, w := range listAfter {
|
||||
if w.ID == zentral.ID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected the werkzeug to be visible again after DeleteWerkzeugSperre")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentKatalogVersionChangesOnCreate(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
before, err := s.CurrentKatalogVersion(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CurrentKatalogVersion: %v", err)
|
||||
}
|
||||
if _, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "Version-Test-Werkzeug")); err != nil {
|
||||
t.Fatalf("CreateWerkzeug: %v", err)
|
||||
}
|
||||
after, err := s.CurrentKatalogVersion(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CurrentKatalogVersion (2): %v", err)
|
||||
}
|
||||
if before == after {
|
||||
t.Fatalf("expected CurrentKatalogVersion to change after adding a werkzeug, got %q both times", before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListZentraleWerkzeugeExcludesMandantenEigene(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
if _, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "Zentral B")); err != nil {
|
||||
t.Fatalf("CreateWerkzeug (zentral): %v", err)
|
||||
}
|
||||
eigenes, err := s.CreateWerkzeug(ctx, testWerkzeugInput(&accID, "Mandanten-eigenes B"))
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWerkzeug (eigenes): %v", err)
|
||||
}
|
||||
|
||||
list, err := s.ListZentraleWerkzeuge(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListZentraleWerkzeuge: %v", err)
|
||||
}
|
||||
for _, w := range list {
|
||||
if w.ID == eigenes.ID {
|
||||
t.Fatal("expected ListZentraleWerkzeuge to exclude mandantenspezifische Eintraege")
|
||||
}
|
||||
}
|
||||
}
|
||||
96
internal/web/abteilung_handlers_test.go
Normal file
96
internal/web/abteilung_handlers_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAdminCanCreateAndDeleteAbteilung(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/abteilungen", url.Values{"name": {"Vertrieb"}})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("create status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
list := getWithCookie(t, s, adminCookie, "/verwaltung/abteilungen")
|
||||
if !strings.Contains(list.Body.String(), "Vertrieb") {
|
||||
t.Errorf("expected Vertrieb in the Abteilungsliste, got: %s", list.Body.String())
|
||||
}
|
||||
|
||||
var abtID string
|
||||
for accID, abts := range fs.abteilungen {
|
||||
_ = accID
|
||||
for _, a := range abts {
|
||||
if a.Name == "Vertrieb" {
|
||||
abtID = a.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
if abtID == "" {
|
||||
t.Fatal("expected Vertrieb to exist in fakeStore")
|
||||
}
|
||||
|
||||
delResp := postForm(t, s, adminCookie, "/verwaltung/abteilungen/"+abtID+"/loeschen", url.Values{})
|
||||
if delResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("delete status = %d, body: %s", delResp.Code, delResp.Body.String())
|
||||
}
|
||||
if _, err := fs.GetAbteilung(context.Background(), abtID); err == nil {
|
||||
t.Error("expected the Abteilung to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeueAntragFormZeigtAngelegteAbteilung(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
postForm(t, s, adminCookie, "/verwaltung/abteilungen", url.Values{"name": {"Marketing"}})
|
||||
|
||||
resp := getWithCookie(t, s, adminCookie, "/antraege/neu")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "Marketing") {
|
||||
t.Errorf("expected the newly created Abteilung in the Fragebogen, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMitarbeiterCannotAccessAbteilungsverwaltung(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/abteilungen")
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCannotDeleteForeignAbteilung(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminA := seedAccountWithRole(t, fs, "Mandant A", "admin-a@example.com", "admin")
|
||||
postForm(t, s, adminA, "/verwaltung/abteilungen", url.Values{"name": {"Nur A"}})
|
||||
var abtID string
|
||||
for _, abts := range fs.abteilungen {
|
||||
for _, a := range abts {
|
||||
if a.Name == "Nur A" {
|
||||
abtID = a.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
adminB := seedAccountWithRole(t, fs, "Mandant B", "admin-b@example.com", "admin")
|
||||
|
||||
resp := postForm(t, s, adminB, "/verwaltung/abteilungen/"+abtID+"/loeschen", url.Values{})
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for a foreign account's Abteilung", resp.Code)
|
||||
}
|
||||
if _, err := fs.GetAbteilung(context.Background(), abtID); err != nil {
|
||||
t.Error("expected the foreign Abteilung to still exist")
|
||||
}
|
||||
}
|
||||
72
internal/web/admin_fachebene_test.go
Normal file
72
internal/web/admin_fachebene_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAdminHatFachebeneRechte prüft die pragmatische Lösung für "Admin
|
||||
// und KI-Verantwortlicher auf derselben Person" (siehe CLAUDE.md,
|
||||
// Offene Punkte / requireFachebene): ein admin sieht den Posteingang
|
||||
// und kann entscheiden, genau wie ein verantwortlicher.
|
||||
func TestAdminSiehtPosteingang(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
_, adminCookie := seedFallImAccount(t, fs, s, "admin")
|
||||
|
||||
resp := getWithCookie(t, s, adminCookie, "/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())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSiehtEntscheidenFormular(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
antragID, adminCookie := seedFallImAccount(t, fs, s, "admin")
|
||||
|
||||
resp := getWithCookie(t, s, adminCookie, "/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 admin, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminKannEntscheiden(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
antragID, adminCookie := seedFallImAccount(t, fs, s, "admin")
|
||||
|
||||
resp := postForm(t, s, adminCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"rueckfrage"}})
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSiehtRegisterUndWiedervorlage(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
|
||||
for _, path := range []string{"/registereintraege", "/wiedervorlage"} {
|
||||
resp := getWithCookie(t, s, adminCookie, path)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Errorf("GET %s status = %d, want 200 for admin", path, resp.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
218
internal/web/admin_handlers.go
Normal file
218
internal/web/admin_handlers.go
Normal file
@@ -0,0 +1,218 @@
|
||||
// Ebene 4 (Mandanten-Verwaltung) — Nutzer/Rollen und Abteilungen für
|
||||
// GENAU EINEN Mandanten, siehe CLAUDE.md, Fünf Ebenen. Anmeldeverfahren,
|
||||
// Werkzeug-Freigaben/-Sperrungen und Rechnungsdaten sind weiterhin
|
||||
// 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 {
|
||||
ID string
|
||||
Email string
|
||||
Role string
|
||||
Active bool
|
||||
IstSelbst bool
|
||||
}
|
||||
|
||||
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) {
|
||||
self := currentUser(r)
|
||||
users, err := s.store.ListUsersForAccount(r.Context(), self.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{
|
||||
ID: u.ID, Email: u.Email, Role: u.Role, Active: u.Active, IstSelbst: u.ID == self.ID,
|
||||
})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "nutzer-liste", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleNutzerDeaktivieren sperrt einen Login des eigenen Mandanten —
|
||||
// nicht löschen, siehe store.User (Fremdschlüssel auf antrag/entscheidung/
|
||||
// audit_log würden das verhindern und die Historie zerstören). Ein Admin
|
||||
// kann sich nicht selbst deaktivieren, sonst könnte ein Mandant sich
|
||||
// versehentlich komplett aussperren.
|
||||
func (s *Server) handleNutzerDeaktivieren(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
self := currentUser(r)
|
||||
if id == self.ID {
|
||||
http.Error(w, "Der eigene Zugang kann nicht deaktiviert werden", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
target, err := s.store.GetUser(r.Context(), id)
|
||||
if err != nil || target.AccountID != self.AccountID {
|
||||
http.Error(w, "Nutzer nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := s.store.SetUserActive(r.Context(), id, false); err != nil {
|
||||
http.Error(w, "Nutzer konnte nicht deaktiviert werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/nutzer", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleNutzerAktivieren(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
target, err := s.store.GetUser(r.Context(), id)
|
||||
if err != nil || target.AccountID != currentUser(r).AccountID {
|
||||
http.Error(w, "Nutzer nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := s.store.SetUserActive(r.Context(), id, true); err != nil {
|
||||
http.Error(w, "Nutzer konnte nicht aktiviert werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/nutzer", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
type abteilungListItem struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
type abteilungListeData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Abteilungen []abteilungListItem
|
||||
Error string
|
||||
}
|
||||
|
||||
// handleAbteilungListe zeigt alle Abteilungen des eigenen Mandanten
|
||||
// mit einem Formular zum Anlegen weiterer — bewusst eine Seite statt
|
||||
// Liste+Formular getrennt, da Abteilungen reine Stammdaten ohne
|
||||
// weitere Felder sind (nur `name`).
|
||||
func (s *Server) handleAbteilungListe(w http.ResponseWriter, r *http.Request) {
|
||||
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 := abteilungListeData{Title: "Abteilungen", Nav: navFor(r)}
|
||||
for _, a := range abteilungen {
|
||||
data.Abteilungen = append(data.Abteilungen, abteilungListItem{ID: a.ID, Name: a.Name})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "abteilung-liste", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleAbteilungCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := r.FormValue("name")
|
||||
if name == "" {
|
||||
http.Error(w, "Name ist Pflicht", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, err := s.store.CreateAbteilung(r.Context(), currentUser(r).AccountID, name); err != nil {
|
||||
http.Error(w, "Abteilung konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/abteilungen", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleAbteilungDelete entfernt eine Abteilung des eigenen Mandanten.
|
||||
// Fremde Abteilungen (anderer Mandant) sind über die ID nicht löschbar
|
||||
// — GetAbteilung prüft AccountID gegen den angemeldeten Mandanten,
|
||||
// bevor gelöscht wird.
|
||||
func (s *Server) handleAbteilungDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
abt, err := s.store.GetAbteilung(r.Context(), id)
|
||||
if err != nil || abt.AccountID != currentUser(r).AccountID {
|
||||
http.Error(w, "Abteilung nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteAbteilung(r.Context(), id); err != nil {
|
||||
http.Error(w, "Abteilung konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/abteilungen", 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)
|
||||
}
|
||||
}
|
||||
462
internal/web/antrag_handlers.go
Normal file
462
internal/web/antrag_handlers.go
Normal file
@@ -0,0 +1,462 @@
|
||||
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"},
|
||||
}
|
||||
|
||||
var c2FolgeLabels = map[string]string{
|
||||
"beschaeftigung": "Einstellung, Beförderung, Kündigung",
|
||||
"kreditwuerdigkeit": "Kreditwürdigkeit",
|
||||
"leistungsbewilligung": "Leistungsbewilligung",
|
||||
"bildung": "Zugang zu Bildung",
|
||||
"gesundheit": "medizinische Beurteilung",
|
||||
}
|
||||
|
||||
var c3ArtLabels = map[string]string{
|
||||
"emotionserkennung_arbeitsplatz": "Emotionserkennung am Arbeitsplatz",
|
||||
"social_scoring": "Social Scoring",
|
||||
"biometrische_kategorisierung": "Biometrische Kategorisierung",
|
||||
}
|
||||
|
||||
// antwortZeile ist eine einzelne Frage-Antwort-Zeile für die Anzeige —
|
||||
// siehe antwortenAnzeige.
|
||||
type antwortZeile struct {
|
||||
Frage string
|
||||
Antwort string
|
||||
}
|
||||
|
||||
func antwortLabel(v string) string {
|
||||
switch v {
|
||||
case "ja":
|
||||
return "Ja"
|
||||
case "nein":
|
||||
return "Nein"
|
||||
case "unsicher":
|
||||
return "Unsicher"
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func jaNein(v bool) string {
|
||||
if v {
|
||||
return "Ja"
|
||||
}
|
||||
return "Nein"
|
||||
}
|
||||
|
||||
// antwortenAnzeige baut aus den rohen Fragebogen-Antworten
|
||||
// (antrag.antworten, JSON) eine lesbare Liste — dieselben Fragen-Labels
|
||||
// wie im Fragebogen selbst (antrag_neu.html). Ohne das sieht sowohl der
|
||||
// antragstellende Mitarbeiter als auch die Fachebene beim Entscheiden
|
||||
// nur die daraus abgeleitete Bewertung, nie die tatsächlich gegebenen
|
||||
// Antworten, auf denen sie beruht — im Audit ist das nicht
|
||||
// nachvollziehbar (siehe CLAUDE.md, Grundregel).
|
||||
func antwortenAnzeige(antworten map[string]any) []antwortZeile {
|
||||
var out []antwortZeile
|
||||
for _, f := range datenfragen {
|
||||
v, _ := antworten[f.Key].(string)
|
||||
if v == "" {
|
||||
v = "nein"
|
||||
}
|
||||
out = append(out, antwortZeile{Frage: f.Label, Antwort: antwortLabel(v)})
|
||||
}
|
||||
|
||||
c1, _ := antworten["c1"].(bool)
|
||||
out = append(out, antwortZeile{Frage: "Geht das Ergebnis unverändert nach außen?", Antwort: jaNein(c1)})
|
||||
|
||||
c2, _ := antworten["c2"].(bool)
|
||||
c2Zeile := antwortZeile{Frage: "Beeinflusst es eine Entscheidung über einen Menschen?", Antwort: jaNein(c2)}
|
||||
if c2 {
|
||||
if folge, _ := antworten["c2_folge"].(string); folge != "" {
|
||||
if label, ok := c2FolgeLabels[folge]; ok {
|
||||
c2Zeile.Antwort += " — " + label
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, c2Zeile)
|
||||
|
||||
c3, _ := antworten["c3"].(bool)
|
||||
c3Zeile := antwortZeile{Frage: "Erkennt/bewertet es Emotionen, Verhalten oder biometrische Merkmale?", Antwort: jaNein(c3)}
|
||||
if c3 {
|
||||
if art, _ := antworten["c3_art"].(string); art != "" {
|
||||
if label, ok := c3ArtLabels[art]; ok {
|
||||
c3Zeile.Antwort += " — " + label
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, c3Zeile)
|
||||
|
||||
c4, _ := antworten["c4"].(bool)
|
||||
out = append(out, antwortZeile{Frage: "Läuft es ohne menschliche Prüfung?", Antwort: jaNein(c4)})
|
||||
|
||||
c5, _ := antworten["c5"].(bool)
|
||||
out = append(out, antwortZeile{Frage: "Merkt der Empfänger, dass es von einer KI stammt?", Antwort: jaNein(c5)})
|
||||
|
||||
if werkzeug, _ := antworten["d_werkzeug_freitext"].(string); werkzeug != "" {
|
||||
out = append(out, antwortZeile{Frage: "Gewünschtes Werkzeug", Antwort: werkzeug})
|
||||
}
|
||||
if zugang, _ := antworten["d_zugang"].(string); zugang != "" {
|
||||
label := "Privater Zugang"
|
||||
if zugang == "firma" {
|
||||
label = "Firmenkonto"
|
||||
}
|
||||
out = append(out, antwortZeile{Frage: "Zugang", Antwort: label})
|
||||
}
|
||||
if geraet, _ := antworten["d_geraet"].(string); geraet != "" {
|
||||
label := "Privatgerät"
|
||||
if geraet == "firma" {
|
||||
label = "Firmengerät"
|
||||
}
|
||||
out = append(out, antwortZeile{Frage: "Gerät", Antwort: label})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
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 {
|
||||
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
|
||||
Titel string
|
||||
Beschreibung string
|
||||
Ergebnis string
|
||||
Haeufigkeit string
|
||||
Status string
|
||||
CreatedAt string
|
||||
Antworten []antwortZeile
|
||||
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), 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: antwortenAnzeige(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{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
|
||||
}
|
||||
262
internal/web/antrag_handlers_test.go
Normal file
262
internal/web/antrag_handlers_test.go
Normal file
@@ -0,0 +1,262 @@
|
||||
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())
|
||||
}
|
||||
body := detailResp.Body.String()
|
||||
if !strings.Contains(body, "Angebotstexte generieren") {
|
||||
t.Errorf("expected the antrag title on the detail page, got: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "Namen, E-Mail-Adressen oder andere Angaben zu Personen") {
|
||||
t.Errorf("expected the b1 Fragebogen-Frage on the detail page, got: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "Merkt der Empfänger, dass es von einer KI stammt?") {
|
||||
t.Errorf("expected the c5 Fragebogen-Frage on the detail page, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
type submissionListItem struct {
|
||||
ID string
|
||||
Platform string
|
||||
PostType string
|
||||
Status string
|
||||
CreatedAt string
|
||||
FindingCount int
|
||||
HighestSeverity string
|
||||
}
|
||||
|
||||
type submissionListData struct {
|
||||
Title string
|
||||
Submissions []submissionListItem
|
||||
}
|
||||
|
||||
// handleSubmissionList zeigt die Archiv-Übersicht: alle Beiträge des
|
||||
// angemeldeten Mandanten mit Kurzfassung der Findings.
|
||||
func (s *Server) handleSubmissionList(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
summaries, err := s.store.ListSubmissionsForAccount(ctx, currentUser(r).AccountID)
|
||||
if err != nil {
|
||||
http.Error(w, "Beiträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := submissionListData{Title: "Beiträge"}
|
||||
for _, sum := range summaries {
|
||||
data.Submissions = append(data.Submissions, submissionListItem{
|
||||
ID: sum.ID, Platform: sum.Platform, PostType: sum.PostType, Status: sum.Status,
|
||||
CreatedAt: sum.CreatedAt.Format("02.01.2006 15:04"),
|
||||
FindingCount: sum.FindingCount, HighestSeverity: sum.HighestSeverity,
|
||||
})
|
||||
}
|
||||
|
||||
if err := s.templates.ExecuteTemplate(w, "beitraege", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type participantView struct {
|
||||
ID string
|
||||
Role string
|
||||
Name string
|
||||
Vorgegeben bool
|
||||
Freigegeben bool
|
||||
ApprovedAt string // leer, wenn noch nicht freigegeben
|
||||
}
|
||||
|
||||
type submissionDetailData struct {
|
||||
Title string
|
||||
SubmissionID string
|
||||
Platform string
|
||||
PostType string
|
||||
Caption string
|
||||
Status string
|
||||
CreatedAt string
|
||||
CanArchive bool
|
||||
IsPublished bool
|
||||
DossierURL string
|
||||
Findings []findingView
|
||||
Participants []participantView
|
||||
}
|
||||
|
||||
// loadOwnSubmission lädt eine Submission und prüft die Mandantenzugehörigkeit.
|
||||
// Wie in handleArchive/handleDossierDownload (siehe handlers.go): ein Beitrag
|
||||
// eines anderen Accounts wird wie ein nicht existierender behandelt.
|
||||
func (s *Server) loadOwnSubmission(r *http.Request, id string) (store.Submission, error) {
|
||||
sub, err := s.store.GetSubmission(r.Context(), id)
|
||||
if err != nil {
|
||||
return store.Submission{}, err
|
||||
}
|
||||
if sub.AccountID != currentUser(r).AccountID {
|
||||
return store.Submission{}, store.ErrNotFound
|
||||
}
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
func toParticipantViews(participants []store.Participant) []participantView {
|
||||
views := make([]participantView, len(participants))
|
||||
for i, p := range participants {
|
||||
v := participantView{ID: p.ID, Role: p.Role, Name: p.Name, Vorgegeben: p.Vorgegeben, Freigegeben: p.Freigegeben}
|
||||
if p.ApprovedAt != nil {
|
||||
v.ApprovedAt = p.ApprovedAt.Format("02.01.2006 15:04")
|
||||
}
|
||||
views[i] = v
|
||||
}
|
||||
return views
|
||||
}
|
||||
|
||||
// handleSubmissionDetail zeigt einen einzelnen Beitrag: Fakten, Findings,
|
||||
// Verantwortungsmatrix (Beteiligte) inklusive Verwaltung.
|
||||
func (s *Server) handleSubmissionDetail(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
sub, err := s.loadOwnSubmission(r, r.PathValue("id"))
|
||||
if err != nil {
|
||||
http.Error(w, "Beitrag nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
storeFindings, err := s.store.ListCurrentFindings(ctx, sub.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "Findings konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
findings := make([]findingView, len(storeFindings))
|
||||
for i, f := range storeFindings {
|
||||
findings[i] = findingView{
|
||||
RuleID: f.RuleID, Version: f.RuleVersion, Severity: f.Severity,
|
||||
Title: f.Title, Fix: f.Fix, Sources: f.Sources,
|
||||
}
|
||||
}
|
||||
|
||||
participants, err := s.store.ListParticipants(ctx, sub.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "Beteiligte konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := submissionDetailData{
|
||||
Title: "Beitrag", SubmissionID: sub.ID, Platform: sub.Platform, PostType: sub.PostType,
|
||||
Caption: sub.Caption, Status: sub.Status, CreatedAt: sub.CreatedAt.Format("02.01.2006 15:04"),
|
||||
CanArchive: sub.Status == "checked", IsPublished: sub.Status == "published",
|
||||
DossierURL: "/dossier/" + sub.ID, Findings: findings, Participants: toParticipantViews(participants),
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "beitrag", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type participantListData struct {
|
||||
SubmissionID string
|
||||
Participants []participantView
|
||||
}
|
||||
|
||||
// renderParticipantList rendert das Beteiligten-Fragment neu — Ziel für
|
||||
// htmx-Swaps nach Hinzufügen/Ändern/Löschen, damit die Seite nicht neu
|
||||
// geladen werden muss.
|
||||
func (s *Server) renderParticipantList(w http.ResponseWriter, r *http.Request, submissionID string) {
|
||||
participants, err := s.store.ListParticipants(r.Context(), submissionID)
|
||||
if err != nil {
|
||||
http.Error(w, "Beteiligte konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := participantListData{SubmissionID: submissionID, Participants: toParticipantViews(participants)}
|
||||
if err := s.templates.ExecuteTemplate(w, "beteiligte-liste", data); err != nil {
|
||||
http.Error(w, "Liste konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleAddParticipant fügt einen Beteiligten zu einem Beitrag hinzu.
|
||||
func (s *Server) handleAddParticipant(w http.ResponseWriter, r *http.Request) {
|
||||
sub, err := s.loadOwnSubmission(r, r.PathValue("id"))
|
||||
if err != nil {
|
||||
http.Error(w, "Beitrag nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
role := r.FormValue("role")
|
||||
name := r.FormValue("name")
|
||||
if role == "" || name == "" {
|
||||
http.Error(w, "Rolle und Name sind Pflichtfelder", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := s.store.CreateParticipant(r.Context(), sub.ID, role, name, false, false); err != nil {
|
||||
http.Error(w, "Beteiligter konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.renderParticipantList(w, r, sub.ID)
|
||||
}
|
||||
|
||||
// participantBelongsToOwnSubmission prüft, dass der Beteiligte tatsächlich
|
||||
// zu einem Beitrag des angemeldeten Mandanten gehört — sonst könnte ein
|
||||
// fremder Mandant über eine erratene participant_id einen Beteiligten
|
||||
// eines anderen Accounts ändern oder löschen.
|
||||
func (s *Server) participantBelongsToOwnSubmission(r *http.Request, submissionIDFromURL, participantID string) (store.Participant, error) {
|
||||
p, err := s.store.GetParticipant(r.Context(), participantID)
|
||||
if err != nil {
|
||||
return store.Participant{}, err
|
||||
}
|
||||
if p.SubmissionID != submissionIDFromURL {
|
||||
return store.Participant{}, store.ErrNotFound
|
||||
}
|
||||
if _, err := s.loadOwnSubmission(r, p.SubmissionID); err != nil {
|
||||
return store.Participant{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// handleUpdateParticipant setzt vorgegeben/freigegeben für einen Beteiligten.
|
||||
func (s *Server) handleUpdateParticipant(w http.ResponseWriter, r *http.Request) {
|
||||
submissionID := r.PathValue("id")
|
||||
p, err := s.participantBelongsToOwnSubmission(r, submissionID, r.PathValue("pid"))
|
||||
if err != nil {
|
||||
http.Error(w, "Beteiligter nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
vorgegeben := r.FormValue("vorgegeben") == "on"
|
||||
freigegeben := r.FormValue("freigegeben") == "on"
|
||||
|
||||
if _, err := s.store.UpdateParticipant(r.Context(), p.ID, vorgegeben, freigegeben); err != nil {
|
||||
http.Error(w, "Beteiligter konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.renderParticipantList(w, r, submissionID)
|
||||
}
|
||||
|
||||
// handleDeleteParticipant entfernt einen Beteiligten.
|
||||
func (s *Server) handleDeleteParticipant(w http.ResponseWriter, r *http.Request) {
|
||||
submissionID := r.PathValue("id")
|
||||
p, err := s.participantBelongsToOwnSubmission(r, submissionID, r.PathValue("pid"))
|
||||
if err != nil {
|
||||
http.Error(w, "Beteiligter nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.DeleteParticipant(r.Context(), p.ID); err != nil {
|
||||
http.Error(w, "Beteiligter konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.renderParticipantList(w, r, submissionID)
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
)
|
||||
|
||||
// checkAndReturnSubID führt eine Pre-Publish-Prüfung durch und liefert die
|
||||
// dabei angelegte submission_id — Hilfsfunktion für Tests, die einen
|
||||
// bereits existierenden Beitrag brauchen, ohne den ganzen Ablauf jedes Mal
|
||||
// auszuschreiben.
|
||||
func checkAndReturnSubID(t *testing.T, s interface {
|
||||
ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
}, cookie *http.Cookie) string {
|
||||
t.Helper()
|
||||
form := checkForm()
|
||||
req := httptest.NewRequest(http.MethodPost, "/pruefen", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
w := httptest.NewRecorder()
|
||||
s.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("check status = %d, body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
body := w.Body.String()
|
||||
const marker = `name="submission_id" value="`
|
||||
idx := strings.Index(body, marker)
|
||||
if idx == -1 {
|
||||
t.Fatalf("expected a submission_id field in the result, got: %s", body)
|
||||
}
|
||||
rest := body[idx+len(marker):]
|
||||
return rest[:strings.Index(rest, `"`)]
|
||||
}
|
||||
|
||||
func TestSubmissionListShowsOwnSubmissionsOnly(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fakeExtractor{facts: rules.Facts{
|
||||
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
|
||||
}}, fs)
|
||||
|
||||
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
|
||||
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
|
||||
|
||||
subA := checkAndReturnSubID(t, s, cookieA)
|
||||
_ = checkAndReturnSubID(t, s, cookieB)
|
||||
|
||||
resp := getWithCookie(t, s, cookieA, "/beitraege")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
body := resp.Body.String()
|
||||
if !strings.Contains(body, "/beitraege/"+subA) {
|
||||
t.Errorf("expected Mandant A's own submission link, got: %s", body)
|
||||
}
|
||||
|
||||
// Mandant B hat genau einen eigenen Beitrag, keinen von A.
|
||||
respB := getWithCookie(t, s, cookieB, "/beitraege")
|
||||
if strings.Contains(respB.Body.String(), "/beitraege/"+subA) {
|
||||
t.Errorf("Mandant B should not see Mandant A's submission, got: %s", respB.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmissionListRequiresSession(t *testing.T) {
|
||||
s, _, _ := newAuthedTestServer(t, fakeExtractor{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/beitraege", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303 redirect to /login", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmissionDetailShowsFactsAndFindings(t *testing.T) {
|
||||
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
|
||||
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationPaid,
|
||||
DisclosurePresent: true, DisclosureWording: "Werbung", DisclosureBeforeCut: false,
|
||||
}})
|
||||
subID := checkAndReturnSubID(t, s, cookie)
|
||||
_ = fs
|
||||
|
||||
resp := getWithCookie(t, s, cookie, "/beitraege/"+subID)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
body := resp.Body.String()
|
||||
if !strings.Contains(body, "WK-004") {
|
||||
t.Errorf("expected the finding to be shown, got: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "/veroeffentlichen") {
|
||||
t.Errorf("expected an archive option for a checked submission, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmissionDetailTenantIsolation(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fakeExtractor{facts: rules.Facts{
|
||||
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
|
||||
}}, fs)
|
||||
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
|
||||
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
|
||||
subA := checkAndReturnSubID(t, s, cookieA)
|
||||
|
||||
resp := getWithCookie(t, s, cookieB, "/beitraege/"+subA)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("cross-tenant detail status = %d, want 404", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParticipantAddUpdateDeleteFlow(t *testing.T) {
|
||||
s, _, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
|
||||
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
|
||||
}})
|
||||
subID := checkAndReturnSubID(t, s, cookie)
|
||||
|
||||
addResp := postForm(t, s, cookie, "/beitraege/"+subID+"/beteiligte", url.Values{
|
||||
"role": {"creator"}, "name": {"Max Mustermann"},
|
||||
})
|
||||
if addResp.Code != http.StatusOK {
|
||||
t.Fatalf("add participant status = %d, body: %s", addResp.Code, addResp.Body.String())
|
||||
}
|
||||
if !strings.Contains(addResp.Body.String(), "Max Mustermann") {
|
||||
t.Fatalf("expected the new participant in the response, got: %s", addResp.Body.String())
|
||||
}
|
||||
|
||||
detailResp := getWithCookie(t, s, cookie, "/beitraege/"+subID)
|
||||
if !strings.Contains(detailResp.Body.String(), "Max Mustermann") {
|
||||
t.Fatalf("expected the participant on the detail page, got: %s", detailResp.Body.String())
|
||||
}
|
||||
|
||||
// Beteiligten-ID aus dem Update-Formular extrahieren.
|
||||
body := addResp.Body.String()
|
||||
const marker = "/beteiligte/"
|
||||
idx := strings.Index(body, marker)
|
||||
if idx == -1 {
|
||||
t.Fatalf("expected a participant action URL, got: %s", body)
|
||||
}
|
||||
rest := body[idx+len(marker):]
|
||||
pID := rest[:strings.Index(rest, "/")]
|
||||
|
||||
updateResp := postForm(t, s, cookie, "/beitraege/"+subID+"/beteiligte/"+pID+"/aktualisieren", url.Values{
|
||||
"vorgegeben": {"on"}, "freigegeben": {"on"},
|
||||
})
|
||||
if updateResp.Code != http.StatusOK {
|
||||
t.Fatalf("update participant status = %d, body: %s", updateResp.Code, updateResp.Body.String())
|
||||
}
|
||||
if !strings.Contains(updateResp.Body.String(), "freigegeben am") {
|
||||
t.Fatalf("expected an approval timestamp after freigegeben=true, got: %s", updateResp.Body.String())
|
||||
}
|
||||
|
||||
deleteResp := postForm(t, s, cookie, "/beitraege/"+subID+"/beteiligte/"+pID+"/loeschen", url.Values{})
|
||||
if deleteResp.Code != http.StatusOK {
|
||||
t.Fatalf("delete participant status = %d, body: %s", deleteResp.Code, deleteResp.Body.String())
|
||||
}
|
||||
if strings.Contains(deleteResp.Body.String(), "Max Mustermann") {
|
||||
t.Fatalf("expected the participant to be gone after delete, got: %s", deleteResp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParticipantActionsRejectCrossTenantAccess(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fakeExtractor{facts: rules.Facts{
|
||||
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
|
||||
}}, fs)
|
||||
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
|
||||
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
|
||||
subA := checkAndReturnSubID(t, s, cookieA)
|
||||
|
||||
// Mandant B darf für As Beitrag gar keinen Beteiligten anlegen.
|
||||
addResp := postForm(t, s, cookieB, "/beitraege/"+subA+"/beteiligte", url.Values{
|
||||
"role": {"creator"}, "name": {"Fremd"},
|
||||
})
|
||||
if addResp.Code != http.StatusNotFound {
|
||||
t.Fatalf("cross-tenant add status = %d, want 404", addResp.Code)
|
||||
}
|
||||
|
||||
p, err := fs.CreateParticipant(context.Background(), subA, "creator", "Eigener Beteiligter", false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateParticipant: %v", err)
|
||||
}
|
||||
|
||||
updateResp := postForm(t, s, cookieB, "/beitraege/"+subA+"/beteiligte/"+p.ID+"/aktualisieren", url.Values{
|
||||
"vorgegeben": {"on"},
|
||||
})
|
||||
if updateResp.Code != http.StatusNotFound {
|
||||
t.Fatalf("cross-tenant update status = %d, want 404", updateResp.Code)
|
||||
}
|
||||
|
||||
deleteResp := postForm(t, s, cookieB, "/beitraege/"+subA+"/beteiligte/"+p.ID+"/loeschen", url.Values{})
|
||||
if deleteResp.Code != http.StatusNotFound {
|
||||
t.Fatalf("cross-tenant delete status = %d, want 404", deleteResp.Code)
|
||||
}
|
||||
|
||||
if _, err := fs.GetParticipant(context.Background(), p.ID); err != nil {
|
||||
t.Fatalf("participant should still exist after rejected cross-tenant delete: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -26,10 +26,14 @@ func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden"})
|
||||
}
|
||||
|
||||
// handleRegister legt einen neuen Mandanten (Account) und den ersten
|
||||
// Nutzer darin an. Es gibt bewusst keinen separaten "Account beitreten"-
|
||||
// Flow — das wäre Schritt 4 (Agentur-/Markensicht mit mehreren Nutzern
|
||||
// pro Account), hier reicht ein Nutzer pro neuem Account.
|
||||
// handleRegister ist die "Firma"-Registrierung (Ebene 1 der Frontend-
|
||||
// Spezifikation): legt einen neuen Mandanten (Account) und den ersten
|
||||
// Nutzer als admin an. Testzugang ist sofort aktiv, der Bezahlbetrieb
|
||||
// wird separat vom Betreiber freigeschaltet (nicht Teil dieses Flows).
|
||||
// Es gibt bewusst KEINE offene Selbstregistrierung für einzelne
|
||||
// Mitarbeiter über dieses Formular — die kommen über den
|
||||
// Einladungslink (siehe einladung_handlers.go) oder künftig CSV-
|
||||
// Import/Mandantenlink (weiterhin nicht gebaut).
|
||||
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "ungültiges Formular"})
|
||||
@@ -39,8 +43,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
accountName := r.FormValue("account_name")
|
||||
email := r.FormValue("email")
|
||||
password := r.FormValue("password")
|
||||
role := r.FormValue("role")
|
||||
if accountName == "" || email == "" || password == "" || role == "" {
|
||||
if accountName == "" || email == "" || password == "" {
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Alle Felder sind Pflicht"})
|
||||
return
|
||||
}
|
||||
@@ -57,7 +60,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Konto konnte nicht angelegt werden"})
|
||||
return
|
||||
}
|
||||
user, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, role)
|
||||
user, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, "admin")
|
||||
if err != nil {
|
||||
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"})
|
||||
return
|
||||
@@ -91,11 +94,25 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "E-Mail oder Passwort falsch"})
|
||||
return
|
||||
}
|
||||
// Erst NACH erfolgreicher Passwortprüfung unterscheiden — sonst würde
|
||||
// diese Meldung einem Angreifer ohne gültiges Passwort verraten, dass
|
||||
// die E-Mail-Adresse zu einem (deaktivierten) Konto gehört.
|
||||
if !user.Active {
|
||||
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "Dieses Konto ist deaktiviert"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.startSession(w, r, user.ID); err != nil {
|
||||
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "Sitzung konnte nicht gestartet werden"})
|
||||
return
|
||||
}
|
||||
// Ein Betreiber-Login hat keinen eigenen Antrag zu stellen — der
|
||||
// Plattform-Bereich ist der sinnvolle Einstieg (Ebene 5, technisch
|
||||
// getrennt vom Mandantenbereich).
|
||||
if user.Role == "betreiber" {
|
||||
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
||||
145
internal/web/betreiber_handlers.go
Normal file
145
internal/web/betreiber_handlers.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type betreiberDashboardData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
AccountCount int
|
||||
WerkzeugCount int
|
||||
RecentAuditCount int
|
||||
}
|
||||
|
||||
// handleBetreiberDashboard zeigt eine kurze Übersicht als Einstieg in
|
||||
// den Plattform-Bereich (Ebene 5 — nur Betreiber, technisch getrennt
|
||||
// vom Mandantenbereich).
|
||||
func (s *Server) handleBetreiberDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
accounts, err := s.store.ListAccounts(ctx)
|
||||
if err != nil {
|
||||
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
werkzeuge, err := s.store.ListZentraleWerkzeuge(ctx)
|
||||
if err != nil {
|
||||
http.Error(w, "Werkzeugkatalog konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
auditLog, err := s.store.ListAuditLog(ctx, 5)
|
||||
if err != nil {
|
||||
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := betreiberDashboardData{
|
||||
Title: "Plattform", Nav: navFor(r), AccountCount: len(accounts),
|
||||
WerkzeugCount: len(werkzeuge), RecentAuditCount: len(auditLog),
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-dashboard", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type betreiberAccountListItem struct {
|
||||
ID string
|
||||
Name string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
type betreiberAccountListData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Accounts []betreiberAccountListItem
|
||||
}
|
||||
|
||||
// handleBetreiberAccountList listet alle Mandanten der Plattform.
|
||||
func (s *Server) handleBetreiberAccountList(w http.ResponseWriter, r *http.Request) {
|
||||
accounts, err := s.store.ListAccounts(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := betreiberAccountListData{Title: "Accounts", Nav: navFor(r)}
|
||||
for _, a := range accounts {
|
||||
data.Accounts = append(data.Accounts, betreiberAccountListItem{
|
||||
ID: a.ID, Name: a.Name, CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
||||
})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-accounts", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type betreiberUserView struct {
|
||||
Email string
|
||||
Role string
|
||||
}
|
||||
|
||||
type betreiberAccountDetailData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Name string
|
||||
Users []betreiberUserView
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// handleBetreiberAccountDetail zeigt einen Mandanten mit seinen Logins.
|
||||
func (s *Server) handleBetreiberAccountDetail(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
acc, err := s.store.GetAccount(ctx, r.PathValue("id"))
|
||||
if err != nil {
|
||||
http.Error(w, "Account nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
users, err := s.store.ListUsersForAccount(ctx, acc.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := betreiberAccountDetailData{
|
||||
Title: "Account", Nav: navFor(r), Name: acc.Name,
|
||||
CreatedAt: acc.CreatedAt.Format("02.01.2006 15:04"),
|
||||
}
|
||||
for _, u := range users {
|
||||
data.Users = append(data.Users, betreiberUserView{Email: u.Email, Role: u.Role})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-account-detail", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type betreiberAuditEntryView struct {
|
||||
CreatedAt string
|
||||
Action string
|
||||
TargetType string
|
||||
TargetID string
|
||||
Details string
|
||||
}
|
||||
|
||||
type betreiberAuditLogData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Entries []betreiberAuditEntryView
|
||||
}
|
||||
|
||||
// handleBetreiberAuditLog zeigt das plattformweite Audit-Log.
|
||||
func (s *Server) handleBetreiberAuditLog(w http.ResponseWriter, r *http.Request) {
|
||||
entries, err := s.store.ListAuditLog(r.Context(), 200)
|
||||
if err != nil {
|
||||
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := betreiberAuditLogData{Title: "Audit-Log", Nav: navFor(r)}
|
||||
for _, e := range entries {
|
||||
data.Entries = append(data.Entries, betreiberAuditEntryView{
|
||||
CreatedAt: e.CreatedAt.Format("02.01.2006 15:04:05"), Action: e.Action,
|
||||
TargetType: e.TargetType, TargetID: e.TargetID, Details: e.Details,
|
||||
})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-audit-log", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
255
internal/web/betreiber_werkzeug_handlers.go
Normal file
255
internal/web/betreiber_werkzeug_handlers.go
Normal file
@@ -0,0 +1,255 @@
|
||||
// Ebene 5 (Plattform) — Pflege des zentralen Werkzeugkatalogs
|
||||
// (`werkzeug.account_id IS NULL`). Mandantenspezifische Ergänzungen
|
||||
// (`account_id` gesetzt) sind NICHT Teil dieser Seiten — die bleiben
|
||||
// Sache des jeweiligen Mandanten (Ebene 4, "eigene Werkzeug-
|
||||
// Freigaben/-Sperrungen", noch nicht gebaut), siehe CLAUDE.md.
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
var gueltigeVerarbeitungsorte = []string{"EU", "USA", "gemischt", "on-prem"}
|
||||
|
||||
func istGueltigerVerarbeitungsort(v string) bool {
|
||||
for _, o := range gueltigeVerarbeitungsorte {
|
||||
if o == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// splitListe zerlegt ein kommasepariertes Formularfeld (Zertifizierungen,
|
||||
// geeignete Zwecke, Einschränkungen) in eine bereinigte Liste — ohne
|
||||
// führende/folgende Leerzeichen und ohne leere Einträge.
|
||||
func splitListe(s string) []string {
|
||||
var out []string
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type werkzeugListItem struct {
|
||||
ID string
|
||||
Name string
|
||||
Anbieter string
|
||||
Verarbeitungsort string
|
||||
LetztePruefung string
|
||||
}
|
||||
|
||||
type werkzeugListeData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Werkzeuge []werkzeugListItem
|
||||
}
|
||||
|
||||
// handleBetreiberWerkzeugListe zeigt den zentralen Katalog.
|
||||
func (s *Server) handleBetreiberWerkzeugListe(w http.ResponseWriter, r *http.Request) {
|
||||
werkzeuge, err := s.store.ListZentraleWerkzeuge(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "Werkzeugkatalog konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := werkzeugListeData{Title: "Werkzeugkatalog", Nav: navFor(r)}
|
||||
for _, wz := range werkzeuge {
|
||||
data.Werkzeuge = append(data.Werkzeuge, werkzeugListItem{
|
||||
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
|
||||
LetztePruefung: wz.LetztePruefung.Format("02.01.2006"),
|
||||
})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeuge", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// werkzeugFormData bedient sowohl das Betreiber-Formular (zentraler
|
||||
// Katalog, ActionBase "/betreiber/werkzeuge") als auch das Mandanten-
|
||||
// Formular für eigene Katalogeinträge (ActionBase "/verwaltung/werkzeuge")
|
||||
// — dieselbe Template-Datei, siehe betreiber_werkzeug_form.html.
|
||||
type werkzeugFormData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
ActionBase string
|
||||
ID string // leer bei "neu"
|
||||
Name string
|
||||
Anbieter string
|
||||
Verarbeitungsorte []string
|
||||
Verarbeitungsort string
|
||||
AVVVerfuegbar bool
|
||||
AVVURL string
|
||||
TrainingOptOut bool
|
||||
TrainingStandard bool
|
||||
AufbewahrungTage int
|
||||
Zertifizierungen string
|
||||
GeeigneteZwecke string
|
||||
Einschraenkungen string
|
||||
LetztePruefung string
|
||||
Quelle string
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *Server) handleBetreiberWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
|
||||
data := werkzeugFormData{Title: "Werkzeug anlegen", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge", Verarbeitungsorte: gueltigeVerarbeitungsorte}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// werkzeugInputFromForm liest ein WerkzeugInput aus dem Formular.
|
||||
// accountID ist nil für den zentralen Katalog (Betreiber) oder gesetzt
|
||||
// für einen mandantenspezifischen Eintrag (Ebene 4).
|
||||
func werkzeugInputFromForm(r *http.Request, accountID *string) (store.WerkzeugInput, string) {
|
||||
letztePruefung, err := time.Parse("2006-01-02", r.FormValue("letzte_pruefung"))
|
||||
if err != nil {
|
||||
return store.WerkzeugInput{}, "Datum der letzten Prüfung ist ungültig oder fehlt"
|
||||
}
|
||||
verarbeitungsort := r.FormValue("verarbeitungsort")
|
||||
if !istGueltigerVerarbeitungsort(verarbeitungsort) {
|
||||
return store.WerkzeugInput{}, "ungültiger Verarbeitungsort"
|
||||
}
|
||||
name := r.FormValue("name")
|
||||
anbieter := r.FormValue("anbieter")
|
||||
quelle := r.FormValue("quelle")
|
||||
if name == "" || anbieter == "" || quelle == "" {
|
||||
return store.WerkzeugInput{}, "Name, Anbieter und Quelle sind Pflicht"
|
||||
}
|
||||
aufbewahrungTage, _ := strconv.Atoi(r.FormValue("aufbewahrung_tage"))
|
||||
|
||||
return store.WerkzeugInput{
|
||||
AccountID: accountID,
|
||||
Name: name,
|
||||
Anbieter: anbieter,
|
||||
Verarbeitungsort: verarbeitungsort,
|
||||
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on",
|
||||
AVVURL: r.FormValue("avv_url"),
|
||||
TrainingOptOut: r.FormValue("training_opt_out") == "on",
|
||||
TrainingStandard: r.FormValue("training_standard") == "on",
|
||||
AufbewahrungTage: aufbewahrungTage,
|
||||
Zertifizierungen: splitListe(r.FormValue("zertifizierungen")),
|
||||
GeeigneteZwecke: splitListe(r.FormValue("geeignete_zwecke")),
|
||||
Einschraenkungen: splitListe(r.FormValue("einschraenkungen")),
|
||||
LetztePruefung: letztePruefung,
|
||||
Quelle: quelle,
|
||||
}, ""
|
||||
}
|
||||
|
||||
// formDataFromForm baut die Formular-Daten für eine erneute Anzeige bei
|
||||
// einem Validierungsfehler — die Eingaben des Nutzers bleiben erhalten,
|
||||
// statt das Formular zu leeren.
|
||||
func werkzeugFormDataFromRequest(r *http.Request, title, actionBase, id, errMsg string) werkzeugFormData {
|
||||
return werkzeugFormData{
|
||||
Title: title, Nav: navFor(r), ActionBase: actionBase, ID: id, Verarbeitungsorte: gueltigeVerarbeitungsorte,
|
||||
Name: r.FormValue("name"), Anbieter: r.FormValue("anbieter"), Verarbeitungsort: r.FormValue("verarbeitungsort"),
|
||||
AVVVerfuegbar: r.FormValue("avv_verfuegbar") == "on", AVVURL: r.FormValue("avv_url"),
|
||||
TrainingOptOut: r.FormValue("training_opt_out") == "on", TrainingStandard: r.FormValue("training_standard") == "on",
|
||||
Zertifizierungen: r.FormValue("zertifizierungen"), GeeigneteZwecke: r.FormValue("geeignete_zwecke"),
|
||||
Einschraenkungen: r.FormValue("einschraenkungen"), LetztePruefung: r.FormValue("letzte_pruefung"),
|
||||
Quelle: r.FormValue("quelle"), Error: errMsg,
|
||||
}
|
||||
}
|
||||
|
||||
// handleBetreiberWerkzeugCreate legt einen zentralen Katalogeintrag an.
|
||||
func (s *Server) handleBetreiberWerkzeugCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
in, errMsg := werkzeugInputFromForm(r, nil)
|
||||
if errMsg != "" {
|
||||
data := werkzeugFormDataFromRequest(r, "Werkzeug anlegen", "/betreiber/werkzeuge", "", errMsg)
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := s.store.CreateWerkzeug(r.Context(), in); err != nil {
|
||||
data := werkzeugFormDataFromRequest(r, "Werkzeug anlegen", "/betreiber/werkzeuge", "", "Werkzeug konnte nicht angelegt werden: "+err.Error())
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/betreiber/werkzeuge", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleBetreiberWerkzeugEditForm zeigt einen zentralen Katalogeintrag
|
||||
// zum Bearbeiten. Ein mandantenspezifischer Eintrag (account_id
|
||||
// gesetzt) ist hier bewusst nicht erreichbar — das ist Sache des
|
||||
// jeweiligen Mandanten, nicht der Plattform.
|
||||
func (s *Server) handleBetreiberWerkzeugEditForm(w http.ResponseWriter, r *http.Request) {
|
||||
wz, err := s.store.GetWerkzeug(r.Context(), r.PathValue("id"))
|
||||
if err != nil || wz.AccountID != nil {
|
||||
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
data := werkzeugFormData{
|
||||
Title: "Werkzeug bearbeiten", Nav: navFor(r), ActionBase: "/betreiber/werkzeuge", ID: wz.ID, Verarbeitungsorte: gueltigeVerarbeitungsorte,
|
||||
Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
|
||||
AVVVerfuegbar: wz.AVVVerfuegbar, AVVURL: wz.AVVURL,
|
||||
TrainingOptOut: wz.TrainingOptOut, TrainingStandard: wz.TrainingStandard, AufbewahrungTage: wz.AufbewahrungTage,
|
||||
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
|
||||
Einschraenkungen: strings.Join(wz.Einschraenkungen, ", "), LetztePruefung: wz.LetztePruefung.Format("2006-01-02"),
|
||||
Quelle: wz.Quelle,
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleBetreiberWerkzeugUpdate speichert Änderungen an einem zentralen
|
||||
// Katalogeintrag. Werkzeug ist nicht append-only (siehe store.UpdateWerkzeug)
|
||||
// — eine bereits getroffene Entscheidung hält ihren eigenen, zum
|
||||
// Entscheidungszeitpunkt eingefrorenen Snapshot (entscheidung.werkzeug_snapshot).
|
||||
func (s *Server) handleBetreiberWerkzeugUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
wz, err := s.store.GetWerkzeug(r.Context(), id)
|
||||
if err != nil || wz.AccountID != nil {
|
||||
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
in, errMsg := werkzeugInputFromForm(r, nil)
|
||||
if errMsg != "" {
|
||||
data := werkzeugFormDataFromRequest(r, "Werkzeug bearbeiten", "/betreiber/werkzeuge", id, errMsg)
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := s.store.UpdateWerkzeug(r.Context(), id, in); err != nil {
|
||||
data := werkzeugFormDataFromRequest(r, "Werkzeug bearbeiten", "/betreiber/werkzeuge", id, "Werkzeug konnte nicht gespeichert werden: "+err.Error())
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/betreiber/werkzeuge", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleBetreiberWerkzeugDelete entfernt einen zentralen Katalogeintrag.
|
||||
func (s *Server) handleBetreiberWerkzeugDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
wz, err := s.store.GetWerkzeug(r.Context(), id)
|
||||
if err != nil || wz.AccountID != nil {
|
||||
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteWerkzeug(r.Context(), id); err != nil {
|
||||
http.Error(w, "Werkzeug konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/betreiber/werkzeuge", http.StatusSeeOther)
|
||||
}
|
||||
118
internal/web/betreiber_werkzeug_handlers_test.go
Normal file
118
internal/web/betreiber_werkzeug_handlers_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func werkzeugForm() url.Values {
|
||||
return url.Values{
|
||||
"name": {"ChatGPT Enterprise"}, "anbieter": {"OpenAI"}, "verarbeitungsort": {"EU"},
|
||||
"avv_verfuegbar": {"on"}, "avv_url": {"https://example.com/avv"},
|
||||
"training_opt_out": {"on"}, "training_standard": {"on"}, "aufbewahrung_tage": {"30"},
|
||||
"zertifizierungen": {"ISO 27001, SOC 2"}, "geeignete_zwecke": {"Textgenerierung"},
|
||||
"einschraenkungen": {""}, "letzte_pruefung": {"2026-08-01"}, "quelle": {"https://example.com/beleg"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetreiberCanCreateZentralesWerkzeug(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
|
||||
resp := postForm(t, s, betreiberCookie, "/betreiber/werkzeuge", werkzeugForm())
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var w store.Werkzeug
|
||||
for _, wz := range fs.werkzeuge {
|
||||
w = wz
|
||||
}
|
||||
if w.Name != "ChatGPT Enterprise" || w.AccountID != nil {
|
||||
t.Fatalf("Werkzeug = %+v, unerwartete Werte", w)
|
||||
}
|
||||
if len(w.Zertifizierungen) != 2 {
|
||||
t.Errorf("Zertifizierungen = %v, want 2 Einträge", w.Zertifizierungen)
|
||||
}
|
||||
|
||||
list := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge")
|
||||
if !strings.Contains(list.Body.String(), "ChatGPT Enterprise") {
|
||||
t.Errorf("expected the new werkzeug in the Katalogliste, got: %s", list.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetreiberWerkzeugCreateRequiresLetztePruefungUndQuelle(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
|
||||
form := werkzeugForm()
|
||||
form.Set("letzte_pruefung", "")
|
||||
resp := postForm(t, s, betreiberCookie, "/betreiber/werkzeuge", form)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (Formular mit Fehler) ohne letzte_pruefung", resp.Code)
|
||||
}
|
||||
if len(fs.werkzeuge) != 0 {
|
||||
t.Errorf("expected no werkzeug to be created, got %d", len(fs.werkzeuge))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBetreiberCanUpdateAndDeleteZentralesWerkzeug(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
postForm(t, s, betreiberCookie, "/betreiber/werkzeuge", werkzeugForm())
|
||||
var id string
|
||||
for wid := range fs.werkzeuge {
|
||||
id = wid
|
||||
}
|
||||
|
||||
editResp := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge/"+id)
|
||||
if editResp.Code != http.StatusOK || !strings.Contains(editResp.Body.String(), "ChatGPT Enterprise") {
|
||||
t.Fatalf("edit form status = %d, body: %s", editResp.Code, editResp.Body.String())
|
||||
}
|
||||
|
||||
updateForm := werkzeugForm()
|
||||
updateForm.Set("name", "ChatGPT Enterprise (aktualisiert)")
|
||||
updateResp := postForm(t, s, betreiberCookie, "/betreiber/werkzeuge/"+id, updateForm)
|
||||
if updateResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("update status = %d, body: %s", updateResp.Code, updateResp.Body.String())
|
||||
}
|
||||
if fs.werkzeuge[id].Name != "ChatGPT Enterprise (aktualisiert)" {
|
||||
t.Errorf("Name = %q nach Update, unerwartet", fs.werkzeuge[id].Name)
|
||||
}
|
||||
|
||||
deleteResp := postForm(t, s, betreiberCookie, "/betreiber/werkzeuge/"+id+"/loeschen", url.Values{})
|
||||
if deleteResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("delete status = %d, body: %s", deleteResp.Code, deleteResp.Body.String())
|
||||
}
|
||||
if _, ok := fs.werkzeuge[id]; ok {
|
||||
t.Error("expected the werkzeug to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBetreiberCannotEditMandantenEigenesWerkzeug: ein mandantenspezifischer
|
||||
// Katalogeintrag (account_id gesetzt) ist über die Betreiber-Seiten
|
||||
// nicht erreichbar — das bleibt Sache des jeweiligen Mandanten.
|
||||
func TestBetreiberCannotEditMandantenEigenesWerkzeug(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
acc, err := fs.CreateAccount(context.Background(), "Mandant mit eigenem Werkzeug")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
eigenesID := "eigenes-werkzeug"
|
||||
fs.werkzeuge[eigenesID] = store.Werkzeug{ID: eigenesID, AccountID: &acc.ID, Name: "Mandanten-eigenes Tool"}
|
||||
|
||||
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/werkzeuge/"+eigenesID)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for a mandanten-eigenes werkzeug", resp.Code)
|
||||
}
|
||||
}
|
||||
184
internal/web/csv_import_handlers.go
Normal file
184
internal/web/csv_import_handlers.go
Normal file
@@ -0,0 +1,184 @@
|
||||
// Ebene 4 — CSV-Import für Mitarbeiter (zweiter Punkt der geplanten
|
||||
// Onboarding-Reihenfolge, siehe CLAUDE.md, Onboarding). Es gibt keine
|
||||
// E-Mail-Infrastruktur (siehe Einladungslink, Nutzerverwaltung) — wie
|
||||
// beim einzelnen "Nutzer anlegen" generiert der Import ein
|
||||
// Einmalpasswort pro Zeile, das der Admin nach dem Import EINMALIG
|
||||
// sieht und selbst weitergeben muss. Kein Rollback bei Teilfehlern:
|
||||
// gültige Zeilen werden angelegt, fehlerhafte werden einzeln gemeldet
|
||||
// (keine Alles-oder-nichts-Transaktion — eine falsche Zeile in einer
|
||||
// Liste von 50 Mitarbeitenden soll nicht die anderen 49 blockieren).
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/auth"
|
||||
)
|
||||
|
||||
type csvMitarbeiterZeile struct {
|
||||
Zeile int // 1-basiert, Kopfzeile ist Zeile 1 — für Fehlermeldungen
|
||||
Email string
|
||||
Role string
|
||||
}
|
||||
|
||||
// parseCSVMitarbeiter liest eine Mitarbeiter-CSV mit einer Kopfzeile.
|
||||
// Pflichtspalte "email" (oder "e-mail"), optionale Spalte "role" (oder
|
||||
// "rolle") — fehlt sie oder ist der Wert leer/ungültig, wird das erst
|
||||
// beim Anlegen selbst gemeldet, nicht schon beim Parsen (damit eine
|
||||
// einzelne falsche Rolle nicht den ganzen Import verhindert).
|
||||
func parseCSVMitarbeiter(r io.Reader) ([]csvMitarbeiterZeile, error) {
|
||||
cr := csv.NewReader(r)
|
||||
cr.TrimLeadingSpace = true
|
||||
cr.FieldsPerRecord = -1
|
||||
|
||||
header, err := cr.Read()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Kopfzeile konnte nicht gelesen werden: %w", err)
|
||||
}
|
||||
emailCol, roleCol := -1, -1
|
||||
for i, h := range header {
|
||||
switch strings.ToLower(strings.TrimSpace(h)) {
|
||||
case "email", "e-mail":
|
||||
emailCol = i
|
||||
case "role", "rolle":
|
||||
roleCol = i
|
||||
}
|
||||
}
|
||||
if emailCol == -1 {
|
||||
return nil, fmt.Errorf(`Kopfzeile muss eine Spalte "email" enthalten`)
|
||||
}
|
||||
|
||||
var out []csvMitarbeiterZeile
|
||||
zeile := 1
|
||||
for {
|
||||
record, err := cr.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Zeile %d: %w", zeile+1, err)
|
||||
}
|
||||
zeile++
|
||||
if emailCol >= len(record) {
|
||||
continue
|
||||
}
|
||||
email := strings.TrimSpace(record[emailCol])
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
role := "mitarbeiter"
|
||||
if roleCol != -1 && roleCol < len(record) {
|
||||
if v := strings.TrimSpace(record[roleCol]); v != "" {
|
||||
role = v
|
||||
}
|
||||
}
|
||||
out = append(out, csvMitarbeiterZeile{Zeile: zeile, Email: email, Role: role})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// generateEinmalPasswort erzeugt ein zufälliges Initialpasswort für
|
||||
// eine importierte Zeile — wiederverwendet auth.NewSessionToken() statt
|
||||
// einen eigenen Zufallsgenerator zu schreiben, nur gekürzt auf eine
|
||||
// Länge, die sich noch manuell weitergeben lässt.
|
||||
func generateEinmalPasswort() (string, error) {
|
||||
token, err := auth.NewSessionToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token[:16], nil
|
||||
}
|
||||
|
||||
type csvImportFormData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *Server) handleNutzerCSVImportForm(w http.ResponseWriter, r *http.Request) {
|
||||
data := csvImportFormData{Title: "Mitarbeiter per CSV importieren", Nav: navFor(r)}
|
||||
if err := s.templates.ExecuteTemplate(w, "nutzer-import", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) renderCSVImportForm(w http.ResponseWriter, r *http.Request, errMsg string) {
|
||||
data := csvImportFormData{Title: "Mitarbeiter per CSV importieren", Nav: navFor(r), Error: errMsg}
|
||||
if err := s.templates.ExecuteTemplate(w, "nutzer-import", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type csvImportErgebnisZeile struct {
|
||||
Zeile int
|
||||
Email string
|
||||
Role string
|
||||
Passwort string // nur bei Erfolg gesetzt
|
||||
Fehler string // nur bei Fehler gesetzt
|
||||
}
|
||||
|
||||
type csvImportErgebnisData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Ergebnisse []csvImportErgebnisZeile
|
||||
}
|
||||
|
||||
// handleNutzerCSVImportUpload liest die hochgeladene CSV-Datei und legt
|
||||
// pro gültiger Zeile einen Login im eigenen Mandanten an. Das Ergebnis
|
||||
// (inkl. Einmalpasswort) wird nur EINMAL auf dieser Antwortseite
|
||||
// gezeigt — danach ist nur noch der Hash in der Datenbank vorhanden.
|
||||
func (s *Server) handleNutzerCSVImportUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil { // 1 MB reicht für eine Mitarbeiterliste
|
||||
s.renderCSVImportForm(w, r, "Datei konnte nicht gelesen werden: "+err.Error())
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("csv")
|
||||
if err != nil {
|
||||
s.renderCSVImportForm(w, r, "Bitte eine CSV-Datei auswählen")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
zeilen, err := parseCSVMitarbeiter(file)
|
||||
if err != nil {
|
||||
s.renderCSVImportForm(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
if len(zeilen) == 0 {
|
||||
s.renderCSVImportForm(w, r, "Die Datei enthält keine gültigen Zeilen")
|
||||
return
|
||||
}
|
||||
|
||||
accountID := currentUser(r).AccountID
|
||||
var ergebnisse []csvImportErgebnisZeile
|
||||
for _, z := range zeilen {
|
||||
if !istMandantenRolle(z.Role) {
|
||||
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: fmt.Sprintf("ungültige Rolle %q", z.Role)})
|
||||
continue
|
||||
}
|
||||
passwort, err := generateEinmalPasswort()
|
||||
if err != nil {
|
||||
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: "Passwort konnte nicht erzeugt werden"})
|
||||
continue
|
||||
}
|
||||
hash, err := auth.HashPassword(passwort)
|
||||
if err != nil {
|
||||
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: err.Error()})
|
||||
continue
|
||||
}
|
||||
if _, err := s.store.CreateUser(r.Context(), accountID, z.Email, hash, z.Role); err != nil {
|
||||
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: "E-Mail evtl. schon vergeben"})
|
||||
continue
|
||||
}
|
||||
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Role: z.Role, Passwort: passwort})
|
||||
}
|
||||
|
||||
data := csvImportErgebnisData{Title: "Import-Ergebnis", Nav: navFor(r), Ergebnisse: ergebnisse}
|
||||
if err := s.templates.ExecuteTemplate(w, "nutzer-import-ergebnis", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
149
internal/web/csv_import_handlers_test.go
Normal file
149
internal/web/csv_import_handlers_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/web"
|
||||
)
|
||||
|
||||
// postCSV lädt csvContent als multipart-Formular mit Feldname "csv" hoch
|
||||
// — reale Browser-Uploads laufen genauso über multipart/form-data.
|
||||
func postCSV(t *testing.T, s *web.Server, cookie *http.Cookie, path, csvContent string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
fw, err := mw.CreateFormFile("csv", "mitarbeiter.csv")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFormFile: %v", err)
|
||||
}
|
||||
if _, err := fw.Write([]byte(csvContent)); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
t.Fatalf("mw.Close: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, path, &body)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
if cookie != nil {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestAdminCanImportMitarbeiterPerCSV(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
|
||||
resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email,role\nlisa@example.com,verantwortlicher\ntom@example.com,\n")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
body := resp.Body.String()
|
||||
if !strings.Contains(body, "lisa@example.com") || !strings.Contains(body, "tom@example.com") {
|
||||
t.Fatalf("expected both imported emails in the result, got: %s", body)
|
||||
}
|
||||
|
||||
lisa, err := fs.GetUserByEmail(context.Background(), "lisa@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail (lisa): %v", err)
|
||||
}
|
||||
if lisa.Role != "verantwortlicher" {
|
||||
t.Errorf("lisa.Role = %q, want verantwortlicher", lisa.Role)
|
||||
}
|
||||
tom, err := fs.GetUserByEmail(context.Background(), "tom@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail (tom): %v", err)
|
||||
}
|
||||
if tom.Role != "mitarbeiter" {
|
||||
t.Errorf("tom.Role = %q, want default mitarbeiter", tom.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSVImportZeigtEinmalpasswortDasFunktioniert(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
|
||||
resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email\nneu@example.com\n")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
// Das angezeigte Einmalpasswort aus der <code>-Zelle extrahieren.
|
||||
body := resp.Body.String()
|
||||
start := strings.Index(body, "<code>")
|
||||
end := strings.Index(body, "</code>")
|
||||
if start == -1 || end == -1 {
|
||||
t.Fatalf("expected a <code>-Zelle mit dem Einmalpasswort, got: %s", body)
|
||||
}
|
||||
passwort := body[start+len("<code>") : end]
|
||||
|
||||
loginResp := postForm(t, s, nil, "/login", map[string][]string{
|
||||
"email": {"neu@example.com"}, "password": {passwort},
|
||||
})
|
||||
if loginResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("login with the generated password: status = %d, body: %s", loginResp.Code, loginResp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSVImportMeldetUngueltigeRolleOhneDieAnderenZuBlockieren(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
|
||||
resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email,role\nboese@example.com,betreiber\ngut@example.com,mitarbeiter\n")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if _, err := fs.GetUserByEmail(context.Background(), "boese@example.com"); err == nil {
|
||||
t.Error("expected no user to be created for an invalid role (betreiber)")
|
||||
}
|
||||
if _, err := fs.GetUserByEmail(context.Background(), "gut@example.com"); err != nil {
|
||||
t.Error("expected the valid row to still be imported despite the earlier invalid row")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSVImportOhneDateiZeigtFehler(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
if err := mw.Close(); err != nil {
|
||||
t.Fatalf("mw.Close: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/verwaltung/nutzer/import", &body)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.AddCookie(adminCookie)
|
||||
w := httptest.NewRecorder()
|
||||
s.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "CSV-Datei auswählen") {
|
||||
t.Errorf("expected an error asking for a file, got: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMitarbeiterCannotAccessCSVImport(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/import")
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||
}
|
||||
}
|
||||
64
internal/web/csv_import_parse_test.go
Normal file
64
internal/web/csv_import_parse_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseCSVMitarbeiterMitRolle(t *testing.T) {
|
||||
csv := "email,role\nlisa@example.com,verantwortlicher\ntom@example.com,\n"
|
||||
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
|
||||
if err != nil {
|
||||
t.Fatalf("parseCSVMitarbeiter: %v", err)
|
||||
}
|
||||
if len(zeilen) != 2 {
|
||||
t.Fatalf("zeilen = %+v, want 2", zeilen)
|
||||
}
|
||||
if zeilen[0].Email != "lisa@example.com" || zeilen[0].Role != "verantwortlicher" {
|
||||
t.Errorf("zeilen[0] = %+v, unerwartet", zeilen[0])
|
||||
}
|
||||
if zeilen[1].Email != "tom@example.com" || zeilen[1].Role != "mitarbeiter" {
|
||||
t.Errorf("zeilen[1] = %+v, want default role mitarbeiter", zeilen[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCSVMitarbeiterOhneRollenspalte(t *testing.T) {
|
||||
csv := "email\nlisa@example.com\n"
|
||||
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
|
||||
if err != nil {
|
||||
t.Fatalf("parseCSVMitarbeiter: %v", err)
|
||||
}
|
||||
if len(zeilen) != 1 || zeilen[0].Role != "mitarbeiter" {
|
||||
t.Fatalf("zeilen = %+v, unerwartet", zeilen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCSVMitarbeiterUeberspringtLeereEmail(t *testing.T) {
|
||||
csv := "email,role\n,mitarbeiter\nlisa@example.com,mitarbeiter\n"
|
||||
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
|
||||
if err != nil {
|
||||
t.Fatalf("parseCSVMitarbeiter: %v", err)
|
||||
}
|
||||
if len(zeilen) != 1 || zeilen[0].Email != "lisa@example.com" {
|
||||
t.Fatalf("zeilen = %+v, want exactly lisa@example.com", zeilen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCSVMitarbeiterOhneEmailSpalteSchlaegtFehl(t *testing.T) {
|
||||
csv := "name,role\nLisa,mitarbeiter\n"
|
||||
_, err := parseCSVMitarbeiter(strings.NewReader(csv))
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when the header has no email column")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCSVMitarbeiterZeilennummernZaehlenKopfzeileAlsEins(t *testing.T) {
|
||||
csv := "email\nlisa@example.com\ntom@example.com\n"
|
||||
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
|
||||
if err != nil {
|
||||
t.Fatalf("parseCSVMitarbeiter: %v", err)
|
||||
}
|
||||
if len(zeilen) != 2 || zeilen[0].Zeile != 2 || zeilen[1].Zeile != 3 {
|
||||
t.Fatalf("zeilen = %+v, want Zeile 2 and 3", zeilen)
|
||||
}
|
||||
}
|
||||
140
internal/web/einladung_handlers.go
Normal file
140
internal/web/einladung_handlers.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// Ebene 1 (öffentlich) — Mitarbeiter-Selbstanmeldung über den
|
||||
// Sammellink eines Mandanten ("Einladung annehmen", siehe CLAUDE.md,
|
||||
// Onboarding: "Einladungslink [Sammellink, Selbstanmeldung, Abteilung
|
||||
// beim ersten Antrag]"). Ein Link gilt für beliebig viele Mitarbeiter
|
||||
// desselben Mandanten, kein Einmal-Token pro Person — die Abteilung
|
||||
// wählt der/die Mitarbeiter:in erst beim ersten Antrag (Fragebogen
|
||||
// Abschnitt A), nicht hier.
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/auth"
|
||||
)
|
||||
|
||||
type einladungFormData struct {
|
||||
Title string
|
||||
Firmenname string
|
||||
Error string
|
||||
}
|
||||
|
||||
// handleEinladungForm zeigt das Selbstanmelde-Formular für einen
|
||||
// gültigen Einladungslink. Ein unbekanntes oder erneuertes (damit
|
||||
// ungültig gewordenes) Token bekommt dieselbe Fehlermeldung wie ein
|
||||
// abgelaufener Link — es wird nicht unterschieden, ob das Token nie
|
||||
// existiert hat oder nur nicht mehr gültig ist.
|
||||
func (s *Server) handleEinladungForm(w http.ResponseWriter, r *http.Request) {
|
||||
acc, err := s.store.GetAccountByEinladungToken(r.Context(), r.PathValue("token"))
|
||||
if err != nil {
|
||||
s.renderEinladungError(w)
|
||||
return
|
||||
}
|
||||
s.renderEinladungForm(w, acc.Name, "")
|
||||
}
|
||||
|
||||
func (s *Server) renderEinladungForm(w http.ResponseWriter, firmenname, errMsg string) {
|
||||
data := einladungFormData{Title: "Einladung annehmen", Firmenname: firmenname, Error: errMsg}
|
||||
if err := s.templates.ExecuteTemplate(w, "einladung", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) renderEinladungError(w http.ResponseWriter) {
|
||||
if err := s.templates.ExecuteTemplate(w, "einladung-ungueltig", authPageData{Title: "Einladung"}); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleEinladungAnnehmen legt einen neuen Login mit Rolle
|
||||
// "mitarbeiter" im Mandanten des Einladungslinks an und meldet die
|
||||
// Person direkt an — wie bei der Firma-Registrierung gibt es keine
|
||||
// E-Mail-Bestätigung (noch nicht gebaut, siehe CLAUDE.md, Onboarding).
|
||||
func (s *Server) handleEinladungAnnehmen(w http.ResponseWriter, r *http.Request) {
|
||||
acc, err := s.store.GetAccountByEinladungToken(r.Context(), r.PathValue("token"))
|
||||
if err != nil {
|
||||
s.renderEinladungError(w)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderEinladungForm(w, acc.Name, "ungültiges Formular")
|
||||
return
|
||||
}
|
||||
|
||||
email := r.FormValue("email")
|
||||
password := r.FormValue("password")
|
||||
if email == "" || password == "" {
|
||||
s.renderEinladungForm(w, acc.Name, "E-Mail und Passwort sind Pflicht")
|
||||
return
|
||||
}
|
||||
|
||||
passwordHash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
s.renderEinladungForm(w, acc.Name, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.store.CreateUser(r.Context(), acc.ID, email, passwordHash, "mitarbeiter")
|
||||
if err != nil {
|
||||
s.renderEinladungForm(w, acc.Name, "Konto konnte nicht angelegt werden — E-Mail evtl. schon vergeben")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.startSession(w, r, user.ID); err != nil {
|
||||
s.renderEinladungForm(w, acc.Name, "Sitzung konnte nicht gestartet werden")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
type einladungVerwaltungData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Link string
|
||||
}
|
||||
|
||||
// einladungBasisURL leitet Schema+Host aus dem Request ab, damit der
|
||||
// vollständige Link (nicht nur das Token) angezeigt werden kann — hinter
|
||||
// einem Reverse-Proxy (siehe CLAUDE.md, Testsystem: nginx+TLS) trägt
|
||||
// r.TLS bei HTTPS-Terminierung am Proxy nichts, deshalb zusätzlich
|
||||
// X-Forwarded-Proto berücksichtigen.
|
||||
func einladungBasisURL(r *http.Request) string {
|
||||
scheme := "http"
|
||||
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
return scheme + "://" + r.Host
|
||||
}
|
||||
|
||||
// handleEinladungVerwaltung zeigt den aktuellen Sammellink des eigenen
|
||||
// Mandanten zum Kopieren.
|
||||
func (s *Server) handleEinladungVerwaltung(w http.ResponseWriter, r *http.Request) {
|
||||
acc, err := s.store.GetAccount(r.Context(), currentUser(r).AccountID)
|
||||
if err != nil {
|
||||
http.Error(w, "Account konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := einladungVerwaltungData{
|
||||
Title: "Einladungslink", Nav: navFor(r),
|
||||
Link: einladungBasisURL(r) + "/einladung/" + acc.EinladungToken,
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "einladung-verwaltung", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleEinladungErneuern ersetzt den Sammellink des eigenen Mandanten
|
||||
// — der alte Link wird sofort ungültig (z. B. wenn er versehentlich
|
||||
// außerhalb des Unternehmens geteilt wurde).
|
||||
func (s *Server) handleEinladungErneuern(w http.ResponseWriter, r *http.Request) {
|
||||
newToken, err := auth.NewSessionToken()
|
||||
if err != nil {
|
||||
http.Error(w, "Neuer Link konnte nicht erzeugt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.store.RegenerateEinladungToken(r.Context(), currentUser(r).AccountID, newToken); err != nil {
|
||||
http.Error(w, "Link konnte nicht erneuert werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/einladung", http.StatusSeeOther)
|
||||
}
|
||||
144
internal/web/einladung_handlers_test.go
Normal file
144
internal/web/einladung_handlers_test.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEinladungFormZeigtFirmenname(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
acc, err := fs.CreateAccount(context.Background(), "Beispiel GmbH")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
resp := getWithCookie(t, s, nil, "/einladung/"+acc.EinladungToken)
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "Beispiel GmbH") {
|
||||
t.Errorf("expected the Firmenname on the Einladung page, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinladungMitUnbekanntemTokenZeigtFehler(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
|
||||
resp := getWithCookie(t, s, nil, "/einladung/unbekanntes-token")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "ungültig") {
|
||||
t.Errorf("expected an invalid-link message, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinladungAnnehmenLegtMitarbeiterAn(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
acc, err := fs.CreateAccount(context.Background(), "Beispiel GmbH")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
resp := postForm(t, s, nil, "/einladung/"+acc.EinladungToken, url.Values{
|
||||
"email": {"neu@example.com"}, "password": {"ein-langes-passwort"},
|
||||
})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if len(resp.Result().Cookies()) == 0 {
|
||||
t.Fatal("expected a session cookie to be set")
|
||||
}
|
||||
|
||||
user, err := fs.GetUserByEmail(context.Background(), "neu@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
if user.Role != "mitarbeiter" || user.AccountID != acc.ID {
|
||||
t.Errorf("User = %+v, want role mitarbeiter in account %s", user, acc.ID)
|
||||
}
|
||||
|
||||
cookie := resp.Result().Cookies()[0]
|
||||
protected := getWithCookie(t, s, cookie, "/antraege")
|
||||
if protected.Code != http.StatusOK {
|
||||
t.Fatalf("expected the new session to work, status = %d", protected.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinladungAnnehmenMitUnbekanntemTokenSchlaegtFehl(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
|
||||
resp := postForm(t, s, nil, "/einladung/unbekanntes-token", url.Values{
|
||||
"email": {"neu@example.com"}, "password": {"ein-langes-passwort"},
|
||||
})
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "ungültig") {
|
||||
t.Errorf("expected an invalid-link message, got: %s", resp.Body.String())
|
||||
}
|
||||
if len(fs.users) != 0 {
|
||||
t.Error("expected no user to be created for an invalid token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSiehtEinladungslink(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
|
||||
resp := getWithCookie(t, s, adminCookie, "/verwaltung/einladung")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "/einladung/") {
|
||||
t.Errorf("expected the Einladungslink on the page, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminKannEinladungslinkErneuern(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
admin, err := fs.GetUserByEmail(context.Background(), "admin@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
altesToken := fs.accounts[admin.AccountID].EinladungToken
|
||||
|
||||
resp := postForm(t, s, adminCookie, "/verwaltung/einladung/erneuern", url.Values{})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
neuesToken := fs.accounts[admin.AccountID].EinladungToken
|
||||
if neuesToken == altesToken {
|
||||
t.Fatal("expected the token to change")
|
||||
}
|
||||
|
||||
oldLinkResp := getWithCookie(t, s, nil, "/einladung/"+altesToken)
|
||||
if !strings.Contains(oldLinkResp.Body.String(), "ungültig") {
|
||||
t.Errorf("expected the old link to be invalid, got: %s", oldLinkResp.Body.String())
|
||||
}
|
||||
newLinkResp := getWithCookie(t, s, nil, "/einladung/"+neuesToken)
|
||||
if newLinkResp.Code != http.StatusOK || strings.Contains(newLinkResp.Body.String(), "ungültig") {
|
||||
t.Errorf("expected the new link to work, status=%d body: %s", newLinkResp.Code, newLinkResp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMitarbeiterCannotAccessEinladungsverwaltung(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/einladung")
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||
}
|
||||
}
|
||||
280
internal/web/fachebene_handlers.go
Normal file
280
internal/web/fachebene_handlers.go
Normal file
@@ -0,0 +1,280 @@
|
||||
// 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 (
|
||||
"encoding/json"
|
||||
"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
|
||||
Antworten []antwortZeile
|
||||
Bewertung *bewertungView
|
||||
Entscheidung *entscheidungView
|
||||
CanEntscheiden bool
|
||||
WerkzeugOptionen []werkzeugOption
|
||||
Error string
|
||||
}
|
||||
|
||||
// hatEntscheidungsrecht — "admin" ist bewusst mit eingeschlossen, siehe
|
||||
// requireFachebene: die Spezifikation will admin+verantwortlicher auf
|
||||
// derselben Person, ohne dass app_user.role zwei Werte tragen kann.
|
||||
func hatEntscheidungsrecht(role string) bool {
|
||||
return role == "verantwortlicher" || role == "admin"
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
var antworten map[string]any
|
||||
if len(antrag.Antworten) > 0 {
|
||||
_ = json.Unmarshal(antrag.Antworten, &antworten)
|
||||
}
|
||||
|
||||
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"), Antworten: antwortenAnzeige(antworten),
|
||||
}
|
||||
|
||||
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 = hatEntscheidungsrecht(currentUser(r).Role) && 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 (oder admin, siehe hatEntscheidungsrecht). 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 !hatEntscheidungsrecht(user.Role) {
|
||||
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
|
||||
}
|
||||
|
||||
istGenehmigung := entscheidungTyp == "genehmigt" || entscheidungTyp == "genehmigt_mit_auflagen"
|
||||
in := store.EntscheidungInput{
|
||||
AntragID: antrag.ID, BewertungID: bewertung.ID, EntscheiderUserID: user.ID,
|
||||
Entscheidung: entscheidungTyp, Begruendung: begruendung,
|
||||
}
|
||||
var werkzeugName string
|
||||
if istGenehmigung {
|
||||
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
|
||||
}
|
||||
werkzeugName = werkzeug.Name
|
||||
in.WerkzeugID = &werkzeugID
|
||||
in.WerkzeugSnapshot = &werkzeug
|
||||
gueltigBis := gueltigkeitFuer(bewertung.Einstufung)
|
||||
in.GueltigBis = &gueltigBis
|
||||
}
|
||||
|
||||
entscheidung, err := s.store.CreateEntscheidung(r.Context(), in)
|
||||
if 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
|
||||
}
|
||||
|
||||
// Jede Genehmigung erzeugt automatisch einen Registereintrag (siehe
|
||||
// CLAUDE.md) — bei "abgelehnt"/"rueckfrage" gibt es nichts
|
||||
// Genehmigtes, das im Register stehen müsste.
|
||||
if istGenehmigung {
|
||||
abteilungName := ""
|
||||
if antrag.AbteilungID != nil {
|
||||
if abt, err := s.store.GetAbteilung(r.Context(), *antrag.AbteilungID); err == nil {
|
||||
abteilungName = abt.Name
|
||||
}
|
||||
}
|
||||
var auflagen []string
|
||||
for _, a := range bewertung.Anforderungen {
|
||||
auflagen = append(auflagen, a.Beschreibung)
|
||||
}
|
||||
if _, err := s.store.CreateRegistereintrag(r.Context(), store.RegistereintragInput{
|
||||
AccountID: user.AccountID, AntragID: antrag.ID, EntscheidungID: entscheidung.ID,
|
||||
Zweck: antrag.Titel, Abteilung: abteilungName, Werkzeug: werkzeugName,
|
||||
Datenklasse: bewertung.Datenklasse, Einstufung: bewertung.Einstufung, Auflagen: auflagen,
|
||||
Verantwortlicher: user.Email, EntschiedenAm: entscheidung.CreatedAt, GueltigBis: entscheidung.GueltigBis,
|
||||
}); err != nil {
|
||||
http.Error(w, "Registereintrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/faelle/"+antrag.ID, http.StatusSeeOther)
|
||||
}
|
||||
238
internal/web/fachebene_handlers_test.go
Normal file
238
internal/web/fachebene_handlers_test.go
Normal file
@@ -0,0 +1,238 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
// TestFallDetailZeigtFragebogenAntworten: die Fachebene muss die
|
||||
// tatsächlich gegebenen Fragebogen-Antworten sehen, nicht nur die
|
||||
// daraus abgeleitete Bewertung — sonst lässt sich eine Entscheidung
|
||||
// nicht nachvollziehbar treffen (siehe CLAUDE.md, Grundregel).
|
||||
func TestFallDetailZeigtFragebogenAntworten(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())
|
||||
}
|
||||
body := resp.Body.String()
|
||||
if !strings.Contains(body, "Namen, E-Mail-Adressen oder andere Angaben zu Personen") {
|
||||
t.Errorf("expected the b1 Fragebogen-Frage on the fall-detail page, got: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "Merkt der Empfänger, dass es von einer KI stammt?") {
|
||||
t.Errorf("expected the c5 Fragebogen-Frage on the fall-detail page, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,8 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/dossier"
|
||||
"github.com/netcell-it/deklarix/internal/evidence"
|
||||
"github.com/netcell-it/deklarix/internal/extract"
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
)
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -20,255 +11,50 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
type indexData struct {
|
||||
Title string
|
||||
Title string
|
||||
Nav navData
|
||||
OffeneAntraege int
|
||||
ZeigePosteingang bool
|
||||
OffenerPosteingang int
|
||||
}
|
||||
|
||||
// handleIndex ist die Startseite nach der Anmeldung — ein kurzer
|
||||
// Überblick statt reiner Navigations-Duplikate (die Links stehen
|
||||
// eh schon in der Seitenleiste): eigene offene Anträge, und für die
|
||||
// Fachebene zusätzlich die Zahl offener Fälle im Posteingang. Bewusst
|
||||
// ohne die Wiedervorlage-Zahl hier — deren Berechnung vergleicht jeden
|
||||
// aktiven Genehmigungs-Snapshot gegen den aktuellen Katalogstand
|
||||
// (siehe handleWiedervorlageListe) und wäre für eine Startseite, die
|
||||
// bei jedem Login geladen wird, zu teuer.
|
||||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.templates.ExecuteTemplate(w, "index", indexData{Title: "Pre-Publish-Prüfung"}); err != nil {
|
||||
user := currentUser(r)
|
||||
nav := navFor(r)
|
||||
data := indexData{Title: "Start", Nav: nav}
|
||||
|
||||
if !nav.IsBetreiber {
|
||||
eigene, err := s.store.ListAntraegeForUser(r.Context(), user.ID)
|
||||
if err == nil {
|
||||
for _, a := range eigene {
|
||||
if a.Status == "eingereicht" {
|
||||
data.OffeneAntraege++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if nav.IsFachebene {
|
||||
data.ZeigePosteingang = true
|
||||
alle, err := s.store.ListAntraegeForAccount(r.Context(), user.AccountID)
|
||||
if err == nil {
|
||||
for _, a := range alle {
|
||||
if a.Status == "eingereicht" {
|
||||
data.OffenerPosteingang++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.templates.ExecuteTemplate(w, "index", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type findingView struct {
|
||||
RuleID string
|
||||
Version int
|
||||
Severity string
|
||||
Title string
|
||||
Fix string
|
||||
Sources []string
|
||||
}
|
||||
|
||||
type resultData struct {
|
||||
SubmissionID string
|
||||
NeedsClarification bool
|
||||
CanArchive bool
|
||||
Findings []findingView
|
||||
}
|
||||
|
||||
// handleCheck führt die Pre-Publish-Prüfung aus: Extraktion (Stufe 1),
|
||||
// Regelauswertung (Stufe 2), und speichert Submission + Extraction +
|
||||
// Findings. Die eigentliche Archivierung (Hash, Zeitstempel, Dossier)
|
||||
// passiert erst in handleArchive, wenn der Beitrag tatsächlich
|
||||
// veröffentlicht wurde — das sind unterschiedliche Zeitpunkte im
|
||||
// Lebenszyklus (siehe CLAUDE.md, Funktion 1 vs. 2).
|
||||
func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
platform := r.FormValue("platform")
|
||||
postType := r.FormValue("post_type")
|
||||
caption := r.FormValue("caption")
|
||||
consideration := r.FormValue("consideration")
|
||||
if platform == "" || postType == "" || caption == "" || consideration == "" {
|
||||
http.Error(w, "Plattform, Beitragstyp, Gegenleistung und Caption sind Pflichtfelder", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
accountID := currentUser(r).AccountID
|
||||
|
||||
result, err := s.extractor.Extract(ctx, extract.Input{
|
||||
Platform: platform,
|
||||
Jurisdiction: "DE",
|
||||
Consideration: consideration,
|
||||
Caption: caption,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "Extraktion fehlgeschlagen: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
sub, err := s.store.CreateSubmission(ctx, accountID, platform, postType, caption)
|
||||
if err != nil {
|
||||
http.Error(w, "Beitrag konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
ext, err := s.store.CreateExtraction(ctx, sub.ID, result.RawJSON, s.extractor.ModelVersion(), extract.PromptVersion)
|
||||
if err != nil {
|
||||
http.Error(w, "Extraktion konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
findings, needsClarification := rules.Evaluate(s.ruleSet, result.Facts)
|
||||
|
||||
data := resultData{SubmissionID: sub.ID, NeedsClarification: needsClarification}
|
||||
if !needsClarification {
|
||||
extractionID := ext.ID
|
||||
for _, f := range findings {
|
||||
if _, err := s.store.CreateFinding(ctx, sub.ID, &extractionID, f.RuleID, f.RuleVersion, string(f.Severity), f.Title, f.Fix, f.Sources); err != nil {
|
||||
http.Error(w, "Finding konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data.Findings = append(data.Findings, findingView{
|
||||
RuleID: f.RuleID, Version: f.RuleVersion, Severity: string(f.Severity),
|
||||
Title: f.Title, Fix: f.Fix, Sources: f.Sources,
|
||||
})
|
||||
}
|
||||
if err := s.store.SetSubmissionStatus(ctx, sub.ID, "checked"); err != nil {
|
||||
http.Error(w, "Status konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data.CanArchive = true
|
||||
}
|
||||
|
||||
if err := s.templates.ExecuteTemplate(w, "result", data); err != nil {
|
||||
http.Error(w, "Ergebnis konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type archivedData struct {
|
||||
SubmissionID string
|
||||
DossierURL string
|
||||
TimestampedAt string
|
||||
}
|
||||
|
||||
// handleArchive markiert einen geprüften Beitrag als veröffentlicht und
|
||||
// archiviert ihn: Hash der Metadaten, RFC-3161-Zeitstempel, PDF-Dossier.
|
||||
// Setzt eine vorherige Prüfung voraus (Submission + Extraction +
|
||||
// Findings müssen bereits existieren).
|
||||
func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
submissionID := r.FormValue("submission_id")
|
||||
if submissionID == "" {
|
||||
http.Error(w, "submission_id ist Pflicht", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
sub, err := s.store.GetSubmission(ctx, submissionID)
|
||||
if err != nil {
|
||||
http.Error(w, "Beitrag nicht gefunden: "+err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
// Mandantentrennung: ein Beitrag eines anderen Accounts wird wie ein
|
||||
// nicht existierender behandelt, nicht mit einer 403 bestätigt —
|
||||
// eine 403 würde einem anderen Mandanten verraten, dass die ID
|
||||
// existiert.
|
||||
if sub.AccountID != currentUser(r).AccountID {
|
||||
http.Error(w, "Beitrag nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
ext, err := s.store.GetLatestExtraction(ctx, submissionID)
|
||||
if err != nil {
|
||||
http.Error(w, "Extraktion nicht gefunden: "+err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
facts, err := extract.ParsePayload(ext.Payload, sub.Platform, "DE")
|
||||
if err != nil {
|
||||
http.Error(w, "gespeicherte Extraktion konnte nicht gelesen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
storeFindings, err := s.store.ListCurrentFindings(ctx, submissionID)
|
||||
if err != nil {
|
||||
http.Error(w, "Findings konnten nicht gelesen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
dossierFindings := make([]rules.Finding, len(storeFindings))
|
||||
for i, f := range storeFindings {
|
||||
dossierFindings[i] = rules.Finding{
|
||||
RuleID: f.RuleID, RuleVersion: f.RuleVersion, Severity: rules.Severity(f.Severity),
|
||||
Title: f.Title, Fix: f.Fix, Sources: f.Sources,
|
||||
}
|
||||
}
|
||||
|
||||
metadataHash, err := evidence.HashMetadata(struct {
|
||||
SubmissionID string
|
||||
Platform string
|
||||
PostType string
|
||||
Caption string
|
||||
Facts rules.Facts
|
||||
Findings []rules.Finding
|
||||
}{sub.ID, sub.Platform, sub.PostType, sub.Caption, facts, dossierFindings})
|
||||
if err != nil {
|
||||
http.Error(w, "Metadaten-Hash fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
timestampToken, err := s.timestamper.Timestamp(ctx, metadataHash)
|
||||
if err != nil {
|
||||
http.Error(w, "Zeitstempel fehlgeschlagen: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(s.dossierDir, 0o750); err != nil {
|
||||
http.Error(w, "Dossier-Verzeichnis konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
dossierPath := filepath.Join(s.dossierDir, submissionID+".pdf")
|
||||
dossierFile, err := os.Create(dossierPath)
|
||||
if err != nil {
|
||||
http.Error(w, "Dossier-Datei konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer dossierFile.Close()
|
||||
|
||||
genErr := dossier.Generate(dossierFile, dossier.Data{
|
||||
Submission: dossier.Submission{
|
||||
Platform: sub.Platform, PostType: sub.PostType, Caption: sub.Caption, CreatedAt: sub.CreatedAt,
|
||||
},
|
||||
Facts: facts,
|
||||
Findings: dossierFindings,
|
||||
MetadataHash: metadataHash,
|
||||
TimestampToken: timestampToken,
|
||||
GeneratedAt: time.Now(),
|
||||
})
|
||||
if genErr != nil {
|
||||
http.Error(w, "Dossier konnte nicht erzeugt werden: "+genErr.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := s.store.CreateEvidencePackage(ctx, submissionID, dossierPath, hex.EncodeToString(metadataHash), timestampToken); err != nil {
|
||||
http.Error(w, "Evidence-Package konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.store.SetSubmissionStatus(ctx, submissionID, "published"); err != nil {
|
||||
http.Error(w, "Status konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
timestampedAt, err := evidence.TimestampTime(timestampToken)
|
||||
if err != nil {
|
||||
http.Error(w, "Zeitstempel konnte nicht gelesen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := archivedData{
|
||||
SubmissionID: submissionID,
|
||||
DossierURL: "/dossier/" + submissionID,
|
||||
TimestampedAt: timestampedAt.Format("02.01.2006 15:04:05 MST"),
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "archived", data); err != nil {
|
||||
http.Error(w, "Ergebnis konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleDossierDownload liefert das erzeugte PDF-Dossier eines
|
||||
// archivierten Beitrags aus.
|
||||
func (s *Server) handleDossierDownload(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
ctx := r.Context()
|
||||
|
||||
sub, err := s.store.GetSubmission(ctx, id)
|
||||
if err != nil || sub.AccountID != currentUser(r).AccountID {
|
||||
http.Error(w, "Kein Dossier für diesen Beitrag gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
pkg, err := s.store.GetLatestEvidencePackage(ctx, id)
|
||||
if err != nil {
|
||||
http.Error(w, "Kein Dossier für diesen Beitrag gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
http.ServeFile(w, r, pkg.DossierPath)
|
||||
}
|
||||
|
||||
63
internal/web/handlers_test.go
Normal file
63
internal/web/handlers_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIndexZeigtEigeneOffeneAntraege(t *testing.T) {
|
||||
s, _, cookie := newAuthedTestServer(t)
|
||||
postForm(t, s, cookie, "/antraege", fullAntragForm())
|
||||
|
||||
resp := getWithCookie(t, s, cookie, "/")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), `href="/antraege">Meine offenen Anträge <span class="status">1</span>`) {
|
||||
t.Errorf("expected 1 offenen Antrag auf der Startseite, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexZeigtPosteingangNurFuerFachebene(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
_, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
|
||||
|
||||
resp := getWithCookie(t, s, verantwortlicherCookie, "/")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), `href="/faelle">Posteingang <span class="status">1</span>`) {
|
||||
t.Errorf("expected 1 offenen Fall im Posteingang auf der Startseite, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexZeigtKeinenPosteingangFuerMitarbeiter(t *testing.T) {
|
||||
s, _, cookie := newAuthedTestServer(t)
|
||||
|
||||
resp := getWithCookie(t, s, cookie, "/")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if strings.Contains(resp.Body.String(), "Posteingang") {
|
||||
t.Errorf("expected no Posteingang tile for mitarbeiter, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexZeigtPlattformLinkFuerBetreiber(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
|
||||
|
||||
resp := getWithCookie(t, s, betreiberCookie, "/")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), `href="/betreiber">Zur Plattform`) {
|
||||
t.Errorf("expected a Plattform link for betreiber, got: %s", resp.Body.String())
|
||||
}
|
||||
if strings.Contains(resp.Body.String(), "Antrag stellen") {
|
||||
t.Errorf("expected no antrag tiles for betreiber, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
103
internal/web/mandant_eigenes_werkzeug_test.go
Normal file
103
internal/web/mandant_eigenes_werkzeug_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func TestAdminCanCreateEditAndDeleteEigenesWerkzeug(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/werkzeuge", werkzeugForm())
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("create status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
var id string
|
||||
admin, err := fs.GetUserByEmail(context.Background(), "admin@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
for wid, w := range fs.werkzeuge {
|
||||
if w.AccountID != nil && *w.AccountID == admin.AccountID {
|
||||
id = wid
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatal("expected an eigenes werkzeug to exist")
|
||||
}
|
||||
|
||||
list := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge")
|
||||
if !strings.Contains(list.Body.String(), "ChatGPT Enterprise") {
|
||||
t.Errorf("expected the eigenes werkzeug in the list, got: %s", list.Body.String())
|
||||
}
|
||||
|
||||
editForm := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge/"+id)
|
||||
if editForm.Code != http.StatusOK || !strings.Contains(editForm.Body.String(), "ChatGPT Enterprise") {
|
||||
t.Fatalf("edit form status = %d, body: %s", editForm.Code, editForm.Body.String())
|
||||
}
|
||||
|
||||
updateForm := werkzeugForm()
|
||||
updateForm.Set("name", "ChatGPT Enterprise (eigene Version)")
|
||||
updateResp := postForm(t, s, adminCookie, "/verwaltung/werkzeuge/"+id, updateForm)
|
||||
if updateResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("update status = %d, body: %s", updateResp.Code, updateResp.Body.String())
|
||||
}
|
||||
if fs.werkzeuge[id].Name != "ChatGPT Enterprise (eigene Version)" {
|
||||
t.Errorf("Name = %q nach Update, unerwartet", fs.werkzeuge[id].Name)
|
||||
}
|
||||
|
||||
deleteResp := postForm(t, s, adminCookie, "/verwaltung/werkzeuge/"+id+"/loeschen", url.Values{})
|
||||
if deleteResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("delete status = %d, body: %s", deleteResp.Code, deleteResp.Body.String())
|
||||
}
|
||||
if _, ok := fs.werkzeuge[id]; ok {
|
||||
t.Error("expected the eigenes werkzeug to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCannotEditZentralesWerkzeugUeberMandantenRoute(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
fs.werkzeuge["werkzeug-central"] = store.Werkzeug{ID: "werkzeug-central", Name: "ChatGPT Enterprise", Anbieter: "OpenAI"}
|
||||
|
||||
resp := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge/werkzeug-central")
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for a central werkzeug via the tenant route", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCannotEditFremdesEigenesWerkzeug(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminA := seedAccountWithRole(t, fs, "Mandant A", "admin-a@example.com", "admin")
|
||||
postForm(t, s, adminA, "/verwaltung/werkzeuge", werkzeugForm())
|
||||
adminAUser, err := fs.GetUserByEmail(context.Background(), "admin-a@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
var id string
|
||||
for wid, w := range fs.werkzeuge {
|
||||
if w.AccountID != nil && *w.AccountID == adminAUser.AccountID {
|
||||
id = wid
|
||||
}
|
||||
}
|
||||
|
||||
adminB := seedAccountWithRole(t, fs, "Mandant B", "admin-b@example.com", "admin")
|
||||
resp := getWithCookie(t, s, adminB, "/verwaltung/werkzeuge/"+id)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for a foreign account's eigenes werkzeug", resp.Code)
|
||||
}
|
||||
deleteResp := postForm(t, s, adminB, "/verwaltung/werkzeuge/"+id+"/loeschen", url.Values{})
|
||||
if deleteResp.Code != http.StatusNotFound {
|
||||
t.Fatalf("delete status = %d, want 404 for a foreign account's eigenes werkzeug", deleteResp.Code)
|
||||
}
|
||||
}
|
||||
209
internal/web/mandant_werkzeug_handlers.go
Normal file
209
internal/web/mandant_werkzeug_handlers.go
Normal file
@@ -0,0 +1,209 @@
|
||||
// Ebene 4 — "eigene Werkzeug-Freigaben/-Sperrungen" (siehe CLAUDE.md,
|
||||
// Fünf Ebenen): ein Mandant kann einen zentralen Katalogeintrag für
|
||||
// sich sperren, ohne den zentralen Katalog selbst zu verändern (das
|
||||
// bleibt Sache der Plattform, siehe betreiber_werkzeug_handlers.go),
|
||||
// UND eigene, mandantenspezifische Katalogeinträge pflegen
|
||||
// (`werkzeug.account_id` = der eigene Account) — dieselbe
|
||||
// Formular-Vorlage wie der Betreiber nutzt (werkzeugFormData,
|
||||
// betreiber-werkzeug-form.html, ActionBase unterscheidet die Ziel-URL).
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type mandantWerkzeugItem struct {
|
||||
ID string
|
||||
Name string
|
||||
Anbieter string
|
||||
Gesperrt bool
|
||||
}
|
||||
|
||||
type eigenesWerkzeugItem struct {
|
||||
ID string
|
||||
Name string
|
||||
Anbieter string
|
||||
}
|
||||
|
||||
type mandantWerkzeugListeData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Werkzeuge []mandantWerkzeugItem
|
||||
EigeneWerkzeuge []eigenesWerkzeugItem
|
||||
}
|
||||
|
||||
// handleMandantWerkzeugListe zeigt den zentralen Katalog aus Sicht des
|
||||
// eigenen Mandanten mit Sperren/Entsperren-Aktion je Eintrag, sowie
|
||||
// die eigenen (mandantenspezifischen) Katalogeinträge mit Bearbeiten/
|
||||
// Löschen.
|
||||
func (s *Server) handleMandantWerkzeugListe(w http.ResponseWriter, r *http.Request) {
|
||||
accountID := currentUser(r).AccountID
|
||||
zentral, err := s.store.ListZentraleWerkzeuge(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "Werkzeugkatalog konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
sperren, err := s.store.ListWerkzeugSperrenForAccount(r.Context(), accountID)
|
||||
if err != nil {
|
||||
http.Error(w, "Sperrungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
eigene, err := s.store.ListWerkzeugeForAccount(r.Context(), accountID)
|
||||
if err != nil {
|
||||
http.Error(w, "Werkzeuge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
gesperrt := make(map[string]bool, len(sperren))
|
||||
for _, sp := range sperren {
|
||||
gesperrt[sp.WerkzeugID] = true
|
||||
}
|
||||
|
||||
data := mandantWerkzeugListeData{Title: "Werkzeugkatalog", Nav: navFor(r)}
|
||||
for _, wz := range zentral {
|
||||
data.Werkzeuge = append(data.Werkzeuge, mandantWerkzeugItem{
|
||||
ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter, Gesperrt: gesperrt[wz.ID],
|
||||
})
|
||||
}
|
||||
for _, wz := range eigene {
|
||||
if wz.AccountID == nil {
|
||||
continue // zentral, oben schon gelistet
|
||||
}
|
||||
data.EigeneWerkzeuge = append(data.EigeneWerkzeuge, eigenesWerkzeugItem{ID: wz.ID, Name: wz.Name, Anbieter: wz.Anbieter})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "mandant-werkzeuge", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleMandantWerkzeugNeuForm(w http.ResponseWriter, r *http.Request) {
|
||||
data := werkzeugFormData{Title: "Eigenes Werkzeug anlegen", Nav: navFor(r), ActionBase: "/verwaltung/werkzeuge", Verarbeitungsorte: gueltigeVerarbeitungsorte}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleMandantWerkzeugCreate legt einen mandantenspezifischen
|
||||
// Katalogeintrag an (`werkzeug.account_id` = eigener Account) — sichtbar
|
||||
// nur für diesen Mandanten, verändert den zentralen Katalog nicht.
|
||||
func (s *Server) handleMandantWerkzeugCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
accountID := currentUser(r).AccountID
|
||||
in, errMsg := werkzeugInputFromForm(r, &accountID)
|
||||
if errMsg != "" {
|
||||
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug anlegen", "/verwaltung/werkzeuge", "", errMsg)
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := s.store.CreateWerkzeug(r.Context(), in); err != nil {
|
||||
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug anlegen", "/verwaltung/werkzeuge", "", "Werkzeug konnte nicht angelegt werden: "+err.Error())
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleMandantWerkzeugEditForm zeigt einen eigenen Katalogeintrag zum
|
||||
// Bearbeiten. Ein zentraler oder fremder Eintrag ist hier nicht
|
||||
// erreichbar (404) — das bleibt Sache der Plattform bzw. des jeweils
|
||||
// anderen Mandanten.
|
||||
func (s *Server) handleMandantWerkzeugEditForm(w http.ResponseWriter, r *http.Request) {
|
||||
wz, err := s.store.GetWerkzeug(r.Context(), r.PathValue("id"))
|
||||
accountID := currentUser(r).AccountID
|
||||
if err != nil || wz.AccountID == nil || *wz.AccountID != accountID {
|
||||
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
data := werkzeugFormData{
|
||||
Title: "Eigenes Werkzeug bearbeiten", Nav: navFor(r), ActionBase: "/verwaltung/werkzeuge", ID: wz.ID, Verarbeitungsorte: gueltigeVerarbeitungsorte,
|
||||
Name: wz.Name, Anbieter: wz.Anbieter, Verarbeitungsort: wz.Verarbeitungsort,
|
||||
AVVVerfuegbar: wz.AVVVerfuegbar, AVVURL: wz.AVVURL,
|
||||
TrainingOptOut: wz.TrainingOptOut, TrainingStandard: wz.TrainingStandard, AufbewahrungTage: wz.AufbewahrungTage,
|
||||
Zertifizierungen: strings.Join(wz.Zertifizierungen, ", "), GeeigneteZwecke: strings.Join(wz.GeeigneteZwecke, ", "),
|
||||
Einschraenkungen: strings.Join(wz.Einschraenkungen, ", "), LetztePruefung: wz.LetztePruefung.Format("2006-01-02"),
|
||||
Quelle: wz.Quelle,
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleMandantWerkzeugUpdate speichert Änderungen an einem eigenen
|
||||
// Katalogeintrag.
|
||||
func (s *Server) handleMandantWerkzeugUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
accountID := currentUser(r).AccountID
|
||||
wz, err := s.store.GetWerkzeug(r.Context(), id)
|
||||
if err != nil || wz.AccountID == nil || *wz.AccountID != accountID {
|
||||
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
in, errMsg := werkzeugInputFromForm(r, &accountID)
|
||||
if errMsg != "" {
|
||||
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug bearbeiten", "/verwaltung/werkzeuge", id, errMsg)
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := s.store.UpdateWerkzeug(r.Context(), id, in); err != nil {
|
||||
data := werkzeugFormDataFromRequest(r, "Eigenes Werkzeug bearbeiten", "/verwaltung/werkzeuge", id, "Werkzeug konnte nicht gespeichert werden: "+err.Error())
|
||||
if err := s.templates.ExecuteTemplate(w, "betreiber-werkzeug-form", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleMandantWerkzeugDelete entfernt einen eigenen Katalogeintrag.
|
||||
func (s *Server) handleMandantWerkzeugDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
accountID := currentUser(r).AccountID
|
||||
wz, err := s.store.GetWerkzeug(r.Context(), id)
|
||||
if err != nil || wz.AccountID == nil || *wz.AccountID != accountID {
|
||||
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteWerkzeug(r.Context(), id); err != nil {
|
||||
http.Error(w, "Werkzeug konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleMandantWerkzeugSperren sperrt einen zentralen Katalogeintrag
|
||||
// für den eigenen Mandanten — der zentrale Eintrag selbst bleibt
|
||||
// unverändert, er verschwindet nur aus ListWerkzeugeForAccount für
|
||||
// diesen einen Mandanten.
|
||||
func (s *Server) handleMandantWerkzeugSperren(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
grund := r.FormValue("grund")
|
||||
if _, err := s.store.CreateWerkzeugSperre(r.Context(), currentUser(r).AccountID, r.PathValue("id"), grund); err != nil {
|
||||
http.Error(w, "Werkzeug konnte nicht gesperrt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleMandantWerkzeugEntsperren(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.store.DeleteWerkzeugSperre(r.Context(), currentUser(r).AccountID, r.PathValue("id")); err != nil {
|
||||
http.Error(w, "Sperrung konnte nicht aufgehoben werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verwaltung/werkzeuge", http.StatusSeeOther)
|
||||
}
|
||||
66
internal/web/mandant_werkzeug_handlers_test.go
Normal file
66
internal/web/mandant_werkzeug_handlers_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func TestAdminCanSperrenUndEntsperrenZentralesWerkzeug(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
fs.werkzeuge["werkzeug-central"] = store.Werkzeug{ID: "werkzeug-central", Name: "ChatGPT Enterprise", Anbieter: "OpenAI"}
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
|
||||
list := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge")
|
||||
if !strings.Contains(list.Body.String(), "ChatGPT Enterprise") {
|
||||
t.Fatalf("expected the zentrale werkzeug in the list, got: %s", list.Body.String())
|
||||
}
|
||||
|
||||
resp := postForm(t, s, adminCookie, "/verwaltung/werkzeuge/werkzeug-central/sperren", url.Values{"grund": {"zu teuer"}})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("sperren status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
afterSperren := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge")
|
||||
if !strings.Contains(afterSperren.Body.String(), `<span class="status">gesperrt</span>`) {
|
||||
t.Errorf("expected the werkzeug to show as gesperrt, got: %s", afterSperren.Body.String())
|
||||
}
|
||||
|
||||
resp = postForm(t, s, adminCookie, "/verwaltung/werkzeuge/werkzeug-central/entsperren", url.Values{})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("entsperren status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
afterEntsperren := getWithCookie(t, s, adminCookie, "/verwaltung/werkzeuge")
|
||||
if strings.Contains(afterEntsperren.Body.String(), `<span class="status">gesperrt</span>`) {
|
||||
t.Errorf("expected the werkzeug to no longer show as gesperrt, got: %s", afterEntsperren.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSperreIstProMandantIsoliert(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
fs.werkzeuge["werkzeug-central"] = store.Werkzeug{ID: "werkzeug-central", Name: "ChatGPT Enterprise", Anbieter: "OpenAI"}
|
||||
adminA := seedAccountWithRole(t, fs, "Mandant A", "admin-a@example.com", "admin")
|
||||
adminB := seedAccountWithRole(t, fs, "Mandant B", "admin-b@example.com", "admin")
|
||||
|
||||
postForm(t, s, adminA, "/verwaltung/werkzeuge/werkzeug-central/sperren", url.Values{})
|
||||
|
||||
respB := getWithCookie(t, s, adminB, "/verwaltung/werkzeuge")
|
||||
if strings.Contains(respB.Body.String(), `<span class="status">gesperrt</span>`) {
|
||||
t.Errorf("expected Mandant B to see the werkzeug as unsperrt, got: %s", respB.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMitarbeiterCannotAccessWerkzeugverwaltung(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/werkzeuge")
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func (s *Server) authenticate(r *http.Request) (store.User, bool) {
|
||||
}
|
||||
|
||||
user, err := s.store.GetUser(r.Context(), sess.UserID)
|
||||
if err != nil {
|
||||
if err != nil || !user.Active {
|
||||
return store.User{}, false
|
||||
}
|
||||
return user, true
|
||||
@@ -68,6 +68,98 @@ func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// requireBetreiber schützt den Plattform-Bereich (Ebene 5 — "nur
|
||||
// Betreiber", technisch getrennt vom Mandantenbereich, siehe CLAUDE.md).
|
||||
// Ohne Sitzung geht es wie bei requirePage zu /login; mit einer
|
||||
// Sitzung, aber ohne Betreiber-Rolle, gibt es 404 statt 403 — sonst
|
||||
// würde eine 403 einem angemeldeten, aber unprivilegierten Nutzer
|
||||
// verraten, dass unter dieser URL überhaupt etwas existiert.
|
||||
func (s *Server) requireBetreiber(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 != "betreiber" {
|
||||
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
||||
}
|
||||
}
|
||||
|
||||
// requireFachebene schützt die Fachebene (Ebene 3 — Posteingang und
|
||||
// Entscheiden, Rollen "verantwortlicher" und "pruefer", siehe
|
||||
// CLAUDE.md). "admin" ist hier bewusst mit zugelassen: die Spezifikation
|
||||
// will, dass der erste Nutzer einer neuen Firma gleichzeitig admin UND
|
||||
// verantwortlicher ist ("Admin und KI-Verantwortlicher" — siehe CLAUDE.md,
|
||||
// Offene Punkte), app_user.role kennt aber nur einen Wert. Statt eines
|
||||
// Datenmodell-Umbaus (roles-Array oder zwei app_user-Zeilen pro Person)
|
||||
// bekommt admin hier pragmatisch dieselben Fachebene-Rechte wie
|
||||
// verantwortlicher (inkl. Entscheidungsrecht, siehe handleFallEntscheiden)
|
||||
// — ohne das könnte eine frisch registrierte Firma mit nur einem
|
||||
// admin-Login keinen einzigen eingereichten Antrag sehen oder
|
||||
// bearbeiten. pruefer bleibt unverändert nur lesend. 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" && user.Role != "admin" {
|
||||
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).
|
||||
// Eigenes, kleines Struct statt jeder Seite Zugriff auf den vollen
|
||||
// currentUser zu geben — die Navigation braucht nur, ob ein
|
||||
// Plattform-Link gezeigt werden soll.
|
||||
type navData struct {
|
||||
IsBetreiber bool
|
||||
IsFachebene bool
|
||||
IsAdmin bool
|
||||
}
|
||||
|
||||
// navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage.
|
||||
// Nur für Seiten hinter requirePage/requireBetreiber aufrufbar (braucht
|
||||
// currentUser).
|
||||
func navFor(r *http.Request) navData {
|
||||
role := currentUser(r).Role
|
||||
return navData{
|
||||
IsBetreiber: role == "betreiber",
|
||||
// admin sieht die Fachebene mit, siehe requireFachebene.
|
||||
IsFachebene: role == "verantwortlicher" || role == "pruefer" || role == "admin",
|
||||
IsAdmin: role == "admin",
|
||||
}
|
||||
}
|
||||
|
||||
// currentUser liest den Nutzer, den requirePage/requireAPI in den
|
||||
// Kontext gelegt haben. Panics, wenn es aufgerufen wird, ohne dass eine
|
||||
// dieser Middlewares vorgeschaltet war — das ist ein Programmierfehler,
|
||||
|
||||
102
internal/web/nutzer_aktivierung_test.go
Normal file
102
internal/web/nutzer_aktivierung_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAdminKannNutzerDeaktivierenUndReaktivieren(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
postForm(t, s, adminCookie, "/verwaltung/nutzer", url.Values{
|
||||
"email": {"weg@example.com"}, "password": {"ein-langes-passwort"}, "role": {"mitarbeiter"},
|
||||
})
|
||||
target, err := fs.GetUserByEmail(context.Background(), "weg@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
|
||||
resp := postForm(t, s, adminCookie, "/verwaltung/nutzer/"+target.ID+"/deaktivieren", url.Values{})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("deaktivieren status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
deaktiviert, err := fs.GetUser(context.Background(), target.ID)
|
||||
if err != nil || deaktiviert.Active {
|
||||
t.Fatalf("expected user to be inactive, got %+v (err %v)", deaktiviert, err)
|
||||
}
|
||||
|
||||
loginResp := postForm(t, s, nil, "/login", url.Values{"email": {"weg@example.com"}, "password": {"ein-langes-passwort"}})
|
||||
if !strings.Contains(loginResp.Body.String(), "deaktiviert") {
|
||||
t.Errorf("expected a deactivation message on login, got: %s", loginResp.Body.String())
|
||||
}
|
||||
|
||||
reaktivierenResp := postForm(t, s, adminCookie, "/verwaltung/nutzer/"+target.ID+"/aktivieren", url.Values{})
|
||||
if reaktivierenResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("aktivieren status = %d, body: %s", reaktivierenResp.Code, reaktivierenResp.Body.String())
|
||||
}
|
||||
reaktiviert, err := fs.GetUser(context.Background(), target.ID)
|
||||
if err != nil || !reaktiviert.Active {
|
||||
t.Fatalf("expected user to be active again, got %+v (err %v)", reaktiviert, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminKannSichNichtSelbstDeaktivieren(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
self, err := fs.GetUserByEmail(context.Background(), "admin@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
|
||||
resp := postForm(t, s, adminCookie, "/verwaltung/nutzer/"+self.ID+"/deaktivieren", url.Values{})
|
||||
if resp.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 for self-deactivation", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeaktivierterNutzerVerliertLaufendeSitzungSofort(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
|
||||
admin, err := fs.GetUserByEmail(context.Background(), "admin@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
mitarbeiterCookie := seedUserInAccount(t, fs, admin.AccountID, "mitarbeiter@example.com", "mitarbeiter")
|
||||
|
||||
if resp := getWithCookie(t, s, mitarbeiterCookie, "/antraege"); resp.Code != http.StatusOK {
|
||||
t.Fatalf("expected active session to work, status = %d", resp.Code)
|
||||
}
|
||||
|
||||
target, err := fs.GetUserByEmail(context.Background(), "mitarbeiter@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
postForm(t, s, adminCookie, "/verwaltung/nutzer/"+target.ID+"/deaktivieren", url.Values{})
|
||||
|
||||
resp := getWithCookie(t, s, mitarbeiterCookie, "/antraege")
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected the deactivated user's session to be rejected, status = %d", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCannotDeactivateForeignAccountUser(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
adminA := seedAccountWithRole(t, fs, "Mandant A", "admin-a@example.com", "admin")
|
||||
seedAccountWithRole(t, fs, "Mandant B", "admin-b@example.com", "admin")
|
||||
targetB, err := fs.GetUserByEmail(context.Background(), "admin-b@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByEmail: %v", err)
|
||||
}
|
||||
|
||||
resp := postForm(t, s, adminA, "/verwaltung/nutzer/"+targetB.ID+"/deaktivieren", url.Values{})
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for a foreign account's user", resp.Code)
|
||||
}
|
||||
}
|
||||
133
internal/web/register_handlers.go
Normal file
133
internal/web/register_handlers.go
Normal file
@@ -0,0 +1,133 @@
|
||||
// Ebene 3 — Register und Export (Schritt 6 der Baureihenfolge, siehe
|
||||
// CLAUDE.md). Jede Genehmigung erzeugt automatisch einen Registereintrag
|
||||
// (siehe handleFallEntscheiden); diese Seiten zeigen und exportieren
|
||||
// sie. Reuse des bereits vorhandenen (bisher ungenutzten) go-pdf/fpdf-
|
||||
// Dependency statt einer neuen Bibliothek für den PDF-Export.
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-pdf/fpdf"
|
||||
)
|
||||
|
||||
type registerEintragView struct {
|
||||
Zweck string
|
||||
Abteilung string
|
||||
Werkzeug string
|
||||
Datenklasse string
|
||||
Einstufung string
|
||||
Auflagen string
|
||||
Verantwortlicher string
|
||||
EntschiedenAm string
|
||||
GueltigBis string
|
||||
}
|
||||
|
||||
type registerListeData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Eintraege []registerEintragView
|
||||
}
|
||||
|
||||
func (s *Server) registerEintraegeFuerAccount(r *http.Request) ([]registerEintragView, error) {
|
||||
eintraege, err := s.store.ListRegistereintraegeForAccount(r.Context(), currentUser(r).AccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
views := make([]registerEintragView, len(eintraege))
|
||||
for i, e := range eintraege {
|
||||
gueltigBis := ""
|
||||
if e.GueltigBis != nil {
|
||||
gueltigBis = e.GueltigBis.Format("02.01.2006")
|
||||
}
|
||||
views[i] = registerEintragView{
|
||||
Zweck: e.Zweck, Abteilung: e.Abteilung, Werkzeug: e.Werkzeug,
|
||||
Datenklasse: e.Datenklasse, Einstufung: e.Einstufung, Auflagen: strings.Join(e.Auflagen, "; "),
|
||||
Verantwortlicher: e.Verantwortlicher, EntschiedenAm: e.EntschiedenAm.Format("02.01.2006 15:04"), GueltigBis: gueltigBis,
|
||||
}
|
||||
}
|
||||
return views, nil
|
||||
}
|
||||
|
||||
// handleRegisterListe zeigt das Register (alle Genehmigungen des
|
||||
// Mandanten) — Ebene 3, für verantwortlicher und pruefer gleichermaßen
|
||||
// lesbar.
|
||||
func (s *Server) handleRegisterListe(w http.ResponseWriter, r *http.Request) {
|
||||
views, err := s.registerEintraegeFuerAccount(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Register konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := registerListeData{Title: "Register", Nav: navFor(r), Eintraege: views}
|
||||
if err := s.templates.ExecuteTemplate(w, "register-liste", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
var registerCSVHeader = []string{"Zweck", "Abteilung", "Werkzeug", "Datenklasse", "Einstufung", "Auflagen", "Verantwortliche/r", "Entschieden am", "Gültig bis"}
|
||||
|
||||
func registerCSVRow(e registerEintragView) []string {
|
||||
return []string{e.Zweck, e.Abteilung, e.Werkzeug, e.Datenklasse, e.Einstufung, e.Auflagen, e.Verantwortlicher, e.EntschiedenAm, e.GueltigBis}
|
||||
}
|
||||
|
||||
// handleRegisterExportCSV exportiert das Register als CSV.
|
||||
func (s *Server) handleRegisterExportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
views, err := s.registerEintraegeFuerAccount(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Register konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="register.csv"`)
|
||||
cw := csv.NewWriter(w)
|
||||
_ = cw.Write(registerCSVHeader)
|
||||
for _, e := range views {
|
||||
_ = cw.Write(registerCSVRow(e))
|
||||
}
|
||||
cw.Flush()
|
||||
}
|
||||
|
||||
// handleRegisterExportPDF exportiert das Register als PDF — eine
|
||||
// einfache Tabelle, keine Gestaltung über den Design-System-Anspruch
|
||||
// des restlichen Produkts hinaus.
|
||||
func (s *Server) handleRegisterExportPDF(w http.ResponseWriter, r *http.Request) {
|
||||
views, err := s.registerEintraegeFuerAccount(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Register konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
pdf := fpdf.New("L", "mm", "A4", "")
|
||||
// cp1252 deckt deutsche Umlaute/ß ab — die eingebauten Helvetica-
|
||||
// Fonts von fpdf können sonst kein UTF-8 darstellen.
|
||||
tr := pdf.UnicodeTranslatorFromDescriptor("")
|
||||
pdf.AddPage()
|
||||
pdf.SetFont("Helvetica", "B", 14)
|
||||
pdf.CellFormat(0, 10, tr("Register"), "", 1, "L", false, 0, "")
|
||||
pdf.Ln(2)
|
||||
|
||||
colWidths := []float64{45, 25, 35, 30, 30, 55, 40, 30, 25}
|
||||
pdf.SetFont("Helvetica", "B", 8)
|
||||
for i, h := range registerCSVHeader {
|
||||
pdf.CellFormat(colWidths[i], 8, tr(h), "1", 0, "L", false, 0, "")
|
||||
}
|
||||
pdf.Ln(-1)
|
||||
|
||||
pdf.SetFont("Helvetica", "", 8)
|
||||
for _, e := range views {
|
||||
row := registerCSVRow(e)
|
||||
for i, cell := range row {
|
||||
pdf.CellFormat(colWidths[i], 8, tr(cell), "1", 0, "L", false, 0, "")
|
||||
}
|
||||
pdf.Ln(-1)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="register.pdf"`)
|
||||
if err := pdf.Output(w); err != nil {
|
||||
http.Error(w, fmt.Sprintf("PDF konnte nicht erzeugt werden: %v", err), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
142
internal/web/register_handlers_test.go
Normal file
142
internal/web/register_handlers_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
"github.com/netcell-it/deklarix/internal/web"
|
||||
)
|
||||
|
||||
// genehmigeFall reicht einen Antrag ein und genehmigt ihn mit einem
|
||||
// echten, zulässigen Werkzeug — Vorbereitung für Register-Tests, die
|
||||
// einen tatsächlichen Registereintrag brauchen.
|
||||
func genehmigeFall(t *testing.T, fs *fakeStore, s *web.Server) (accountID string) {
|
||||
t.Helper()
|
||||
acc, err := fs.CreateAccount(context.Background(), "Register-Mandant")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
fs.werkzeuge["werkzeug-ok"] = store.Werkzeug{ID: "werkzeug-ok", Name: "ChatGPT Enterprise", AVVVerfuegbar: true, TrainingStandard: true}
|
||||
mitarbeiterCookie := seedUserInAccount(t, fs, acc.ID, "mitarbeiter@example.com", "mitarbeiter")
|
||||
createResp := postForm(t, s, mitarbeiterCookie, "/antraege", fullAntragForm())
|
||||
if createResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("Antrag anlegen: status = %d, body: %s", createResp.Code, createResp.Body.String())
|
||||
}
|
||||
var antragID string
|
||||
for id := range fs.antraege {
|
||||
if fs.antraege[id].AccountID == acc.ID {
|
||||
antragID = id
|
||||
}
|
||||
}
|
||||
verantwortlicherCookie := seedUserInAccount(t, fs, acc.ID, "verantwortlicher@example.com", "verantwortlicher")
|
||||
entscheidenResp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
|
||||
"entscheidung": {"genehmigt"}, "werkzeug_id": {"werkzeug-ok"},
|
||||
})
|
||||
if entscheidenResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("Entscheiden: status = %d, body: %s", entscheidenResp.Code, entscheidenResp.Body.String())
|
||||
}
|
||||
return acc.ID
|
||||
}
|
||||
|
||||
func TestGenehmigungErzeugtRegistereintrag(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
accountID := genehmigeFall(t, fs, s)
|
||||
|
||||
list := fs.registereintraege[accountID]
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("registereintraege = %+v, want exactly one entry", list)
|
||||
}
|
||||
e := list[0]
|
||||
if e.Werkzeug != "ChatGPT Enterprise" || e.Zweck != "Angebotstexte generieren" || e.Datenklasse != "personenbezogen" {
|
||||
t.Errorf("Registereintrag = %+v, unerwartete Werte", e)
|
||||
}
|
||||
if e.Verantwortlicher != "verantwortlicher@example.com" {
|
||||
t.Errorf("Verantwortlicher = %q, want verantwortlicher@example.com", e.Verantwortlicher)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbgelehnteEntscheidungErzeugtKeinenRegistereintrag(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
antragID, verantwortlicherCookie := seedFallImAccount(t, fs, s, "verantwortlicher")
|
||||
|
||||
resp := postForm(t, s, verantwortlicherCookie, "/faelle/"+antragID+"/entscheiden", url.Values{
|
||||
"entscheidung": {"abgelehnt"}, "begruendung": {"Kein Werkzeug verfügbar"},
|
||||
})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
|
||||
for _, list := range fs.registereintraege {
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("expected no registereintrag for an abgelehnt Entscheidung, got %+v", list)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterListeZeigtGenehmigung(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
accountID := genehmigeFall(t, fs, s)
|
||||
verantwortlicherCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer")
|
||||
|
||||
resp := getWithCookie(t, s, verantwortlicherCookie, "/registereintraege")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "ChatGPT Enterprise") {
|
||||
t.Errorf("expected the genehmigtes werkzeug in the register, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterExportCSVEnthaeltEintrag(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
accountID := genehmigeFall(t, fs, s)
|
||||
verantwortlicherCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer")
|
||||
|
||||
resp := getWithCookie(t, s, verantwortlicherCookie, "/registereintraege/export.csv")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if ct := resp.Header().Get("Content-Type"); !strings.Contains(ct, "text/csv") {
|
||||
t.Errorf("Content-Type = %q, want text/csv", ct)
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "ChatGPT Enterprise") {
|
||||
t.Errorf("expected the werkzeug in the CSV export, got: %s", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterExportPDFReturnsPDF(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
accountID := genehmigeFall(t, fs, s)
|
||||
verantwortlicherCookie := seedUserInAccount(t, fs, accountID, "reviewer@example.com", "pruefer")
|
||||
|
||||
resp := getWithCookie(t, s, verantwortlicherCookie, "/registereintraege/export.pdf")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if ct := resp.Header().Get("Content-Type"); ct != "application/pdf" {
|
||||
t.Errorf("Content-Type = %q, want application/pdf", ct)
|
||||
}
|
||||
if !strings.HasPrefix(resp.Body.String(), "%PDF") {
|
||||
t.Error("expected the response body to start with the PDF magic bytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMitarbeiterCannotAccessRegister(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fs)
|
||||
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
|
||||
|
||||
resp := getWithCookie(t, s, cookie, "/registereintraege")
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for role mitarbeiter on /registereintraege", resp.Code)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Package web ist die HTTP-Schicht: Routing, Templates, Handler.
|
||||
// Bewusst html/template + htmx, kein Frontend-Build (siehe CLAUDE.md,
|
||||
// Stack). Jede Submission gehört einem Account (Mandant); Auth-Cookies
|
||||
// Stack). Jeder Antrag gehört einem Account (Mandant); Auth-Cookies
|
||||
// binden einen Request an einen angemeldeten Nutzer und damit an dessen
|
||||
// Account — siehe middleware.go.
|
||||
package web
|
||||
@@ -13,8 +13,6 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/evidence"
|
||||
"github.com/netcell-it/deklarix/internal/extract"
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
@@ -25,91 +23,142 @@ var templatesFS embed.FS
|
||||
//go:embed static/*
|
||||
var staticFS embed.FS
|
||||
|
||||
// Extractor ist die Schnittstelle, die der Server für Stufe 1 braucht.
|
||||
// *extract.Engine erfüllt sie (regelbasiert, kein externer Dienst);
|
||||
// Tests injizieren einen Fake, um Facts unabhängig von der echten
|
||||
// Erkennungslogik vorzugeben.
|
||||
type Extractor interface {
|
||||
Extract(ctx context.Context, in extract.Input) (extract.Result, error)
|
||||
ModelVersion() string
|
||||
}
|
||||
|
||||
// Store ist die Schnittstelle, die der Server zur Persistenz braucht —
|
||||
// bewusst schmal (nur was diese Handler tatsächlich nutzen), nicht der
|
||||
// komplette *store.Store. *store.Store erfüllt sie; Tests injizieren
|
||||
// einen Fake statt eine echte Datenbank zu brauchen.
|
||||
type Store interface {
|
||||
CreateSubmission(ctx context.Context, accountID, platform, postType, caption string) (store.Submission, error)
|
||||
GetSubmission(ctx context.Context, id string) (store.Submission, error)
|
||||
SetSubmissionStatus(ctx context.Context, id, status string) error
|
||||
CreateExtraction(ctx context.Context, submissionID string, payload []byte, modelVersion, promptVersion string) (store.Extraction, error)
|
||||
GetLatestExtraction(ctx context.Context, submissionID string) (store.Extraction, error)
|
||||
CreateFinding(ctx context.Context, submissionID string, extractionID *string, ruleID string, ruleVersion int, severity, title, fix string, sources []string) (store.Finding, error)
|
||||
ListCurrentFindings(ctx context.Context, submissionID string) ([]store.Finding, error)
|
||||
CreateEvidencePackage(ctx context.Context, submissionID, dossierPath, sha256Hex string, timestampToken []byte) (store.EvidencePackage, error)
|
||||
GetLatestEvidencePackage(ctx context.Context, submissionID string) (store.EvidencePackage, error)
|
||||
ListSubmissionsForAccount(ctx context.Context, accountID string) ([]store.SubmissionSummary, error)
|
||||
|
||||
CreateParticipant(ctx context.Context, submissionID, role, name string, vorgegeben, freigegeben bool) (store.Participant, error)
|
||||
ListParticipants(ctx context.Context, submissionID string) ([]store.Participant, error)
|
||||
GetParticipant(ctx context.Context, id string) (store.Participant, error)
|
||||
UpdateParticipant(ctx context.Context, id string, vorgegeben, freigegeben bool) (store.Participant, error)
|
||||
DeleteParticipant(ctx context.Context, id string) error
|
||||
|
||||
CreateAccount(ctx context.Context, name string) (store.Account, error)
|
||||
GetAccount(ctx context.Context, id string) (store.Account, error)
|
||||
GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error)
|
||||
RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error
|
||||
ListAccounts(ctx context.Context) ([]store.Account, error)
|
||||
CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error)
|
||||
GetUserByEmail(ctx context.Context, email string) (store.User, error)
|
||||
GetUser(ctx context.Context, id string) (store.User, error)
|
||||
ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error)
|
||||
SetUserActive(ctx context.Context, id string, active bool) error
|
||||
CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error)
|
||||
GetSession(ctx context.Context, token string) (store.Session, error)
|
||||
DeleteSession(ctx context.Context, token string) error
|
||||
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
|
||||
ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error)
|
||||
|
||||
ListAbteilungenForAccount(ctx context.Context, accountID string) ([]store.Abteilung, error)
|
||||
CreateAbteilung(ctx context.Context, accountID, name string) (store.Abteilung, error)
|
||||
DeleteAbteilung(ctx context.Context, id string) 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)
|
||||
ListZentraleWerkzeuge(ctx context.Context) ([]store.Werkzeug, error)
|
||||
CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID, grund string) (store.WerkzeugSperre, error)
|
||||
DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID string) error
|
||||
ListWerkzeugSperrenForAccount(ctx context.Context, accountID string) ([]store.WerkzeugSperre, error)
|
||||
GetWerkzeug(ctx context.Context, id string) (store.Werkzeug, error)
|
||||
CreateWerkzeug(ctx context.Context, in store.WerkzeugInput) (store.Werkzeug, error)
|
||||
UpdateWerkzeug(ctx context.Context, id string, in store.WerkzeugInput) (store.Werkzeug, error)
|
||||
DeleteWerkzeug(ctx context.Context, id string) 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)
|
||||
ListAktiveGenehmigungenForAccount(ctx context.Context, accountID string) ([]store.Entscheidung, error)
|
||||
|
||||
GetAbteilung(ctx context.Context, id string) (store.Abteilung, error)
|
||||
CreateRegistereintrag(ctx context.Context, in store.RegistereintragInput) (store.Registereintrag, error)
|
||||
ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]store.Registereintrag, error)
|
||||
}
|
||||
|
||||
// Regelwerk bündelt die drei geladenen Regelwerk-Dateien, die die
|
||||
// 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.
|
||||
type Server struct {
|
||||
mux *http.ServeMux
|
||||
extractor Extractor
|
||||
ruleSet []rules.Rule
|
||||
store Store
|
||||
timestamper evidence.Timestamper
|
||||
dossierDir string
|
||||
templates *template.Template
|
||||
mux *http.ServeMux
|
||||
store Store
|
||||
regelwerk Regelwerk
|
||||
templates *template.Template
|
||||
}
|
||||
|
||||
// NewServer erstellt den Server. ruleSet kommt von rules.Load und wird
|
||||
// einmal beim Start geladen, nicht pro Request. dossierDir ist das
|
||||
// Verzeichnis, in das erzeugte Nachweis-Dossiers geschrieben werden.
|
||||
func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper evidence.Timestamper, dossierDir string) (*Server, error) {
|
||||
// NewServer erstellt den Server. regelwerk kommt von rules.Load* und
|
||||
// wird einmal beim Start geladen, nicht pro Request.
|
||||
func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
|
||||
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("web: templates parsen: %w", err)
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
extractor: extractor,
|
||||
ruleSet: ruleSet,
|
||||
store: st,
|
||||
timestamper: timestamper,
|
||||
dossierDir: dossierDir,
|
||||
templates: tmpl,
|
||||
store: st,
|
||||
regelwerk: regelwerk,
|
||||
templates: tmpl,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /health", s.handleHealth)
|
||||
mux.HandleFunc("GET /register", s.handleRegisterForm)
|
||||
mux.HandleFunc("POST /register", s.handleRegister)
|
||||
mux.HandleFunc("GET /einladung/{token}", s.handleEinladungForm)
|
||||
mux.HandleFunc("POST /einladung/{token}", s.handleEinladungAnnehmen)
|
||||
mux.HandleFunc("GET /login", s.handleLoginForm)
|
||||
mux.HandleFunc("POST /login", s.handleLogin)
|
||||
mux.HandleFunc("POST /logout", s.handleLogout)
|
||||
mux.HandleFunc("GET /{$}", s.requirePage(s.handleIndex))
|
||||
mux.HandleFunc("POST /pruefen", s.requireAPI(s.handleCheck))
|
||||
mux.HandleFunc("POST /veroeffentlichen", s.requireAPI(s.handleArchive))
|
||||
mux.HandleFunc("GET /dossier/{id}", s.requireAPI(s.handleDossierDownload))
|
||||
mux.HandleFunc("GET /beitraege", s.requirePage(s.handleSubmissionList))
|
||||
mux.HandleFunc("GET /beitraege/{id}", s.requirePage(s.handleSubmissionDetail))
|
||||
mux.HandleFunc("POST /beitraege/{id}/beteiligte", s.requireAPI(s.handleAddParticipant))
|
||||
mux.HandleFunc("POST /beitraege/{id}/beteiligte/{pid}/aktualisieren", s.requireAPI(s.handleUpdateParticipant))
|
||||
mux.HandleFunc("POST /beitraege/{id}/beteiligte/{pid}/loeschen", s.requireAPI(s.handleDeleteParticipant))
|
||||
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 /registereintraege", s.requireFachebene(s.handleRegisterListe))
|
||||
mux.HandleFunc("GET /registereintraege/export.csv", s.requireFachebene(s.handleRegisterExportCSV))
|
||||
mux.HandleFunc("GET /registereintraege/export.pdf", s.requireFachebene(s.handleRegisterExportPDF))
|
||||
mux.HandleFunc("GET /wiedervorlage", s.requireFachebene(s.handleWiedervorlageListe))
|
||||
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("POST /verwaltung/nutzer/{id}/deaktivieren", s.requireAdmin(s.handleNutzerDeaktivieren))
|
||||
mux.HandleFunc("POST /verwaltung/nutzer/{id}/aktivieren", s.requireAdmin(s.handleNutzerAktivieren))
|
||||
mux.HandleFunc("GET /verwaltung/nutzer/import", s.requireAdmin(s.handleNutzerCSVImportForm))
|
||||
mux.HandleFunc("POST /verwaltung/nutzer/import", s.requireAdmin(s.handleNutzerCSVImportUpload))
|
||||
mux.HandleFunc("GET /verwaltung/einladung", s.requireAdmin(s.handleEinladungVerwaltung))
|
||||
mux.HandleFunc("POST /verwaltung/einladung/erneuern", s.requireAdmin(s.handleEinladungErneuern))
|
||||
mux.HandleFunc("GET /verwaltung/abteilungen", s.requireAdmin(s.handleAbteilungListe))
|
||||
mux.HandleFunc("POST /verwaltung/abteilungen", s.requireAdmin(s.handleAbteilungCreate))
|
||||
mux.HandleFunc("POST /verwaltung/abteilungen/{id}/loeschen", s.requireAdmin(s.handleAbteilungDelete))
|
||||
mux.HandleFunc("GET /verwaltung/werkzeuge", s.requireAdmin(s.handleMandantWerkzeugListe))
|
||||
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/sperren", s.requireAdmin(s.handleMandantWerkzeugSperren))
|
||||
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/entsperren", s.requireAdmin(s.handleMandantWerkzeugEntsperren))
|
||||
mux.HandleFunc("GET /verwaltung/werkzeuge/neu", s.requireAdmin(s.handleMandantWerkzeugNeuForm))
|
||||
mux.HandleFunc("POST /verwaltung/werkzeuge", s.requireAdmin(s.handleMandantWerkzeugCreate))
|
||||
mux.HandleFunc("GET /verwaltung/werkzeuge/{id}", s.requireAdmin(s.handleMandantWerkzeugEditForm))
|
||||
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}", s.requireAdmin(s.handleMandantWerkzeugUpdate))
|
||||
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/loeschen", s.requireAdmin(s.handleMandantWerkzeugDelete))
|
||||
mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard))
|
||||
mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList))
|
||||
mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail))
|
||||
mux.HandleFunc("GET /betreiber/audit-log", s.requireBetreiber(s.handleBetreiberAuditLog))
|
||||
mux.HandleFunc("GET /betreiber/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugListe))
|
||||
mux.HandleFunc("GET /betreiber/werkzeuge/neu", s.requireBetreiber(s.handleBetreiberWerkzeugNeuForm))
|
||||
mux.HandleFunc("POST /betreiber/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugCreate))
|
||||
mux.HandleFunc("GET /betreiber/werkzeuge/{id}", s.requireBetreiber(s.handleBetreiberWerkzeugEditForm))
|
||||
mux.HandleFunc("POST /betreiber/werkzeuge/{id}", s.requireBetreiber(s.handleBetreiberWerkzeugUpdate))
|
||||
mux.HandleFunc("POST /betreiber/werkzeuge/{id}/loeschen", s.requireBetreiber(s.handleBetreiberWerkzeugDelete))
|
||||
mux.Handle("GET /static/", http.FileServerFS(staticFS))
|
||||
s.mux = mux
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user