Compare commits

...

24 Commits

Author SHA1 Message Date
noroot
eb99891e38 feat: eigene Werkzeugkatalog-Einträge für Mandanten
Ein Mandant kann jetzt zusätzlich zum Sperren zentraler Katalogeinträge
auch eigene, nur für sich sichtbare Werkzeuge anlegen/bearbeiten/
löschen (GET/POST /verwaltung/werkzeuge/neu bzw. /{id}, POST
/{id}/loeschen — werkzeug.account_id = eigener Account). Nutzt
dasselbe Formular wie der zentrale Katalog des Betreibers
(werkzeugFormData/betreiber-werkzeug-form.html, ein neues ActionBase-
Feld unterscheidet die Ziel-URL); ein zentraler oder fremder Eintrag
bleibt über diese Route unerreichbar (404). Schließt die letzte
dokumentierte Lücke bei "eigene Werkzeug-Freigaben/-Sperrungen" (Ebene 4).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 15:23:43 +02:00
noroot
36af1bf288 feat: responsives Frontend — Seitenleiste ab Desktop, Tabellen-Styling
Nav wird jetzt für alle Seiten aus einer einzigen Stelle
(layout.html-Block) gerendert: auf dem Handy ein per Checkbox-Hack
aufklappbares Menü (kein JavaScript), ab 960px Breite per CSS in eine
permanente, dunkle Seitenleiste im enconf-Look verwandelt — dieselbe
Markup-Struktur bedient beide Layouts, kein separates Desktop-Template.
<details>/<summary> wurde bewusst NICHT verwendet: aktuelle
Chromium-Versionen steuern dessen Auf-/Zuklapp-Zustand über eine
CSS-resistente interne Animation (auch mit !important nicht
überschreibbar), per Headless-Screenshot verifiziert.

Zusätzlich: Tabellen (vorher komplett ungestylt, betraf v. a. das
Register) haben jetzt echte Gestaltung inkl. horizontal scrollbarem
Wrapper für schmale Bildschirme, Listenkarten ohne Link-Wrapper
(Nutzer-/Abteilungsliste) bekommen dieselbe Kartenoptik wie
verlinkte Einträge, und destruktive Aktionen (Löschen/Deaktivieren)
sind jetzt visuell von primären Aktionen unterschieden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 15:06:14 +02:00
noroot
5f6502bb41 feat: Wiedervorlage und Katalog-Benachrichtigung (Schritt 7 der Baureihenfolge)
GET /wiedervorlage (Ebene 3) zeigt aktive Genehmigungen, die erneut
geprüft werden sollten: Ablaufdatum erreicht oder innerhalb von 30
Tagen, das zugesagte Werkzeug wurde aus dem Katalog entfernt, oder der
aktuelle Katalogeintrag weicht bei AVV/Training-Standard/
Verarbeitungsort vom zum Entscheidungszeitpunkt eingefrorenen Snapshot
ab. Keine E-Mail-Infrastruktur vorhanden — Benachrichtigung ist bewusst
eine In-App-Liste. Damit ist die ursprüngliche Baureihenfolge
(Schritt 1-7) vollständig umgesetzt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 14:12:44 +02:00
noroot
22f748d66c feat: Ebene 4 vollständig steuerbar machen — Abteilungen, Werkzeug-Sperrungen, Nutzer-Deaktivierung
Deklarix soll ein buchbarer Service werden — dafür muss jede Entität im
Datenmodell über das Frontend steuerbar sein, nicht nur einsehbar.
Schließt drei konkrete Lücken:

- Abteilungen (GET /verwaltung/abteilungen, anlegen/löschen) — ohne
  diese Seite blieb die Abteilung-Auswahl im Antrag-Fragebogen leer
  und unbenutzbar, das war ein Funktionsdefizit, kein Komfortfehler.
- Eigene Werkzeug-Sperrungen (GET /verwaltung/werkzeuge) — ein Mandant
  kann einen zentralen Katalogeintrag jetzt für sich sperren/entsperren,
  ohne den zentralen Katalog selbst zu verändern.
- Nutzer-Deaktivierung (POST /verwaltung/nutzer/{id}/deaktivieren bzw.
  .../aktivieren, neue Spalte app_user.active, Migration 0012). Nutzer
  werden nicht gelöscht (Fremdschlüssel auf antrag/entscheidung/
  audit_log würden das verhindern und die Historie zerstören) —
  deaktivierte Logins können sich nicht mehr anmelden und verlieren
  eine laufende Sitzung sofort. Ein Admin kann sich nicht selbst
  deaktivieren.

Zusätzlich: store.ListAktiveGenehmigungenForAccount als Grundlage für
die Wiedervorlage (Schritt 7, Web-Layer folgt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 14:07:47 +02:00
noroot
d52b325424 feat: Registereintrag und Export (Schritt 6 der Baureihenfolge)
Jede Genehmigung (genehmigt/genehmigt_mit_auflagen) erzeugt jetzt
automatisch einen append-only registereintrag mit Zweck, Abteilung,
Werkzeug, Datenklasse, Einstufung, Auflagen, Verantwortliche/r und
Gültigkeit. Ebene 3 (verantwortlicher/pruefer) kann das Register unter
GET /registereintraege einsehen und als CSV oder PDF exportieren. Der
PDF-Export nutzt das bereits vorhandene, bisher ungenutzte go-pdf/fpdf
(keine neue Abhängigkeit) mit UnicodeTranslatorFromDescriptor für
deutsche Umlaute. Abgelehnte Entscheidungen und Rückfragen erzeugen
bewusst keinen Registereintrag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 12:55:24 +02:00
noroot
cff8c9eadf feat: Werkzeugkatalog-Pflege für die Plattform (Ebene 5)
Betreiber können den zentralen Werkzeugkatalog jetzt über die UI
pflegen (GET /betreiber/werkzeuge, GET/POST .../neu, GET/POST
.../{id}, POST .../{id}/loeschen) statt nur per SQL. Bearbeitet
ausschließlich zentrale (account_id IS NULL) Einträge — ein
mandantenspezifischer Katalogeintrag bleibt über diese Seiten
unerreichbar (404), das ist weiterhin Sache des jeweiligen Mandanten.
Ohne befüllten Katalog konnte bisher keine Bewertung tatsächlich zu
"genehmigt" mit einem echten Werkzeug führen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 12:42:48 +02:00
noroot
aad517ce23 feat: Nutzerverwaltung für Ebene 4 (Mandanten-Verwaltung)
Ein admin kann jetzt weitere Logins im eigenen Mandanten anlegen
(GET /verwaltung/nutzer, GET/POST /verwaltung/nutzer/neu) mit einer
der vier Mandanten-Rollen — betreiber bleibt Ebene 5 vorbehalten und
kann von keinem Mandanten-Admin vergeben werden. Schließt die Lücke,
dass die Fachebene (verantwortlicher/pruefer) bisher nur per
manuellem SQL-Insert erreichbar war, weil die Firma-Registrierung
ausschließlich einen admin-Nutzer erzeugt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 12:34:04 +02:00
noroot
d1e80dd19f feat: Posteingang und Entscheiden für die Fachebene (Schritt 5 der Baureihenfolge)
Ebene 3 (Rollen verantwortlicher/pruefer) bekommt GET /faelle
(Posteingang aller offenen Anträge des Mandanten) und GET/POST
/faelle/{id} zum Entscheiden. Eine Entscheidung friert Regelwerk-,
Katalog- und den vollständigen Werkzeugdatensatz ein, verlangt eine
Begründung bei Abweichung vom abgeleiteten Vorschlag, erlaubt bei
Genehmigung nur ein durch die Bewertung zulässiges Werkzeug und
protokolliert die Entscheidung im Audit-Log. entscheidung ist
append-only wie bewertung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 12:26:51 +02:00
noroot
e968cf9761 feat: Ergebnisdarstellung mit Herleitung (Schritt 4 der Baureihenfolge)
POST /antraege berechnet und speichert direkt beim Einreichen eine
append-only bewertung (Datenklasse, KI-VO-Einstufung, Anforderungen,
zulässige/ausgeschlossene Werkzeuge), GET /antraege/{id} zeigt sie
inklusive Herleitung und dem Pflicht-Hinweis, dass das System nicht
entscheidet, sondern nur vorbereitet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 10:22:24 +02:00
noroot
b1b121bb23 feat: Ableitungen und harte Filter (Schritt 3 der Baureihenfolge)
Reine Auswertungslogik in internal/rules, operiert auf rules.Antworten
(geparst aus antrag.antworten) und den drei Regelwerken aus Schritt 1
— ohne DB-/Web-Zugriff, vollständig isoliert testbar.

- EvaluateDatenklasse: "höchste zutreffende Stufe gewinnt" (höherer
  Rang). Trifft keine Stufe zu (z. B. B7 fälschlich "nein" trotz keiner
  anderen Kategorie), wird konservativ "intern" angenommen statt
  "oeffentlich" — im Zweifel mehr Schutz. Nicht fachlich bestätigt,
  siehe rules/OPEN.md Punkt 5.
- EvaluateEinstufung: Prüfreihenfolge wie im Regelwerk (verboten zuerst
  = K.-o.-Prüfung), erste zutreffende Stufe/Variante gewinnt.
  IstVerboten prüft die K.-o.-Bedingung direkt.
- DeriveAnforderungen: Anforderungsprofil aus Datenklasse+Einstufung.
- FilterWerkzeuge/ErfuelltAnforderung: harter Filter gegen einen
  Werkzeugkatalog. WerkzeugEigenschaften ist ein eigener, schlanker Typ
  statt store.Werkzeug — internal/rules bleibt unabhängig von
  internal/store. Nur technische Werkzeug-Eigenschaften
  (avv_erforderlich, eu_verarbeitung, kein_training_auf_eingabe) werden
  hart gefiltert; Prozess-Anforderungen (menschliche_aufsicht,
  kennzeichnungspflicht, dsfa_erforderlich) sortieren kein Werkzeug
  aus, sondern werden später als Auflage vermerkt — Annahme, siehe
  rules/OPEN.md Punkt 6.

rules/OPEN.md um Punkt 4 (Auswirkung der fehlenden Löschfristen auf den
Filter), Punkt 5 (Datenklasse-Fallback) und Punkt 6 (welche
Anforderungen hart filtern) ergänzt — Annahmen dokumentiert statt
geraten, damit Schritt 3 nicht auf die fachliche Klärung warten musste.

16 neue Tests gegen die ECHTEN rules/*.yaml-Dateien (nicht nur
synthetische Fixtures) — deckt Rang-Konflikte, "Unsicher zählt wie Ja",
alle vier Einstufungsstufen inkl. Auffangregel, Anforderungs-Ableitung
und Werkzeug-Filterung ab. Noch nicht ans Web angebunden (Schritt 4).
2026-08-29 09:39:41 +02:00
noroot
db9c5b7482 feat: Fragebogen mit adaptiver Logik (Schritt 2 der Baureihenfolge)
Ebene 2 ("Antrag stellen") als einseitiges Formular statt mehrseitigem
Assistenten — jede zusätzliche Seite kostet Zeit, und laut Spezifikation
wird ein Antrag umgangen, wenn er länger als fünf Minuten dauert.

- GET /antraege/neu: Fragebogen-Formular (Abschnitte A-D exakt nach
  Spezifikation). Adaptive Folgefragen (C2: welche Art von Entscheidung,
  C3: welche Art von Erkennung) werden rein per CSS :has() ein-/
  ausgeblendet, kein JavaScript nötig — visuell mit Chromium-Screenshots
  verifiziert (unchecked vs. checked).
- POST /antraege: legt an und reicht direkt ein (entwurf->eingereicht in
  einem Schritt, kein Zwischenspeichern als Entwurf für v1). Die
  antworten-JSON nutzt exakt dieselben Fakten-Schlüssel wie
  rules/*.yaml (b1-b7, c1-c5, c2_folge, c3_art) — Schritt 3 kann sie
  direkt auswerten, ohne Felder umzubenennen. "Unsicher" wird bewusst
  NICHT zu "ja" normalisiert (das ist eine Auswertungsregel für Schritt
  3, keine Speicherregel) — der Antrag hält fest, was der Mitarbeiter
  tatsächlich geantwortet hat.
- GET /antraege: eigene Anträge (Ebene 2 sieht nur eigene, nicht die
  des ganzen Mandanten — dafür neue Store-Methode
  ListAntraegeForUser, getrennt von ListAntraegeForAccount für den
  späteren Ebene-3-Posteingang).
- GET /antraege/{id}: Detail, fremder Antrag liefert 404 (nicht 403,
  gleiches Muster wie überall sonst im Projekt).
- betreiber-Rolle wird von allen Antrags-Routen weggeleitet (Ebene 5
  ist technisch getrennt vom Mandantenbereich).

rules/OPEN.md Punkt 3 (fehlende Fragebogen-Unterscheidung für die
"verboten"-Varianten) damit geklärt: c3_art existiert jetzt mit den
bereits in rules/kivo_einstufung.yaml erwarteten Werten.

Volle Testsuite inkl. echter Postgres-Tests grün; End-to-End gegen
einen laufenden Server verifiziert (Antrag anlegen, antworten-JSON in
der DB korrekt, Detail-/Listenansicht, Mandantentrennung).
2026-08-28 23:09:24 +02:00
noroot
344a33805a fix: Migration 0008 auf Bestandsdaten mit alten Rollenwerten vorbereiten
Beim ersten Deploy-Versuch auf dem Testsystem schlug die neue
app_user_role_check-Constraint fehl, weil echte Bestandszeilen noch
alte Rollenwerte (admin, creator) trugen — die Migration ging naiv
davon aus, dass es keine bestehenden Nutzer mit alten Rollen gäbe.

Fix: alte Constraint zuerst droppen (sie kennt neue Werte wie
"betreiber" noch nicht und hätte die Datenmigration selbst blockiert),
dann bestehende Zeilen umschreiben (admin -> betreiber, da das die
alte Plattform-Betreiber-Rolle war, nicht der neue mandantenbezogene
admin; creator/agentur/marke/kanzlei -> mitarbeiter), erst danach die
neue Constraint scharf schalten. Lokal gegen eine Datenbank mit
manuell eingefügten Alt-Rollen-Zeilen verifiziert (0001-0007 anwenden,
Testdaten einfügen, 0008 anwenden, Rollen prüfen).
2026-08-28 21:43:04 +02:00
noroot
b4d4ee8d3c feat!: Produktwechsel zu KI-Antragsprüfung — Phase 1 (Datenmodell, Regelwerk, Katalog)
Deklarix war eine Pre-Publish-Kennzeichnungsprüfung für Werbe-Content
(UWG/MStV). Dieser Scope wird komplett verworfen und durch eine
KI-Antragsprüfung ersetzt: Mitarbeitende beschreiben ein KI-Vorhaben,
das System leitet Datenklasse und KI-VO-Einstufung ab, gleicht sie
gegen einen Werkzeugkatalog ab und erzeugt einen Entscheidungsvorschlag
mit Herleitung — ein Mensch entscheidet, das System bereitet nur vor.

BREAKING CHANGE: Migration 0008 droppt alle werberechtsspezifischen
Tabellen (submission, finding, extraction, evidence_package,
participant, platform_connection, asset). account/app_user/session/
audit_log bleiben (Mandantentrennung, Login, Protokollierung sind
produktunabhängig) — app_user.role wechselt von
creator/agentur/marke/kanzlei/admin zu den fünf neuen Rollen
mitarbeiter/verantwortlicher/pruefer/admin/betreiber (vier
Mandanten-Rollen + eine plattformweite, siehe CLAUDE.md).

Entfernt: internal/extract, internal/dossier, internal/evidence,
internal/socialconnect, alte rules/*.yaml (UWG-Regeln), testdata/golden
— alles ausschließlich für das alte Produkt.

Neu, Phase 1 der Baureihenfolge ("Datenmodell, Regelwerk als YAML,
Katalogstruktur"):
- Store: abteilung (Stammdaten), werkzeug + werkzeug_sperre (der
  eigentliche Wert des Produkts — zentral gepflegter Katalog mit
  mandantenspezifischen Ergänzungen/Sperrungen, Pflichtfelder
  letzte_pruefung/quelle für jede Zusicherung), antrag (Fragebogen-
  Grundgerüst, Antworten als JSONB für den adaptiven Fragebogen aus
  Phase 2).
- internal/rules komplett neu: lädt und validiert drei YAML-
  Regelwerke (Datenklasse-Ableitung, KI-VO-Einstufung, Anforderungs-
  profil) aus rules/*.yaml — noch ohne Auswertungslogik gegen echte
  Fragebogen-Antworten (das ist Phase 3, bewusst erst nach dem
  Fragebogen aus Phase 2, der die exakten Fakten-Feldnamen festlegt).
  Offene fachliche Annahmen (Rangfolge der Datenklassen, Fragebogen-
  Lücke für die "verboten"-Varianten) explizit in rules/OPEN.md
  dokumentiert statt geraten.
- Web-Layer auf Minimalgerüst reduziert, das kompiliert und die neue
  Rollenwelt trägt: Firma-Registrierung (Ebene 1, erster Nutzer wird
  admin), Login/Logout, Plattform-Bereich (Ebene 5, nur betreiber:
  Dashboard, Accounts-Übersicht, Audit-Log) — Fragebogen (Ebene 2) und
  Fachebene (Ebene 3) folgen in den nächsten Phasen.
- CLAUDE.md komplett neu geschrieben: Produktbeschreibung, Fünf-Ebenen-
  Rollenmodell, Fragebogen-Spezifikation, Ableitungstabellen,
  Werkzeugkatalog, Bewertungslogik (geplant), Onboarding, offene
  Punkte (u. a. Postgres-RLS-Frage aus der Frontend-Spezifikation
  noch nicht entschieden, "Admin und Verantwortlicher gleichzeitig"
  beim Onboarding noch nicht datenmodelliert).

Volle Testsuite inkl. echter Postgres-Tests grün. End-to-End gegen
einen laufenden Server verifiziert: Firma-Registrierung legt Account +
admin-Nutzer an, Betreiber-Login leitet zu /betreiber, mandanten-
übergreifende Accounts-Liste sichtbar für betreiber, 404 für
mitarbeiter auf /betreiber, 303 zu /login ohne Sitzung.
2026-08-28 21:39:04 +02:00
noroot
6fd7831784 feat: Erinnerung zum Sichern von Story-Insights vor Ablauf
Ausgangspunkt: Instagram hält Story-Insights nach eigener Aussage nur
24 Stunden vor, auch der offizielle Datenexport enthält sie nicht mehr
danach. Der Standbild-Screenshot beim Prüfen entsteht direkt beim
Veröffentlichen, bevor nennenswerte Kennzahlen existieren — er kann
das strukturell nicht auffangen. Eine OAuth-Anbindung allein löst das
auch nicht: selbst mit API-Zugriff bräuchte es einen Abruf innerhalb
desselben 24h-Fensters.

- Migration 0007: asset.purpose ('initial' | 'insights', Default
  'initial' erhält die Bedeutung aller Bestandszeilen). Ein Beitrag
  kann jetzt mehrere Insights-Nachweise über die Zeit bekommen.
  GetLatestAssetForSubmission berücksichtigt weiterhin nur 'initial',
  damit ein späterer Insights-Upload nie den beim Archivieren
  referenzierten Original-Screenshot verdrängt.
- internal/web/insights_reminder.go: computeInsightsReminder — reine,
  ungetestete gegen echte Instagram-Daten, aber isoliert testbare
  Logik fürs Erinnerungs-Timing (Produktentscheidung, keine Rechtsnorm,
  daher nicht in rules/*.yaml).
- GET /beitraege/{id} zeigt die Erinnerung bei veröffentlichten
  "story"-Beiträgen ohne existierendes insights-Asset; POST
  /beitraege/{id}/insights speichert einen weiteren Screenshot (gleiche
  Validierung wie das initiale Standbild, wiederverwendet über
  readUploadedAsset/storeAsset mit purpose-Parameter).
- Bewusst nur In-App-Banner in dieser Ausbaustufe, kein Mail-/Push-
  Versand — dafür fehlt aktuell ein SMTP-Relay/Versanddienst, siehe
  CLAUDE.md-Hinweis dazu.

Volle Testsuite inkl. echter Postgres-Tests grün; End-to-End gegen
einen laufenden Server verifiziert (Story archivieren → Erinnerung
sichtbar → Insights-Upload → Erinnerung verschwindet, Nachweis
gelistet, Mandantentrennung beim Upload durchgesetzt).
2026-08-28 10:10:44 +02:00
noroot
5813e6209c feat: technisches Grundgerüst für Instagram-/TikTok-OAuth (Plattform-Verbindung)
Vorbereitung für automatische Beweissicherung statt manuellem
Screenshot-Upload: ein Kunde kann künftig seinen eigenen Instagram-
oder TikTok-Account per Standard-OAuth-Consent verbinden. Bewusst nur
das Grundgerüst — Meta/TikTok verlangen vor öffentlicher Nutzung eine
einmalige Business-Verification/App-Review (Wochen Vorlauf, siehe
CLAUDE.md-Abschnitt "Plattform-Verbindung (OAuth)"), die separat von
dieser Codeänderung läuft.

- internal/socialconnect: Connector-Interface + InstagramConnector/
  TikTokConnector (reiner Authorization-Code-Flow, kein DB-Zugriff).
  Instagram tauscht den Code zweistufig (kurzlebiges → 60-Tage-Token),
  TikTok liefert Access-/Refresh-Token direkt. Endpunkte/Scopes wurden
  gegen aktuelle Entwicklerdokumentation gebaut, nie gegen die echte
  API verifiziert (keine Zugangsdaten vorhanden) — Hinweis dazu im
  Paket- und CLAUDE.md-Kommentar.
- Migration 0006: platform_connection (NICHT append-only, anders als
  finding/extraction/asset — ein Token wird ersetzt, keine Korrektur-
  Zeile), höchstens eine Verbindung pro Account+Plattform.
- internal/web: GET /verbindungen (Übersicht je Plattform: verbunden/
  nicht verbunden/nicht konfiguriert), GET /oauth/{platform}/start
  (State-Cookie gegen CSRF, Redirect zum Consent-Screen),
  GET /oauth/{platform}/callback (State prüfen, Code tauschen,
  Verbindung speichern), POST /verbindungen/{platform}/trennen.
- Ohne gesetzte Client-Credentials + PUBLIC_BASE_URL bleibt die
  Funktion inaktiv (kein Connector konfiguriert, /verbindungen zeigt
  "nicht konfiguriert", kein Absturz) — main.go loggt das beim Start.

Volle Testsuite inkl. echter Postgres-Tests grün; OAuth-Flow gegen
Fake-Connector/httptest-Server verifiziert (State-Mismatch, Ablehnung
durch Nutzer, Token-Speicherung, Mandantentrennung). Kein Live-Test
gegen echte Meta-/TikTok-Endpunkte möglich, da noch keine echten
Client-Credentials existieren.
2026-08-28 09:32:43 +02:00
noroot
790ab20651 feat: Standbild-Upload bei der Pre-Publish-Prüfung
CLAUDE.md beschreibt die Prüfung seit dem ersten Commit als "Caption,
Standbild und Vertragslage rein" — bisher wurde nur die Caption
verarbeitet, das asset-Schema aus Migration 0001 blieb ungenutzt.

- internal/store/asset.go: CreateAsset/GetLatestAssetForSubmission.
  Migration 0005 macht asset append-only (Trigger fehlte seit 0001,
  weil bis jetzt nichts hineinschrieb) — ein hochgeladenes Beweisstück
  wird nicht nachträglich ausgetauscht, aus demselben Grund wie bei
  extraction/finding/evidence_package.
- handleCheck liest ein optionales "standbild"-Formularfeld (Bild-
  Upload, max. 8 MiB, Content-Type muss image/* sein), validiert es
  VOR dem Anlegen der Submission (ein ungültiger Upload hinterlässt so
  keine leere Beitrags-Zeile), speichert es danach unter ASSET_DIR und
  legt die Asset-Zeile an.
- handleArchive bindet den Asset-Hash (falls vorhanden) in den
  Metadaten-Hash und ins PDF-Dossier ein (dossier.Data.AssetHash war
  bereits vorbereitet, wurde aber nie befüllt).
- index.html: Formular auf multipart/form-data umgestellt
  (hx-encoding + enctype), neues optionales Dateifeld. handleCheck
  bleibt abwärtskompatibel zu urlencoded-Requests (ParseMultipartForm
  liefert ErrNotMultipart, das wird wie "kein Bild hochgeladen"
  behandelt, nicht wie ein Fehler).
- ASSET_DIR neue Konfigurationsvariable (Default "assets", wie
  DOSSIER_DIR relativ zu WorkingDirectory=/var/lib/deklarix — kein
  postinst-Healing nötig, anders als bei RULES_DIR, dessen Default
  nicht zum installierten Pfad passt).

Volle Testsuite inkl. echter Postgres-Tests grün; End-to-End gegen
einen laufenden Server verifiziert (Upload, Hash in DB, Hash im
erzeugten PDF via pdftotext, Ablehnung bei falschem Dateityp).
2026-08-27 21:45:03 +02:00
noroot
ae59e745c0 fix: Admin nach Login direkt zum Admin-Bereich leiten
Ein Admin-Login hat kein eigenes Produkt zu prüfen — die Pre-Publish-
Prüfung ("/") ist die Startseite für Mandanten (Creator/Agentur/Marke/
Kanzlei). Nutzer hat nach dem Login zu Recht gefragt, wieso ein Admin
dieses Formular sieht. Login leitet role=admin jetzt direkt zu /admin,
alle anderen Rollen weiterhin zu "/".
2026-08-27 19:06:05 +02:00
noroot
8e5d06aafb fix: Admin-Link in der Navigation zeigen
Nach dem Login landet jeder Nutzer (auch ein Admin, jeder Login gehört
zu einem Account) auf der normalen Startseite — ohne einen Link zu
/admin in der Navigation war der Admin-Bereich für einen Admin ohne
die URL im Kopf praktisch unerreichbar (genau das hat der Nutzer nach
dem Login gemeldet).

navData{IsAdmin} wird jetzt von jeder angemeldeten Seite (Start,
Beiträge, Beitrag-Detail, alle Admin-Seiten) an den gemeinsamen
"nav"-Template-Block durchgereicht; der Link erscheint nur für
role=admin. Test deckt beide Fälle ab (Admin sieht den Link, Mandant
nicht).
2026-08-27 19:04:04 +02:00
noroot
835ad9f0a7 feat: Admin-Bereich (Accounts, Kanzlei-Verzeichnis-Freigabe, Audit-Log)
Bislang gab es keine vom Nutzer-Rollenmodell (creator/agentur/marke/
kanzlei) getrennte Betreiber-Rolle — jede Verwaltungsaufgabe (welche
Kanzlei darf im öffentlichen Verzeichnis stehen, wer sind unsere
Accounts) wäre nur per Hand in der Datenbank möglich gewesen. Admin
ist von Anfang an als fünfte app_user-Rolle im Datenmodell verankert,
nicht nachträglich aufgesetzt.

Migration 0004:
- app_user.role erlaubt zusätzlich 'admin' (kein Self-Service-Weg
  dorthin — /register bietet die Rolle nicht an, erster Admin wird
  einmalig per SQL angelegt, siehe CLAUDE.md).
- account.verified: Freigabe fürs kostenlose Kanzlei-Verzeichnis
  (§ 49b Abs. 3 BRAO: reine Auflistung, kein Routing/keine Vermittlung).
- audit_log: append-only-Protokoll jeder Admin-Aktion (gleicher Trigger
  wie finding/extraction/evidence_package).

Neue Routen:
- GET /admin, /admin/accounts, /admin/accounts/{id}: Accounts-Übersicht
  und -Detail (Logins je Account), requireAdmin (404 statt 403 für
  angemeldete Nicht-Admins, wie beim bestehenden Mandanten-404-Muster).
- POST /admin/accounts/{id}/verifizieren: Kanzlei-Freigabe umschalten,
  schreibt einen Audit-Log-Eintrag.
- GET /admin/audit-log: Protokoll ansehen.
- GET /kanzleien: öffentliches Verzeichnis (kein Login), zeigt nur
  Accounts, die sowohl verified sind als auch einen Nutzer der Rolle
  "kanzlei" haben.

Volle Testsuite inkl. echter Postgres-Tests grün; End-to-End manuell
gegen einen laufenden Server verifiziert (Admin-Login, Verify-Toggle,
Erscheinen im öffentlichen Verzeichnis, Audit-Log-Eintrag, 404 für
Nicht-Admin-Zugriff).
2026-08-27 18:17:45 +02:00
noroot
c9d71b0d54 feat: Archiv-Übersicht und Beteiligten-CRUD im Web-Layer
Bisher gab es nur Prüfen -> Archivieren, keine Möglichkeit, bereits
geprüfte Beiträge wieder anzusehen oder die Verantwortungsmatrix
(Beteiligte: wer hat vorgegeben, wer freigegeben) tatsächlich zu
pflegen — nur der Store-Layer dafür existierte schon.

Neu:
- GET /beitraege: Liste aller Beiträge des angemeldeten Mandanten
  (Plattform, Status, höchste Finding-Schwere) via
  ListSubmissionsForAccount.
- GET /beitraege/{id}: Detailseite mit Fakten, Findings, Archivieren-
  Aktion und Verantwortungsmatrix.
- POST .../beteiligte, .../beteiligte/{pid}/aktualisieren,
  .../beteiligte/{pid}/loeschen: echtes CRUD statt nur Ansicht, per
  htmx ohne Seiten-Reload.

Mandantentrennung wie beim bestehenden Archiv-Download: fremde
Beiträge und fremde Beteiligte (auch über eine erratene participant_id)
liefern 404, nicht 403 — sonst würde eine 403 die Existenz der Ressource
bei einem anderen Mandanten bestätigen.

Web-Store-Interface um die bereits fertigen Store-Methoden erweitert,
fakeStore in server_test.go entsprechend nachgezogen. Volle Testsuite
inkl. echter Postgres-Tests (./scripts/test.sh) grün.
2026-08-27 17:54:33 +02:00
noroot
6df1d2961b feat: add participant CRUD and per-account submission listing
Participant (the Verantwortungsmatrix — who briefed, who approved) is
not append-only like finding/extraction/evidence_package; getting a
role wrong and correcting it isn't rewriting evidence, so full CRUD is
legitimate here: Create/List/Get/Update/Delete. UpdateParticipant sets
approved_at the first time freigegeben flips to true and never moves it
again on subsequent no-op updates — it marks when approval first
happened, not "last touched".

ListSubmissionsForAccount is the query the upcoming archive overview
needs: every submission for a tenant plus a findings count and highest
severity, computed with the same anti-join ListCurrentFindings already
uses for "currently valid" findings.

Also fixed a real bug this surfaced: CreateFinding let a nil Sources
slice reach a NOT NULL TEXT[] column, which Postgres rejects with an
unhelpful constraint error instead of a clear message. It now normalizes
nil to an empty slice before inserting — matters for any future rule
that ships without a fundstelle entry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:46:31 +02:00
noroot
77de1627c5 feat: add mobile-first styling matching enconf's CI
Pages were unstyled HTML until now. design/enterprise.css (enconf's
4400-line stylesheet) is tightly coupled to Ant Design/React class
names and a fixed desktop sidebar layout — not usable as-is for
Deklarix's plain server-rendered forms. Instead, internal/web/static/
app.css is a small, purpose-built mobile-first stylesheet that reuses
enconf's actual design tokens (primary blue #1677ff, radius scale,
shadows, Inter) for brand consistency without dragging in the
unrelated layout/framework rules.

Inter is self-hosted (copied from enconf's font files) rather than
pulled from Google Fonts, keeping the "no runtime internet dependency"
property. Only the "latin" subset is included — German umlauts and ß
all live in U+0000-00FF, so the cyrillic/greek/vietnamese subsets
enconf ships aren't needed here.

Inputs/buttons are sized for touch (44px min-height) and use 16px font
size to avoid iOS's auto-zoom-on-focus. Findings are color-coded by
severity (red/amber/green backgrounds with a matching left border).

Verified visually with chromium --headless --screenshot at both mobile
(390px) and desktop (1280px) viewports, including a real WK-001 finding
fetched from the running server and rendered through the actual
stylesheet — not just asserted via HTTP status codes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:41:57 +02:00
noroot
374f4ade31 fix: heal missing RULES_DIR in postinst on upgrade
Discovered live: upgrading the test server to v0.2.0 crash-looped with
"rules: read dir: open .: no such file or directory". Its
/etc/deklarix/deklarix.env predates RULES_DIR entirely (created on
first install, before that variable existed) — postinst never
overwrites an existing env file, by design, so the variable was simply
missing rather than set. main.go's relative default "rules" then
resolved against WorkingDirectory=/var/lib/deklarix instead of the
actual install path (/usr/share/deklarix/rules).

postinst now appends RULES_DIR with its packaged default whenever it's
absent, on both fresh installs and upgrades — never overwriting an
existing value. Same healing pattern enconf uses for DB_SSLMODE.
Verified: reinstalling over the broken config fixed it and the service
came back up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:24:41 +02:00
noroot
34b1d8d2a6 feat: replace Claude-based extraction with a rule-based engine
Deklarix itself no longer depends on the Anthropic API — that was a
separate API key/billing relationship from Claude Code (used to develop
Deklarix), which the user did not intend to take on for the product
itself.

Consideration (Gegenleistung) is no longer guessed from text — it's a
required form field now, since only the submitter actually knows
whether a business relationship existed. A keyword-only system can't
tell a covertly-paid post from a genuinely organic one; they read
identically. What internal/extract *can* still determine reliably and
deterministically from the caption: whether a disclosure keyword is
present (werbung, anzeige, bezahlte partnerschaft, paid partnership,
#ad, #werbung, #anzeige, #sponsored, #sponsoredby, #sponsoredpost —
case-insensitive), its exact original-case wording, and whether it sits
before the platform's "mehr anzeigen" truncation point (~125 chars
Instagram, ~150 TikTok — rough estimates, platforms change these without
notice, verify before real customer use).

internal/extract's Anthropic HTTP client and tool-use schema are gone
(client.go/api.go deleted), replaced by engine.go — a stateless Engine
with no network calls. extract.Result/ParsePayload keep the exact same
JSON shape as before (gegenleistung/kennzeichnung_vorhanden/
kennzeichnung_wortlaut/kennzeichnung_vor_kuerzung), so internal/store and
internal/dossier needed no changes at all — only extract itself, the web
form/handler (new consideration field), and main.go (no more
ANTHROPIC_API_KEY requirement) changed.

Trade-off the user was told and accepted: without an LLM, the system can
no longer independently catch undisclosed paid content that carries no
recognizable keyword at all — that now rests on the submitter's honesty.
Creative or implicit disclosure phrasing outside the keyword list also
won't be recognized.

Verified against a real running instance with zero API keys configured:
register -> check (real rule engine, correctly triggered WK-004 for a
disclosure placed 130 characters in, past the Instagram threshold) ->
archive -> PDF dossier download, all against real Postgres.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:15:39 +02:00
130 changed files with 9139 additions and 3838 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# Build output
dist/
dossiers/
*.exe
*.exe~
*.dll

697
CLAUDE.md
View File

@@ -1,88 +1,383 @@
# 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: das Modell entscheidet nicht
## Grundregel
**Das LLM extrahiert Fakten. 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 (Claude API):** Eingabe Caption plus Bild/Video,
Ausgabe striktes JSON (Plattform, Gegenleistung, Kennzeichnung
vorhanden/Kanal/Wortlaut/Position, Produktkategorie, Verlinkungen, ...).
Ist die Extraktion unsicher, liefert sie `unklar` — nie eine geratene
Bewertung.
- **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:** Das Modell nennt niemals selbst Normen, Urteile oder
Randnummern. Fundstellen stehen ausschließlich in den kuratierten
YAML-Regeln. Eine halluzinierte Fundstelle ist in diesem Markt ein
Totalschaden.
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 | Claude API (nur Fakten-Extraktion, siehe oben) |
| 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ß.
**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
(Sammellink, Selbstanmeldung, Abteilung beim ersten Antrag), (2) CSV-
Import, (3) SSO (Entra ID/Google Workspace), (4) Antrag ganz ohne
vorheriges Konto über Mandantenlink + E-Mail-Bestätigungscode (Konto
entsteht nebenbei). Keins davon ist gebaut — aktuell nur Firma-
Registrierung.
**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.
---
@@ -92,19 +387,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/ # Claude-API-Aufruf, JSON-Schema, Validierung
│ ├── 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
@@ -121,48 +415,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.
---
@@ -283,56 +633,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.**
---
@@ -356,8 +673,7 @@ git push origin main
sudo systemctl start deklarix
sudo systemctl status deklarix
# Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, ANTHROPIC_API_KEY,
# RULES_DIR, DOSSIER_DIR, TSA_URL)
# Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, RULES_DIR)
# Logs prüfen
journalctl -u deklarix -f
@@ -376,16 +692,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
@@ -404,13 +715,39 @@ 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 und Grenzfälle im Anforderungsprofil**
(siehe `rules/OPEN.md`, Punkte 1/2/5/6) — mit dem/der fachlich
Verantwortlichen (z. B. Datenschutzbeauftragte/r) bestätigen. Schritt
3 ist trotzdem schon umgesetzt (auf Basis dieser dokumentierten
Annahmen) — nicht auf die Klärung gewartet, um nicht blockiert zu
bleiben, aber die Ableitung kann sich noch ändern.
- ~~Genaue Fragebogen-Felder für die drei "verboten"-Varianten~~ —
**erledigt in Schritt 2**, C3 hat jetzt eine Folgefrage `c3_art`.
Weiterhin offen: ob die drei Varianten fachlich vollständig sind
(siehe `rules/OPEN.md`, Punkt 3).
- **Löschfristen je Datenklasse** (konkrete Tageswerte für
`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** — die
Spezifikation will, dass der erste Nutzer beide Rollen gleichzeitig
hat; `app_user.role` ist aktuell ein einzelner Wert. Muss geklärt
werden: zwei Rollen pro Nutzer zulassen (Datenmodell-Änderung) oder
zwei `app_user`-Zeilen für dieselbe Person?
- **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.

View File

@@ -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,33 +27,33 @@ func main() {
}
defer db.Close()
apiKey := os.Getenv("ANTHROPIC_API_KEY")
if apiKey == "" {
log.Fatal("ANTHROPIC_API_KEY is required")
}
extractor, err := extract.NewClient(apiKey)
if err != nil {
log.Fatalf("extract client: %v", err)
}
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)
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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))
}

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -1,111 +0,0 @@
package extract
import "encoding/json"
// Diese Typen bilden nur den Ausschnitt der Anthropic-Messages-API ab,
// den die Extraktion tatsächlich braucht (Tool-Use für strukturierte
// Ausgabe) — kein vollständiger API-Client.
type messageRequest struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
System string `json:"system,omitempty"`
Messages []message `json:"messages"`
Tools []tool `json:"tools"`
ToolChoice toolChoice `json:"tool_choice"`
}
type message struct {
Role string `json:"role"`
Content []contentBlock `json:"content"`
}
type contentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
Source *imageSource `json:"source,omitempty"`
}
type imageSource struct {
Type string `json:"type"`
MediaType string `json:"media_type"`
Data string `json:"data"`
}
type tool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema toolInputSchema `json:"input_schema"`
}
type toolInputSchema struct {
Type string `json:"type"`
Properties map[string]any `json:"properties"`
Required []string `json:"required"`
}
type toolChoice struct {
Type string `json:"type"`
Name string `json:"name"`
}
type messageResponse struct {
Content []responseBlock `json:"content"`
Error *apiError `json:"error,omitempty"`
}
type responseBlock struct {
Type string `json:"type"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
}
type apiError struct {
Type string `json:"type"`
Message string `json:"message"`
}
// extractionArgs spiegelt extractionTool.InputSchema — das JSON, das
// die Extraktion vom Modell zurückbekommt.
type extractionArgs struct {
Consideration string `json:"gegenleistung"`
DisclosurePresent bool `json:"kennzeichnung_vorhanden"`
DisclosureWording string `json:"kennzeichnung_wortlaut"`
DisclosureBeforeCut bool `json:"kennzeichnung_vor_kuerzung"`
}
var extractionTool = tool{
Name: "extrahiere_fakten",
Description: "Extrahiere ausschließlich beobachtbare Fakten aus Caption und Bild eines " +
"Social-Media-Beitrags für eine Kennzeichnungsprüfung. Triff KEINE rechtliche " +
"Bewertung. Ist ein Feld nicht sicher zu bestimmen, wähle den vorgesehenen " +
"Unsicherheitswert statt zu raten.",
InputSchema: toolInputSchema{
Type: "object",
Properties: map[string]any{
"gegenleistung": map[string]any{
"type": "string",
"enum": []string{"bezahlt", "sachbezug", "keine", "unklar"},
"description": "Erhält der/die Postende eine Gegenleistung (Geld, Produkt, Einladung)? 'unklar' wenn nicht sicher bestimmbar.",
},
"kennzeichnung_vorhanden": map[string]any{
"type": "boolean",
"description": "Enthält die Caption einen Kennzeichnungshinweis wie 'Werbung' oder 'Anzeige'?",
},
"kennzeichnung_wortlaut": map[string]any{
"type": "string",
"description": "Der exakte Wortlaut der Kennzeichnung, falls vorhanden, sonst leerer String.",
},
"kennzeichnung_vor_kuerzung": map[string]any{
"type": "boolean",
"description": "Ist die Kennzeichnung sichtbar, BEVOR die Plattform die Caption hinter 'mehr anzeigen' kürzt? Ohne Kürzung: true.",
},
},
Required: []string{
"gegenleistung",
"kennzeichnung_vorhanden",
"kennzeichnung_wortlaut",
"kennzeichnung_vor_kuerzung",
},
},
}

View File

@@ -1,233 +0,0 @@
// Package extract ist Stufe 1 aus dem Kernprinzip: es extrahiert
// ausschließlich beobachtbare Fakten aus Caption und Bild eines
// Beitrags über die Claude API. Es bewertet nichts — das Urteil fällt
// ausschließlich das Regelwerk in internal/rules.
package extract
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"github.com/netcell-it/deklarix/internal/rules"
)
const (
defaultBaseURL = "https://api.anthropic.com"
defaultModel = "claude-sonnet-5"
anthropicVersion = "2023-06-01"
// PromptVersion wird zusammen mit jeder Extraktion gespeichert
// (siehe Datenmodell: extraction.prompt_version), damit sich ein
// späteres Finding auf den exakten Prompt-Stand zurückführen lässt,
// der es erzeugt hat. Hochzählen bei jeder inhaltlichen Änderung an
// systemPrompt oder extractionTool.
PromptVersion = "v1"
)
const systemPrompt = `Du extrahierst ausschließlich beobachtbare Fakten aus einem Social-Media-Beitrag (Caption und Bild) für eine Kennzeichnungsprüfung nach deutschem Recht.
Du triffst KEINE rechtliche Bewertung und nennst KEINE Gesetze, Paragraphen oder Urteile — das ist nicht deine Aufgabe.
Ist ein Fakt nicht sicher aus Caption oder Bild zu bestimmen, wähle den dafür vorgesehenen Unsicherheitswert (z. B. "unklar"). Rate niemals.`
// Input ist, was Stufe 1 zur Extraktion braucht. Platform und
// Jurisdiction kommen vom Aufrufer (der Nutzer wählt Plattform und
// Rechtsordnung beim Einreichen) statt vom Modell erraten zu werden —
// aus Caption/Bild lässt sich keine Rechtsordnung ablesen.
type Input struct {
Platform string
Jurisdiction string
Caption string
ImageMediaType string // z. B. "image/jpeg", "image/png"
ImageData []byte
}
// Result ist die Ausgabe von Extract: die für das Regelwerk
// aufbereiteten Facts, plus RawJSON — das exakte, unveränderte JSON, das
// das Modell zurückgegeben 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
}
// Client ruft die Claude API zur Fakten-Extraktion auf.
type Client struct {
apiKey string
model string
baseURL string
httpClient *http.Client
}
// Option konfiguriert einen Client.
type Option func(*Client)
// WithModel überschreibt das Standardmodell.
func WithModel(model string) Option {
return func(c *Client) { c.model = model }
}
// WithBaseURL überschreibt die API-Basis-URL (für Tests).
func WithBaseURL(url string) Option {
return func(c *Client) { c.baseURL = url }
}
// WithHTTPClient überschreibt den verwendeten *http.Client (für Tests).
func WithHTTPClient(hc *http.Client) Option {
return func(c *Client) { c.httpClient = hc }
}
// NewClient erstellt einen Extraktions-Client. apiKey darf nicht leer
// sein — es gibt keinen stillen Fallback auf einen ungültigen Zustand.
func NewClient(apiKey string, opts ...Option) (*Client, error) {
if apiKey == "" {
return nil, fmt.Errorf("extract: apiKey darf nicht leer sein")
}
c := &Client{
apiKey: apiKey,
model: defaultModel,
baseURL: defaultBaseURL,
httpClient: http.DefaultClient,
}
for _, opt := range opts {
opt(c)
}
return c, nil
}
// ModelVersion ist der Modell-Identifier, der zusammen mit jeder
// Extraktion gespeichert werden sollte (siehe Datenmodell:
// extraction.model_version).
func (c *Client) ModelVersion() string { return c.model }
// Extract ruft die Claude API auf und liefert die extrahierten Fakten.
// Bei jedem Fehler (Netzwerk, API-Fehler, unerwartete Antwortform,
// ungültiger Enum-Wert) wird ein Fehler zurückgegeben statt stumm ein
// Zero-Value-Facts zu liefern — ein falsches "keine Gegenleistung" wäre
// hier schlimmer als ein sichtbarer Fehler.
func (c *Client) Extract(ctx context.Context, in Input) (Result, error) {
if in.Caption == "" && len(in.ImageData) == 0 {
return Result{}, fmt.Errorf("extract: caption und bild sind beide leer")
}
var content []contentBlock
if len(in.ImageData) > 0 {
if in.ImageMediaType == "" {
return Result{}, fmt.Errorf("extract: ImageMediaType fehlt für vorhandenes Bild")
}
content = append(content, contentBlock{
Type: "image",
Source: &imageSource{
Type: "base64",
MediaType: in.ImageMediaType,
Data: base64.StdEncoding.EncodeToString(in.ImageData),
},
})
}
content = append(content, contentBlock{Type: "text", Text: "Caption:\n" + in.Caption})
reqBody := messageRequest{
Model: c.model,
MaxTokens: 1024,
System: systemPrompt,
Messages: []message{{Role: "user", Content: content}},
Tools: []tool{extractionTool},
ToolChoice: toolChoice{Type: "tool", Name: extractionTool.Name},
}
payload, err := json.Marshal(reqBody)
if err != nil {
return Result{}, fmt.Errorf("extract: request marshal: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/messages", bytes.NewReader(payload))
if err != nil {
return Result{}, fmt.Errorf("extract: request bauen: %w", err)
}
req.Header.Set("content-type", "application/json")
req.Header.Set("x-api-key", c.apiKey)
req.Header.Set("anthropic-version", anthropicVersion)
resp, err := c.httpClient.Do(req)
if err != nil {
return Result{}, fmt.Errorf("extract: request fehlgeschlagen: %w", err)
}
defer resp.Body.Close()
var msg messageResponse
if err := json.NewDecoder(resp.Body).Decode(&msg); err != nil {
return Result{}, fmt.Errorf("extract: response decode: %w", err)
}
if resp.StatusCode != http.StatusOK {
if msg.Error != nil {
return Result{}, fmt.Errorf("extract: API-Fehler (%s): %s", msg.Error.Type, msg.Error.Message)
}
return Result{}, fmt.Errorf("extract: API-Status %d", resp.StatusCode)
}
var toolUse *responseBlock
for i := range msg.Content {
if msg.Content[i].Type == "tool_use" && msg.Content[i].Name == extractionTool.Name {
toolUse = &msg.Content[i]
break
}
}
if toolUse == nil {
return Result{}, fmt.Errorf("extract: keine tool_use-Antwort für %q enthalten", extractionTool.Name)
}
facts, err := parseArgsToFacts(toolUse.Input, in.Platform, in.Jurisdiction)
if err != nil {
return Result{}, fmt.Errorf("extract: %w", err)
}
return Result{Facts: facts, RawJSON: toolUse.Input}, nil
}
// 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 Modell-Payloads.
func ParsePayload(payload []byte, platform, jurisdiction string) (rules.Facts, error) {
facts, err := parseArgsToFacts(payload, platform, jurisdiction)
if err != nil {
return rules.Facts{}, fmt.Errorf("extract: %w", err)
}
return facts, nil
}
func parseArgsToFacts(raw []byte, platform, jurisdiction string) (rules.Facts, error) {
var args extractionArgs
if err := json.Unmarshal(raw, &args); err != nil {
return rules.Facts{}, fmt.Errorf("tool-input parse: %w", err)
}
consideration, err := parseConsideration(args.Consideration)
if err != nil {
return rules.Facts{}, err
}
return rules.Facts{
Platform: platform,
Jurisdiction: jurisdiction,
Consideration: consideration,
DisclosurePresent: args.DisclosurePresent,
DisclosureWording: args.DisclosureWording,
DisclosureBeforeCut: args.DisclosureBeforeCut,
}, nil
}
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("unerwarteter gegenleistung-Wert %q vom Modell", s)
}
}

View File

@@ -1,225 +0,0 @@
package extract_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/extract"
"github.com/netcell-it/deklarix/internal/rules"
)
// toolUseResponse baut eine minimale Anthropic-Messages-API-Antwort mit
// genau einem tool_use-Block, wie sie extract.Client erwartet.
func toolUseResponse(t *testing.T, toolName string, args any) []byte {
t.Helper()
input, err := json.Marshal(args)
if err != nil {
t.Fatalf("marshal args: %v", err)
}
body := map[string]any{
"content": []map[string]any{
{"type": "tool_use", "name": toolName, "input": json.RawMessage(input)},
},
}
data, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal response: %v", err)
}
return data
}
func newTestClient(t *testing.T, handler http.HandlerFunc) *extract.Client {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
c, err := extract.NewClient("test-key", extract.WithBaseURL(server.URL))
if err != nil {
t.Fatalf("NewClient: %v", err)
}
return c
}
func TestNewClientRejectsEmptyAPIKey(t *testing.T) {
if _, err := extract.NewClient(""); err == nil {
t.Fatal("expected error for empty apiKey, got nil")
}
}
func TestExtractRejectsEmptyInput(t *testing.T) {
called := false
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
called = true
})
_, err := c.Extract(context.Background(), extract.Input{Platform: "instagram"})
if err == nil {
t.Fatal("expected error for empty caption+image, got nil")
}
if called {
t.Fatal("expected no HTTP call for invalid input, but the server was called")
}
}
func TestExtractSuccess(t *testing.T) {
var gotBody map[string]any
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/messages" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
if got := r.Header.Get("x-api-key"); got != "test-key" {
t.Errorf("x-api-key = %q, want test-key", got)
}
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
w.WriteHeader(http.StatusOK)
w.Write(toolUseResponse(t, "extrahiere_fakten", map[string]any{
"gegenleistung": "bezahlt",
"kennzeichnung_vorhanden": true,
"kennzeichnung_wortlaut": "Werbung",
"kennzeichnung_vor_kuerzung": false,
}))
})
got, err := c.Extract(context.Background(), extract.Input{
Platform: "instagram",
Jurisdiction: "DE",
Caption: "Schaut euch dieses Produkt an! Werbung wegen ...",
ImageMediaType: "image/jpeg",
ImageData: []byte("fake-jpeg-bytes"),
})
if err != nil {
t.Fatalf("Extract: %v", err)
}
want := rules.Facts{
Platform: "instagram",
Jurisdiction: "DE",
Consideration: rules.ConsiderationPaid,
DisclosurePresent: true,
DisclosureWording: "Werbung",
DisclosureBeforeCut: false,
}
if got.Facts != want {
t.Fatalf("Extract().Facts = %+v, want %+v", got.Facts, want)
}
var rawArgs map[string]any
if err := json.Unmarshal(got.RawJSON, &rawArgs); err != nil {
t.Fatalf("RawJSON does not parse as JSON: %v", err)
}
if rawArgs["gegenleistung"] != "bezahlt" {
t.Fatalf("RawJSON = %s, expected it to contain the model's raw field names", got.RawJSON)
}
toolChoice, _ := gotBody["tool_choice"].(map[string]any)
if toolChoice["name"] != "extrahiere_fakten" {
t.Errorf("tool_choice.name = %v, want extrahiere_fakten", toolChoice["name"])
}
if gotBody["system"] == nil || gotBody["system"] == "" {
t.Error("expected a non-empty system prompt in the request")
}
}
func TestExtractPassesThroughUnclear(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write(toolUseResponse(t, "extrahiere_fakten", map[string]any{
"gegenleistung": "unklar",
"kennzeichnung_vorhanden": false,
"kennzeichnung_wortlaut": "",
"kennzeichnung_vor_kuerzung": false,
}))
})
got, err := c.Extract(context.Background(), extract.Input{Platform: "tiktok", Caption: "..."})
if err != nil {
t.Fatalf("Extract: %v", err)
}
if got.Facts.Consideration != rules.ConsiderationUnclear {
t.Fatalf("Consideration = %q, want unklar", got.Facts.Consideration)
}
}
func TestExtractRejectsInvalidConsiderationValue(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write(toolUseResponse(t, "extrahiere_fakten", map[string]any{
"gegenleistung": "vielleicht", // nicht im Enum
"kennzeichnung_vorhanden": false,
"kennzeichnung_wortlaut": "",
"kennzeichnung_vor_kuerzung": false,
}))
})
if _, err := c.Extract(context.Background(), extract.Input{Platform: "instagram", Caption: "x"}); err == nil {
t.Fatal("expected error for out-of-enum gegenleistung value, got nil")
}
}
func TestExtractRejectsMissingToolUseBlock(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"content":[{"type":"text","text":"kein tool_use hier"}]}`))
})
if _, err := c.Extract(context.Background(), extract.Input{Platform: "instagram", Caption: "x"}); err == nil {
t.Fatal("expected error when response has no tool_use block, got nil")
}
}
func TestExtractRejectsMalformedToolInput(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"content":[{"type":"tool_use","name":"extrahiere_fakten","input":"not-an-object"}]}`))
})
if _, err := c.Extract(context.Background(), extract.Input{Platform: "instagram", Caption: "x"}); err == nil {
t.Fatal("expected error for malformed tool input, got nil")
}
}
func TestParsePayloadRoundTrip(t *testing.T) {
payload := []byte(`{"gegenleistung":"sachbezug","kennzeichnung_vorhanden":true,"kennzeichnung_wortlaut":"Anzeige","kennzeichnung_vor_kuerzung":true}`)
facts, err := extract.ParsePayload(payload, "tiktok", "DE")
if err != nil {
t.Fatalf("ParsePayload: %v", err)
}
want := rules.Facts{
Platform: "tiktok",
Jurisdiction: "DE",
Consideration: rules.ConsiderationInKind,
DisclosurePresent: true,
DisclosureWording: "Anzeige",
DisclosureBeforeCut: true,
}
if facts != want {
t.Fatalf("ParsePayload() = %+v, want %+v", facts, want)
}
}
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")
}
}
func TestExtractPropagatesAPIError(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
w.Write([]byte(`{"error":{"type":"rate_limit_error","message":"too many requests"}}`))
})
_, err := c.Extract(context.Background(), extract.Input{Platform: "instagram", Caption: "x"})
if err == nil {
t.Fatal("expected error for non-200 status, got nil")
}
if got := err.Error(); !strings.Contains(got, "too many requests") {
t.Fatalf("error %q does not mention API message", got)
}
}

View File

@@ -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 := &regelwerk.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)
}

View 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)
}
}

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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
}

View 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")
}
}

View 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"`
}

View File

@@ -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
}

View File

@@ -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)
}
}

View 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
}

View 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)
}
}

View File

@@ -2,12 +2,15 @@ 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.
type Account struct {
ID string
Name string
@@ -33,8 +36,36 @@ func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
err := s.Pool.QueryRow(ctx, `
SELECT id, name, created_at FROM account WHERE id = $1
`, id).Scan(&a.ID, &a.Name, &a.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, ErrNotFound
}
if err != nil {
return Account{}, fmt.Errorf("store: get account: %w", err)
}
return a, 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 id, name, created_at 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() {
var a Account
if err := rows.Scan(&a.ID, &a.Name, &a.CreatedAt); 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
}

View 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
View 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
}

View 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)
}
}

View 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
}

View File

@@ -32,7 +32,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 +52,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 +110,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 +123,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
View 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
}

View 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")
}
}

View File

@@ -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")
}
}

View 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
}

View 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)
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -1,74 +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.
func (s *Store) CreateFinding(ctx context.Context, submissionID string, extractionID *string, ruleID string, ruleVersion int, severity, title, fix string, sources []string) (Finding, error) {
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
}

View 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'));

View 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();

View File

@@ -0,0 +1 @@
DROP TRIGGER asset_append_only ON asset;

View 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();

View File

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

View 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)
);

View File

@@ -0,0 +1 @@
ALTER TABLE asset DROP COLUMN purpose;

View 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'));

View 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)
);

View 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()
);

View File

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

View 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();

View File

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

View File

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

View File

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

View 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();

View File

@@ -0,0 +1 @@
ALTER TABLE app_user DROP COLUMN active;

View 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;

View 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
}

View 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")
}
}

View File

@@ -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)
}
}

View File

@@ -1,79 +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
}
// 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
}

View File

@@ -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
View 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
}

View 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")
}
}
}

View 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")
}
}

View 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)
}

View 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)
}
}

View File

@@ -0,0 +1,355 @@
package web
import (
"context"
"encoding/json"
"net/http"
"github.com/netcell-it/deklarix/internal/rules"
"github.com/netcell-it/deklarix/internal/store"
)
// antwortenB/C/D-Feldnamen sind bewusst identisch zu den Fakten-
// Schlüsseln in rules/datenklasse.yaml (b1..b7) und
// rules/kivo_einstufung.yaml (c1..c5, c2_folge, c3_art) — Schritt 3
// (Ableitungen) liest dieselben Schlüssel direkt aus antrag.antworten,
// ohne sie erst umbenennen zu müssen.
var datenfragen = []struct {
Key string
Label string
}{
{"b1", "Namen, E-Mail-Adressen oder andere Angaben zu Personen"},
{"b2", "Angaben zu Gesundheit, Religion, Herkunft, Gewerkschaft, Sexualleben"},
{"b3", "Daten von Patienten, Mandanten oder Versicherten"},
{"b4", "Bewerbungs-, Personal- oder Leistungsdaten von Beschäftigten"},
{"b5", "Daten, die uns ein Kunde anvertraut hat"},
{"b6", "Konstruktions-, Kalkulations- oder Vertragsunterlagen"},
{"b7", "Nur allgemein zugängliche oder selbst erfundene Inhalte"},
}
type antragFormData struct {
Title string
Nav navData
Abteilungen []store.Abteilung
Datenfragen []struct {
Key string
Label string
}
Error string
}
// handleAntragNewForm zeigt den Fragebogen (Ebene 2, "Antrag stellen").
// Bewusst eine einzelne Seite statt eines mehrseitigen Assistenten —
// jede zusätzliche Seite kostet Zeit, und "dauert der Antrag länger als
// fünf Minuten, wird er umgangen" (siehe CLAUDE.md). Adaptive
// Folgefragen (C2/C3) werden rein clientseitig per CSS ein-/ausgeblendet,
// ohne zusätzlichen Server-Roundtrip.
func (s *Server) handleAntragNewForm(w http.ResponseWriter, r *http.Request) {
if currentUser(r).Role == "betreiber" {
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
return
}
abteilungen, err := s.store.ListAbteilungenForAccount(r.Context(), currentUser(r).AccountID)
if err != nil {
http.Error(w, "Abteilungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := antragFormData{Title: "Antrag stellen", Nav: navFor(r), Abteilungen: abteilungen, Datenfragen: datenfragen}
if err := s.templates.ExecuteTemplate(w, "antrag-neu", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// antworten ist die JSON-Struktur, die in antrag.antworten gespeichert
// wird — ein flaches Objekt aus den Fragebogen-Fakten-Schlüsseln.
// "Unsicher" wird für B unverändert als "unsicher" gespeichert, NICHT
// zu "ja" normalisiert: diese Normalisierung ("Unsicher zählt wie Ja")
// ist eine Auswertungsregel (Schritt 3), keine Speicherregel — was der
// Mitarbeiter tatsächlich geantwortet hat, bleibt im Antrag unverändert
// erhalten.
func antwortenFromForm(r *http.Request) []byte {
m := map[string]any{}
for _, f := range datenfragen {
v := r.FormValue(f.Key)
if v == "" {
v = "nein"
}
m[f.Key] = v
}
m["c1"] = r.FormValue("c1") == "ja"
m["c2"] = r.FormValue("c2") == "ja"
m["c2_folge"] = r.FormValue("c2_folge")
m["c3"] = r.FormValue("c3") == "ja"
m["c3_art"] = r.FormValue("c3_art")
m["c4"] = r.FormValue("c4") == "ja"
m["c5"] = r.FormValue("c5") == "ja"
m["d_werkzeug_freitext"] = r.FormValue("d_werkzeug_freitext")
m["d_zugang"] = r.FormValue("d_zugang")
m["d_geraet"] = r.FormValue("d_geraet")
data, _ := json.Marshal(m) // map[string]any mit nur primitiven Werten kann nicht scheitern
return data
}
// handleAntragCreate legt einen Antrag an und reicht ihn direkt ein
// (entwurf -> eingereicht in einem Schritt) — der Fragebogen ist eine
// einzelne Seite, ein Zwischenspeichern als Entwurf ist für v1 nicht
// vorgesehen.
func (s *Server) handleAntragCreate(w http.ResponseWriter, r *http.Request) {
if currentUser(r).Role == "betreiber" {
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
titel := r.FormValue("titel")
beschreibung := r.FormValue("beschreibung")
ergebnis := r.FormValue("ergebnis")
haeufigkeit := r.FormValue("haeufigkeit")
if titel == "" || beschreibung == "" || ergebnis == "" || haeufigkeit == "" {
http.Error(w, "Titel, Beschreibung, Ergebnis und Häufigkeit sind Pflichtfelder", http.StatusBadRequest)
return
}
var abteilungID *string
if v := r.FormValue("abteilung_id"); v != "" {
abteilungID = &v
}
ctx := r.Context()
user := currentUser(r)
antworten := antwortenFromForm(r)
antrag, err := s.store.CreateAntrag(ctx, user.AccountID, user.ID, abteilungID, titel)
if err != nil {
http.Error(w, "Antrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
return
}
if _, err := s.store.UpdateAntragFelder(ctx, antrag.ID, titel, beschreibung, ergebnis, haeufigkeit, antworten); err != nil {
http.Error(w, "Antrag konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
return
}
if err := s.store.SetAntragStatus(ctx, antrag.ID, "eingereicht"); err != nil {
http.Error(w, "Antrag konnte nicht eingereicht werden: "+err.Error(), http.StatusInternalServerError)
return
}
if err := s.bewerteAntrag(ctx, antrag.ID, user.AccountID, antworten); err != nil {
http.Error(w, "Bewertung konnte nicht berechnet werden: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/antraege/"+antrag.ID, http.StatusSeeOther)
}
// bewerteAntrag berechnet die Bewertung für einen eingereichten Antrag
// (Schritt 3: Ableitungen und harte Filter) und speichert sie. Bei
// "verboten" erfolgt die K.-o.-Prüfung — keine Werkzeugsuche, siehe
// CLAUDE.md, Bewertungslogik.
func (s *Server) bewerteAntrag(ctx context.Context, antragID, accountID string, antwortenJSON []byte) error {
antworten, err := rules.ParseAntworten(antwortenJSON)
if err != nil {
return err
}
datenklasse := rules.EvaluateDatenklasse(s.regelwerk.Datenklasse, antworten)
einstufung, err := rules.EvaluateEinstufung(s.regelwerk.Einstufung, antworten)
if err != nil {
return err
}
verboten := rules.IstVerboten(einstufung.ID)
in := store.BewertungInput{
AntragID: antragID, Datenklasse: datenklasse.ID, DatenklasseHerleitung: datenklasse.Herleitung,
Einstufung: einstufung.ID, EinstufungHerleitung: einstufungHerleitungText(einstufung), Verboten: verboten,
KatalogVersion: "n/a (K.-o.-Prüfung, keine Werkzeugsuche)",
}
if !verboten {
anforderungen := rules.DeriveAnforderungen(s.regelwerk.Anforderungen, datenklasse.ID, einstufung.ID)
anforderungIDs := make([]string, len(anforderungen))
for i, a := range anforderungen {
in.Anforderungen = append(in.Anforderungen, store.BewertungAnforderung{ID: a.ID, Beschreibung: a.Beschreibung, Herleitung: a.Herleitung})
anforderungIDs[i] = a.ID
}
werkzeuge, err := s.store.ListWerkzeugeForAccount(ctx, accountID)
if err != nil {
return err
}
kandidaten := make([]rules.WerkzeugKandidat, len(werkzeuge))
for i, w := range werkzeuge {
kandidaten[i] = rules.WerkzeugKandidat{ID: w.ID, Eigenschaften: rules.WerkzeugEigenschaften{
AVVVerfuegbar: w.AVVVerfuegbar, Verarbeitungsort: w.Verarbeitungsort,
TrainingOptOut: w.TrainingOptOut, TrainingStandard: w.TrainingStandard, AufbewahrungTage: w.AufbewahrungTage,
}}
}
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, anforderungIDs)
in.ZulaessigeWerkzeuge = zulaessig
for _, a := range ausgeschlossen {
in.AusgeschlosseneWerkzeuge = append(in.AusgeschlosseneWerkzeuge, store.BewertungAusschluss{WerkzeugID: a.WerkzeugID, NichtErfuellt: a.NichtErfuellt})
}
katalogVersion, err := s.store.CurrentKatalogVersion(ctx)
if err != nil {
return err
}
in.KatalogVersion = katalogVersion
}
in.RegelwerkVersion = rules.RegelwerkVersion(s.regelwerk.Datenklasse, s.regelwerk.Einstufung, s.regelwerk.Anforderungen)
_, err = s.store.CreateBewertung(ctx, in)
return err
}
// einstufungHerleitungText fügt die Quelle (z. B. "Art. 5 KI-VO") mit
// an, falls das Regelwerk eine nennt.
func einstufungHerleitungText(e rules.EinstufungErgebnis) string {
if e.Quelle == "" {
return e.Herleitung
}
return e.Herleitung + " (" + e.Quelle + ")"
}
type antragListItem struct {
ID string
Titel string
Status string
Haeufigkeit string
CreatedAt string
}
type antragListData struct {
Title string
Nav navData
Antraege []antragListItem
}
// handleAntragList zeigt die eigenen Anträge (Ebene 2 — "Eigene
// Anträge einsehen, Status verfolgen"), nicht die des ganzen Mandanten.
func (s *Server) handleAntragList(w http.ResponseWriter, r *http.Request) {
if currentUser(r).Role == "betreiber" {
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
return
}
list, err := s.store.ListAntraegeForUser(r.Context(), currentUser(r).ID)
if err != nil {
http.Error(w, "Anträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := antragListData{Title: "Meine Anträge", Nav: navFor(r)}
for _, a := range list {
data.Antraege = append(data.Antraege, antragListItem{
ID: a.ID, Titel: a.Titel, Status: a.Status, Haeufigkeit: a.Haeufigkeit,
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
})
}
if err := s.templates.ExecuteTemplate(w, "antraege-liste", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type anforderungView struct {
ID string
Beschreibung string
Herleitung string
}
type werkzeugAusschlussView struct {
Name string
NichtErfuellt []string
}
type bewertungView struct {
Datenklasse string
DatenklasseHerleitung string
Einstufung string
EinstufungHerleitung string
Verboten bool
Anforderungen []anforderungView
ZulaessigeWerkzeuge []string // Namen, nicht IDs
AusgeschlosseneWerkzeuge []werkzeugAusschlussView
RegelwerkVersion string
KatalogVersion string
CreatedAt string
}
type antragDetailData struct {
Title string
Nav navData
ID string
Titel string
Beschreibung string
Ergebnis string
Haeufigkeit string
Status string
CreatedAt string
Antworten map[string]any
Bewertung *bewertungView
}
// handleAntragDetail zeigt einen einzelnen Antrag mit allen Antworten.
// Ein Antrag eines fremden Nutzers wird wie ein nicht existierender
// behandelt (404), nie mit einer expliziten 403 bestätigt — dasselbe
// Muster wie schon beim alten Produkt für fremde Mandanten.
func (s *Server) handleAntragDetail(w http.ResponseWriter, r *http.Request) {
antrag, err := s.store.GetAntrag(r.Context(), r.PathValue("id"))
if err != nil || antrag.ErstellerUserID != currentUser(r).ID {
http.Error(w, "Antrag nicht gefunden", http.StatusNotFound)
return
}
var antworten map[string]any
if len(antrag.Antworten) > 0 {
_ = json.Unmarshal(antrag.Antworten, &antworten)
}
data := antragDetailData{
Title: "Antrag", Nav: navFor(r), ID: antrag.ID, Titel: antrag.Titel,
Beschreibung: antrag.Beschreibung, Ergebnis: antrag.Ergebnis, Haeufigkeit: antrag.Haeufigkeit,
Status: antrag.Status, CreatedAt: antrag.CreatedAt.Format("02.01.2006 15:04"), Antworten: antworten,
}
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
if err == nil {
data.Bewertung = s.toBewertungView(r.Context(), bewertung)
}
if err := s.templates.ExecuteTemplate(w, "antrag-detail", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// toBewertungView löst die in der Bewertung nur als ID gespeicherten
// Werkzeuge zu Anzeigenamen auf. Ein Werkzeug, das seit der Berechnung
// gelöscht wurde, wird mit seiner ID statt eines Namens angezeigt,
// statt den ganzen Antrag unanzeigbar zu machen.
func (s *Server) toBewertungView(ctx context.Context, b store.Bewertung) *bewertungView {
v := &bewertungView{
Datenklasse: b.Datenklasse, DatenklasseHerleitung: b.DatenklasseHerleitung,
Einstufung: b.Einstufung, EinstufungHerleitung: b.EinstufungHerleitung, Verboten: b.Verboten,
RegelwerkVersion: b.RegelwerkVersion, KatalogVersion: b.KatalogVersion,
CreatedAt: b.CreatedAt.Format("02.01.2006 15:04"),
}
for _, a := range b.Anforderungen {
v.Anforderungen = append(v.Anforderungen, anforderungView{ID: a.ID, Beschreibung: a.Beschreibung, Herleitung: a.Herleitung})
}
for _, id := range b.ZulaessigeWerkzeuge {
v.ZulaessigeWerkzeuge = append(v.ZulaessigeWerkzeuge, s.werkzeugName(ctx, id))
}
for _, a := range b.AusgeschlosseneWerkzeuge {
v.AusgeschlosseneWerkzeuge = append(v.AusgeschlosseneWerkzeuge, werkzeugAusschlussView{
Name: s.werkzeugName(ctx, a.WerkzeugID), NichtErfuellt: a.NichtErfuellt,
})
}
return v
}
func (s *Server) werkzeugName(ctx context.Context, id string) string {
w, err := s.store.GetWerkzeug(ctx, id)
if err != nil {
return id
}
return w.Name
}

View File

@@ -0,0 +1,255 @@
package web_test
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
func fullAntragForm() url.Values {
return url.Values{
"titel": {"Angebotstexte generieren"}, "beschreibung": {"KI schreibt Angebotstexte"},
"ergebnis": {"Fertiger Text zur Freigabe"}, "haeufigkeit": {"gelegentlich"},
"b1": {"ja"}, "b2": {"nein"}, "b3": {"nein"}, "b4": {"nein"}, "b5": {"nein"}, "b6": {"nein"}, "b7": {"nein"},
"c1": {"nein"}, "c2": {"nein"}, "c2_folge": {"keine"}, "c3": {"nein"}, "c3_art": {"keine"},
"c4": {"nein"}, "c5": {"ja"},
"d_werkzeug_freitext": {"ChatGPT"}, "d_zugang": {"firma"}, "d_geraet": {"firma"},
}
}
func TestAntragNewFormRendersFragebogen(t *testing.T) {
s, _, cookie := newAuthedTestServer(t)
resp := getWithCookie(t, s, cookie, "/antraege/neu")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
body := resp.Body.String()
for _, want := range []string{`name="titel"`, `name="b1"`, `name="c2_folge"`, `name="d_zugang"`} {
if !strings.Contains(body, want) {
t.Errorf("expected %q in the Fragebogen, got: %s", want, body)
}
}
}
func TestAntragCreateThenDetailShowsAnswers(t *testing.T) {
s, fs, cookie := newAuthedTestServer(t)
resp := postForm(t, s, cookie, "/antraege", fullAntragForm())
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
}
loc := resp.Header().Get("Location")
if !strings.HasPrefix(loc, "/antraege/") {
t.Fatalf("Location = %q, want a redirect to the new antrag", loc)
}
var antragID string
for id := range fs.antraege {
antragID = id
}
antrag, err := fs.GetAntrag(context.Background(), antragID)
if err != nil {
t.Fatalf("GetAntrag: %v", err)
}
if antrag.Status != "eingereicht" {
t.Fatalf("Status = %q, want eingereicht", antrag.Status)
}
var antworten map[string]any
if err := json.Unmarshal(antrag.Antworten, &antworten); err != nil {
t.Fatalf("Antworten unmarshal: %v", err)
}
if antworten["b1"] != "ja" {
t.Errorf("b1 = %v, want ja", antworten["b1"])
}
if antworten["c5"] != true {
t.Errorf("c5 = %v, want true", antworten["c5"])
}
detailResp := getWithCookie(t, s, cookie, loc)
if detailResp.Code != http.StatusOK {
t.Fatalf("detail status = %d, body: %s", detailResp.Code, detailResp.Body.String())
}
if !strings.Contains(detailResp.Body.String(), "Angebotstexte generieren") {
t.Errorf("expected the antrag title on the detail page, got: %s", detailResp.Body.String())
}
}
func TestAntragCreateRejectsMissingFields(t *testing.T) {
s, _, cookie := newAuthedTestServer(t)
resp := postForm(t, s, cookie, "/antraege", url.Values{"titel": {"Nur Titel"}})
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 for missing required fields", resp.Code)
}
}
func TestAntragListShowsOnlyOwnAntraege(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
if resp := postForm(t, s, cookieA, "/antraege", fullAntragForm()); resp.Code != http.StatusSeeOther {
t.Fatalf("create (A) status = %d, body: %s", resp.Code, resp.Body.String())
}
listA := getWithCookie(t, s, cookieA, "/antraege")
if !strings.Contains(listA.Body.String(), "Angebotstexte generieren") {
t.Errorf("expected Mandant A to see their own antrag, got: %s", listA.Body.String())
}
listB := getWithCookie(t, s, cookieB, "/antraege")
if strings.Contains(listB.Body.String(), "Angebotstexte generieren") {
t.Errorf("expected Mandant B to see no antrag, got: %s", listB.Body.String())
}
}
func TestAntragDetailRejectsForeignUser(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
createResp := postForm(t, s, cookieA, "/antraege", fullAntragForm())
antragURL := createResp.Header().Get("Location")
resp := getWithCookie(t, s, cookieB, antragURL)
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for a foreign user's antrag", resp.Code)
}
}
// fullAntragForm liefert b1=ja (Datenklasse personenbezogen) und
// c1..c3=nein (keine der verboten/hochrisiko/transparenzpflicht-
// Varianten trifft zu) — die Einstufung fällt damit auf die
// Auffangregel "minimal" zurück (siehe rules/kivo_einstufung.yaml).
func TestAntragCreateComputesBewertung(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
resp := postForm(t, s, cookie, "/antraege", fullAntragForm())
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
var antragID string
for id := range fs.antraege {
antragID = id
}
b, err := fs.GetLatestBewertungForAntrag(context.Background(), antragID)
if err != nil {
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
}
if b.Datenklasse != "personenbezogen" {
t.Errorf("Datenklasse = %q, want personenbezogen", b.Datenklasse)
}
if b.Einstufung != "minimal" {
t.Errorf("Einstufung = %q, want minimal", b.Einstufung)
}
if b.Verboten {
t.Error("Verboten = true, want false")
}
detail := getWithCookie(t, s, cookie, resp.Header().Get("Location"))
if !strings.Contains(detail.Body.String(), "Das System entscheidet nicht") {
t.Errorf("expected the mandatory disclaimer on the detail page, got: %s", detail.Body.String())
}
}
// TestAntragCreateVerbotenSkipsWerkzeugSearch prüft die K.-o.-Prüfung:
// c3=ja mit c3_art=social_scoring löst "verboten" aus (Art. 5 KI-VO) —
// dann darf keine Werkzeugsuche stattfinden (siehe CLAUDE.md,
// Bewertungslogik).
func TestAntragCreateVerbotenSkipsWerkzeugSearch(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
form := fullAntragForm()
form.Set("c3", "ja")
form.Set("c3_art", "social_scoring")
resp := postForm(t, s, cookie, "/antraege", form)
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
var antragID string
for id := range fs.antraege {
antragID = id
}
b, err := fs.GetLatestBewertungForAntrag(context.Background(), antragID)
if err != nil {
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
}
if !b.Verboten || b.Einstufung != "verboten" {
t.Fatalf("Bewertung = %+v, want Verboten=true, Einstufung=verboten", b)
}
if len(b.ZulaessigeWerkzeuge) != 0 || len(b.AusgeschlosseneWerkzeuge) != 0 {
t.Errorf("expected no werkzeug search for a verboten Bewertung, got %+v", b)
}
}
// TestAntragCreateFiltersWerkzeugeByAnforderungen prüft den harten
// Filter: personenbezogen verlangt avv_erforderlich — ein Werkzeug ohne
// AVV wird ausgeschlossen und im Ergebnis mit Begründung genannt statt
// stillschweigend wegzufallen, ein Werkzeug mit AVV bleibt zulässig.
func TestAntragCreateFiltersWerkzeugeByAnforderungen(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
fs.werkzeuge["werkzeug-ok"] = store.Werkzeug{
ID: "werkzeug-ok", Name: "ChatGPT Enterprise",
AVVVerfuegbar: true, TrainingStandard: true,
}
fs.werkzeuge["werkzeug-ohne-avv"] = store.Werkzeug{
ID: "werkzeug-ohne-avv", Name: "Freies KI-Tool",
AVVVerfuegbar: false, TrainingStandard: true,
}
resp := postForm(t, s, cookie, "/antraege", fullAntragForm())
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
var antragID string
for id := range fs.antraege {
antragID = id
}
b, err := fs.GetLatestBewertungForAntrag(context.Background(), antragID)
if err != nil {
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
}
if len(b.ZulaessigeWerkzeuge) != 1 || b.ZulaessigeWerkzeuge[0] != "werkzeug-ok" {
t.Errorf("ZulaessigeWerkzeuge = %v, want [werkzeug-ok]", b.ZulaessigeWerkzeuge)
}
if len(b.AusgeschlosseneWerkzeuge) != 1 || b.AusgeschlosseneWerkzeuge[0].WerkzeugID != "werkzeug-ohne-avv" {
t.Fatalf("AusgeschlosseneWerkzeuge = %+v, want [werkzeug-ohne-avv]", b.AusgeschlosseneWerkzeuge)
}
if got := b.AusgeschlosseneWerkzeuge[0].NichtErfuellt; len(got) != 1 || got[0] != "avv_erforderlich" {
t.Errorf("NichtErfuellt = %v, want [avv_erforderlich]", got)
}
detail := getWithCookie(t, s, cookie, resp.Header().Get("Location"))
body := detail.Body.String()
if !strings.Contains(body, "ChatGPT Enterprise") || !strings.Contains(body, "Freies KI-Tool") {
t.Errorf("expected both werkzeuge (by name) on the detail page, got: %s", body)
}
}
func TestBetreiberRedirectedAwayFromAntragPages(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
for _, path := range []string{"/antraege", "/antraege/neu"} {
resp := getWithCookie(t, s, betreiberCookie, path)
if resp.Code != http.StatusSeeOther {
t.Errorf("GET %s status = %d, want 303 redirect for a betreiber", path, resp.Code)
}
}
}

View File

@@ -26,10 +26,13 @@ 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 — die kommen über Einladungslink/CSV-Import/Mandantenlink
// (spätere Ausbaustufe), nicht über dieses Formular.
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 +42,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 +59,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 +93,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)
}

View File

@@ -0,0 +1,146 @@
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
AccountID string
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), AccountID: acc.ID, 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)
}
}

View 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)
}

View 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)
}
}

View File

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

View File

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

View File

@@ -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) {
@@ -21,252 +12,15 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
type indexData struct {
Title string
Nav navData
}
// handleIndex ist die Startseite nach der Anmeldung. Platzhalter für
// Phase 1 (Datenmodell/Regelwerk/Katalogstruktur) — der geführte
// Fragebogen (Ebene 2, "Antrag stellen") ist Phase 2 der Baureihenfolge.
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 {
data := indexData{Title: "Start", Nav: navFor(r)}
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")
if platform == "" || postType == "" || caption == "" {
http.Error(w, "Plattform, Beitragstyp 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",
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)
}

View 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)
}
}

View 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)
}

View 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)
}
}

View File

@@ -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,90 @@ 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). Beide Rollen sehen dieselbe Sicht; ob innerhalb der
// Seite tatsächlich entschieden werden darf, prüft der einzelne
// Handler (nur "verantwortlicher" hat Entscheidungsrecht). Wie bei
// requireBetreiber: 404 statt 403 bei falscher Rolle, damit ein
// angemeldeter, aber unprivilegierter Nutzer (z. B. "mitarbeiter")
// nicht erfährt, dass es die Seite überhaupt gibt.
func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := s.authenticate(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if user.Role != "verantwortlicher" && user.Role != "pruefer" {
http.Error(w, "nicht gefunden", http.StatusNotFound)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
}
}
// requireAdmin schützt die Mandanten-Verwaltung (Ebene 4 — Nutzer/
// Rollen für GENAU EINEN Mandanten, siehe CLAUDE.md). Wie bei
// requireBetreiber/requireFachebene: 404 statt 403 bei falscher Rolle.
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := s.authenticate(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if user.Role != "admin" {
http.Error(w, "nicht gefunden", http.StatusNotFound)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
}
}
// navData steuert die gemeinsame Navigation (layout.html, "nav"-Block).
// 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",
IsFachebene: role == "verantwortlicher" || role == "pruefer",
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,

View 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)
}
}

View 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)
}
}

View 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)
}
}

View File

@@ -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,65 +23,87 @@ var templatesFS embed.FS
//go:embed static/*
var staticFS embed.FS
// Extractor ist die Schnittstelle, die der Server für Stufe 1 braucht.
// *extract.Client erfüllt sie; Tests injizieren einen Fake statt echte
// Claude-API-Aufrufe zu machen.
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)
CreateAccount(ctx context.Context, name string) (store.Account, error)
GetAccount(ctx context.Context, id string) (store.Account, 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()
@@ -94,9 +114,43 @@ func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper
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 /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/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

654
internal/web/static/app.css Normal file
View File

@@ -0,0 +1,654 @@
/* Deklarix — eigenes, schlankes Stylesheet auf Basis der Design-Tokens
aus dem enconf Enterprise Light Theme (design/enterprise.css): gleiche
Marke (Primärblau #1677ff, Inter, Radius-Skala). Mobile-first:
Basis-Stile gelten fürs Telefon (Nav als aufklappbares <details>-Menü
ohne JavaScript), @media (min-width: 960px) verwandelt dieselbe
Nav-Markup-Struktur per CSS in eine feste Seitenleiste (siehe
.topbar/.app-nav) — kein separates Desktop-Template nötig. */
@import url('/static/inter.css');
:root {
--branding-primary: #1677ff;
--radius: 6px;
--radius-md: 8px;
--radius-lg: 10px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--color-bg: #f8fafc;
--color-text: #334155;
--color-heading: #0f172a;
--color-border: #e2e8f0;
--color-muted: #64748b;
--color-hoch: #b91c1c;
--color-hoch-bg: #fef2f2;
--color-mittel: #b45309;
--color-mittel-bg: #fffbeb;
--color-niedrig: #166534;
--color-niedrig-bg: #f0fdf4;
--sidebar-width: 220px;
--sidebar-bg: linear-gradient(180deg, #0b1426 0%, #101d33 50%, #0d1829 100%);
--sidebar-text: #b6c2d9;
--sidebar-text-hover: #ffffff;
--sidebar-hover-bg: rgba(255, 255, 255, 0.08);
}
* {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--color-bg);
color: var(--color-text);
font-size: 16px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 {
color: var(--color-heading);
font-weight: 600;
letter-spacing: -0.02em;
margin: 0 0 0.5em;
}
h1 {
font-size: 1.5rem;
}
p {
margin: 0 0 1em;
}
a {
color: var(--branding-primary);
}
/* Container: volle Breite + Innenabstand auf dem Handy, zentriert mit
fester Breite ab Tablet-Größe aufwärts. */
.page {
padding: 16px;
max-width: 640px;
margin: 0 auto;
}
/* ── Navigation ──────────────────────────────────────────────────────
Mobil: .topbar ist eine schmale, fixierte Kopfleiste mit einem
aufklappbaren <details>-Menü (siehe layout.html) — kein JavaScript,
das <details>-Element liefert Auf-/Zuklappen nativ. Ab Desktop-Breite
(siehe Media Query unten) wird dieselbe Markup-Struktur per CSS in
eine permanent sichtbare, dunkle Seitenleiste verwandelt. */
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #fff;
border-bottom: 1px solid var(--color-border);
}
.brand {
font-weight: 600;
color: var(--color-heading);
text-decoration: none;
letter-spacing: -0.01em;
}
/* Checkbox-Hack: eine unsichtbare Checkbox plus ein <label for=...>
als Klick-/Tap-Ziel steuern per CSS-Geschwister-Selektor, ob .app-nav
sichtbar ist — funktioniert ohne JavaScript und ohne die Falle von
<details> (siehe Kommentar in layout.html). */
.nav-toggle {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
.nav-toggle-label {
cursor: pointer;
padding: 8px 14px;
border: 1px solid var(--color-border);
border-radius: var(--radius);
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text);
}
.nav-toggle:focus-visible ~ .nav-toggle-label {
outline: 2px solid var(--branding-primary);
outline-offset: 1px;
}
.nav-toggle:checked ~ .nav-toggle-label {
border-color: var(--branding-primary);
color: var(--branding-primary);
}
.app-nav {
display: none;
position: absolute;
top: calc(100% + 6px);
right: 16px;
z-index: 20;
flex-direction: column;
gap: 2px;
min-width: 220px;
padding: 8px;
background: #fff;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
}
.nav-toggle:checked ~ .app-nav {
display: flex;
}
.app-nav a {
padding: 10px 12px;
border-radius: var(--radius);
color: var(--color-text);
font-size: 0.9375rem;
font-weight: 500;
text-decoration: none;
}
.app-nav a:hover {
background: var(--color-bg);
color: var(--branding-primary);
}
.app-nav form {
margin: 4px 0 0;
}
.app-nav button {
width: 100%;
margin-top: 0;
background: transparent;
color: var(--color-hoch);
border: 1px solid var(--color-hoch-bg);
box-shadow: none;
}
/* Formulare: großzügige Touch-Ziele (min. 44px Höhe), volle Breite auf
dem Handy. */
form {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 24px;
}
label {
font-weight: 500;
font-size: 0.875rem;
color: var(--color-heading);
margin-top: 12px;
}
input, select, textarea, button {
font: inherit;
font-size: 16px; /* verhindert Auto-Zoom beim Fokussieren auf iOS */
}
input, select, textarea {
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: #fff;
color: var(--color-text);
min-height: 44px;
width: 100%;
}
textarea {
min-height: 120px;
resize: vertical;
}
input:focus, select:focus, textarea:focus {
outline: 2px solid var(--branding-primary);
outline-offset: 1px;
border-color: var(--branding-primary);
}
button {
margin-top: 16px;
padding: 12px 20px;
min-height: 44px;
border: none;
border-radius: var(--radius);
background: var(--branding-primary);
color: #fff;
font-weight: 500;
cursor: pointer;
box-shadow: var(--shadow-sm);
}
button:hover {
filter: brightness(0.94);
}
button:active {
filter: brightness(0.88);
}
nav button {
margin-top: 0;
background: transparent;
color: var(--color-muted);
border: 1px solid var(--color-border);
box-shadow: none;
min-height: 36px;
padding: 6px 14px;
font-size: 0.875rem;
}
.hinweis {
font-size: 0.8125rem;
color: var(--color-muted);
margin-top: 4px;
}
.disclaimer {
font-size: 0.8125rem;
color: var(--color-muted);
border-top: 1px solid var(--color-border);
padding-top: 12px;
margin-top: 24px;
}
.fehler {
background: var(--color-hoch-bg);
color: var(--color-hoch);
border-radius: var(--radius);
padding: 12px;
font-size: 0.9375rem;
}
.rueckfrage {
background: var(--color-mittel-bg);
color: var(--color-mittel);
border-radius: var(--radius);
padding: 12px;
}
.keine-findings {
background: var(--color-niedrig-bg);
color: var(--color-niedrig);
border-radius: var(--radius);
padding: 12px;
}
.archiviert {
background: var(--color-niedrig-bg);
color: var(--color-niedrig);
border-radius: var(--radius);
padding: 12px;
}
.findings {
list-style: none;
margin: 0 0 16px;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.finding {
border-radius: var(--radius-md);
padding: 12px 14px;
box-shadow: var(--shadow);
}
.finding p {
margin: 6px 0 0;
font-size: 0.9375rem;
}
.finding-hoch {
background: var(--color-hoch-bg);
border-left: 4px solid var(--color-hoch);
}
.finding-mittel {
background: var(--color-mittel-bg);
border-left: 4px solid var(--color-mittel);
}
.finding-niedrig {
background: var(--color-niedrig-bg);
border-left: 4px solid var(--color-niedrig);
}
.status {
display: inline-block;
font-size: 0.75rem;
font-weight: 500;
padding: 2px 8px;
border-radius: var(--radius);
background: var(--color-border);
color: var(--color-muted);
}
.status-published {
background: var(--color-niedrig-bg);
color: var(--color-niedrig);
}
.status-checked,
.status-mittel {
background: var(--color-mittel-bg);
color: var(--color-mittel);
}
.beitraege-liste {
list-style: none;
margin: 0 0 16px;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.beitraege-liste li a,
/* Einträge ohne umschließenden Link (Zeilen mit Badge/Aktions-Buttons
statt Navigation, z. B. Nutzer-/Abteilungsliste) bekommen dieselbe
Karte direkt auf dem <li> — :has() unterscheidet beide Fälle, damit
ein verlinkter Eintrag nicht doppelt (li UND a) gerahmt wird. */
.beitraege-liste > li:not(:has(> a)) {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 12px 14px;
border-radius: var(--radius-md);
box-shadow: var(--shadow);
background: #fff;
color: var(--color-text);
text-decoration: none;
}
.beitraege-liste > li:not(:has(> a)) form {
margin: 0;
flex: 0 0 auto;
}
.beitraege-liste > li:not(:has(> a)) button {
margin-top: 0;
}
.beteiligte {
list-style: none;
margin: 0 0 16px;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.beteiligter {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: var(--radius-md);
box-shadow: var(--shadow);
background: #fff;
}
.beteiligter-kopf {
flex: 1 1 100%;
}
.beteiligter .rolle {
color: var(--color-muted);
font-weight: 400;
}
.beteiligter-form {
flex-direction: row;
align-items: center;
gap: 12px;
margin: 0;
flex: 1 1 auto;
}
.beteiligter-form label {
display: flex;
align-items: center;
gap: 4px;
font-weight: 400;
margin: 0;
}
.beteiligter-form input[type="checkbox"] {
width: auto;
min-height: 0;
}
.beteiligter form:last-child {
margin: 0;
}
button.entfernen {
margin: 0;
background: transparent;
color: var(--color-hoch);
border: 1px solid var(--color-hoch-bg);
box-shadow: none;
min-height: 36px;
padding: 6px 14px;
font-size: 0.875rem;
}
.admin-kacheln {
list-style: none;
margin: 0 0 16px;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.admin-kacheln a {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 14px 16px;
border-radius: var(--radius-md);
box-shadow: var(--shadow);
background: #fff;
color: var(--color-text);
text-decoration: none;
font-weight: 500;
}
.kanzlei-eintrag {
display: block;
padding: 12px 14px;
border-radius: var(--radius-md);
box-shadow: var(--shadow);
background: #fff;
}
.fragebogen fieldset {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 12px 14px;
margin: 12px 0;
}
.fragebogen legend {
font-weight: 500;
font-size: 0.9375rem;
padding: 0 4px;
}
.fragebogen fieldset label {
display: inline-flex;
align-items: center;
gap: 4px;
font-weight: 400;
margin: 4px 12px 4px 0;
}
.fragebogen fieldset input[type="radio"] {
width: auto;
min-height: 0;
}
/* Adaptive Folgefragen: rein clientseitig per :has() ein-/ausgeblendet,
kein JavaScript nötig. Siehe templates/antrag_neu.html — die "Ja"-
Antwort trägt eine eigene Klasse (c2-ja/c3-ja), damit sich die
jeweilige Folgefrage gezielt zuordnen lässt. */
.mit-folgefrage .folgefrage {
display: none;
margin-top: 8px;
}
.mit-folgefrage:has(.c2-ja input:checked) .folgefrage,
.mit-folgefrage:has(.c3-ja input:checked) .folgefrage {
display: block;
}
/* Tabellen (z. B. Register-Export-Ansicht) — bewusst horizontal
scrollbar in einem eigenen Wrapper statt schrumpfender Spalten: bei
neun Spalten wie im Register würde Schrumpfen nur unlesbaren Text
erzeugen, siehe .table-scroll in register_liste.html. */
table {
width: 100%;
border-collapse: collapse;
background: #fff;
border-radius: var(--radius-md);
overflow: hidden;
font-size: 0.875rem;
}
th, td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid var(--color-border);
white-space: nowrap;
}
th {
background: var(--color-bg);
color: var(--color-heading);
font-weight: 600;
font-size: 0.8125rem;
}
tbody tr:hover {
background: var(--color-bg);
}
.table-scroll {
overflow-x: auto;
box-shadow: var(--shadow);
border-radius: var(--radius-md);
margin-bottom: 16px;
}
/* Ab hier mehr Platz (Tablet) — der Container bekommt spürbaren
Rand statt volle Breite, sonst bleibt alles identisch. */
@media (min-width: 640px) {
.page {
padding: 32px 24px;
}
h1 {
font-size: 1.75rem;
}
}
/* Ab Desktop-Breite: dieselbe .topbar/.app-nav-Markup wird zu einer
permanent sichtbaren, dunklen Seitenleiste — kein separates
Desktop-Template, nur CSS. Der `details`/`summary`-Aufklapp-
Mechanismus wird hier überschrieben (Inhalt immer sichtbar,
summary versteckt), das Handy-Verhalten bleibt darunter unverändert. */
@media (min-width: 960px) {
.topbar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: var(--sidebar-width);
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
gap: 8px;
padding: 20px 16px;
background: var(--sidebar-bg);
border-bottom: none;
border-right: 1px solid rgba(255, 255, 255, 0.08);
z-index: 10;
}
.brand {
color: #fff;
font-size: 1.125rem;
margin-bottom: 12px;
}
.nav-toggle-label {
display: none;
}
.app-nav {
display: flex;
position: static;
min-width: 0;
padding: 0;
background: transparent;
border: none;
box-shadow: none;
}
.app-nav a {
color: var(--sidebar-text);
}
.app-nav a:hover {
background: var(--sidebar-hover-bg);
color: var(--sidebar-text-hover);
}
.app-nav button {
color: var(--sidebar-text);
border-color: rgba(255, 255, 255, 0.15);
}
.app-nav button:hover {
background: var(--sidebar-hover-bg);
color: var(--sidebar-text-hover);
filter: none;
}
.page {
margin-left: var(--sidebar-width);
max-width: 1100px;
padding: 32px 40px;
width: 100%;
}
}

Binary file not shown.

View File

@@ -0,0 +1,13 @@
/* Inter, selbst gehostet (aus enconf übernommen) — nur der "latin"-Subset
(U+0000-00FF u.a.), das deckt deutsche Umlaute und ß bereits ab, ohne
Zeichensätze für Kyrillisch/Griechisch/Vietnamesisch mitzuladen, die
Deklarix nicht braucht. Eine Datei für alle Schriftschnitte (variable
Font), daher font-weight als Bereich. */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url(/static/fonts/inter-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

View File

@@ -0,0 +1,32 @@
{{define "abteilung-liste"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<h1>Abteilungen</h1>
<p class="hinweis">Stammdaten für den Fragebogen (Feld "Abteilung").</p>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
{{if not .Abteilungen}}
<p class="hinweis">Noch keine Abteilungen.</p>
{{else}}
<ul class="beitraege-liste">
{{range .Abteilungen}}
<li>
{{.Name}}
<form method="post" action="/verwaltung/abteilungen/{{.ID}}/loeschen" style="display:inline" onsubmit="return confirm('Abteilung wirklich löschen?')">
<button type="submit" class="entfernen">Löschen</button>
</form>
</li>
{{end}}
</ul>
{{end}}
<form method="post" action="/verwaltung/abteilungen">
<label for="name">Neue Abteilung</label>
<input type="text" id="name" name="name" required>
<button type="submit">Anlegen</button>
</form>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,26 @@
{{define "antraege-liste"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<h1>Meine Anträge</h1>
{{if not .Antraege}}
<p class="hinweis">Noch keine Anträge gestellt.</p>
{{else}}
<ul class="beitraege-liste">
{{range .Antraege}}
<li>
<a href="/antraege/{{.ID}}">
<strong>{{.Titel}}</strong> · {{.Haeufigkeit}} · {{.CreatedAt}}
<span class="status status-{{.Status}}">{{.Status}}</span>
</a>
</li>
{{end}}
</ul>
{{end}}
<p><a href="/antraege/neu">Neuen Antrag stellen</a></p>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,24 @@
{{define "antrag-detail"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<p><a href="/antraege">&larr; Meine Anträge</a></p>
<h1>{{.Titel}}</h1>
<p class="hinweis">Eingereicht am {{.CreatedAt}} — Status: <span class="status status-{{.Status}}">{{.Status}}</span></p>
<h2>Vorhaben</h2>
<p><strong>Was soll die KI tun?</strong><br>{{.Beschreibung}}</p>
<p><strong>Was soll herauskommen?</strong><br>{{.Ergebnis}}</p>
<p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</p>
{{if .Bewertung}}
{{template "bewertung-block" .Bewertung}}
{{else}}
<p class="hinweis">Die Bewertung wird berechnet, sobald der Antrag eingereicht ist.</p>
{{end}}
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,122 @@
{{define "antrag-neu"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<h1>Antrag stellen</h1>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<form method="post" action="/antraege" class="fragebogen">
<h2>Vorhaben</h2>
<label for="titel">Kurzbezeichnung</label>
<input type="text" id="titel" name="titel" required>
<label for="beschreibung">Was soll die KI tun?</label>
<textarea id="beschreibung" name="beschreibung" rows="3" required></textarea>
<label for="ergebnis">Was soll herauskommen?</label>
<textarea id="ergebnis" name="ergebnis" rows="2" required></textarea>
<label for="haeufigkeit">Häufigkeit</label>
<select id="haeufigkeit" name="haeufigkeit" required>
<option value="einmalig">einmalig</option>
<option value="gelegentlich">gelegentlich</option>
<option value="taeglich">täglich</option>
<option value="automatisiert">automatisiert</option>
</select>
{{if .Abteilungen}}
<label for="abteilung_id">Abteilung</label>
<select id="abteilung_id" name="abteilung_id">
<option value="">— keine Angabe —</option>
{{range .Abteilungen}}
<option value="{{.ID}}">{{.Name}}</option>
{{end}}
</select>
{{end}}
<h2>Daten</h2>
<p class="hinweis">Bei Unsicherheit "Unsicher" wählen — das wird wie "Ja" behandelt.</p>
{{range .Datenfragen}}
<fieldset class="datenfrage">
<legend>{{.Label}}</legend>
<label><input type="radio" name="{{.Key}}" value="ja" required> Ja</label>
<label><input type="radio" name="{{.Key}}" value="nein"> Nein</label>
<label><input type="radio" name="{{.Key}}" value="unsicher"> Unsicher</label>
</fieldset>
{{end}}
<h2>Verwendung des Ergebnisses</h2>
<fieldset>
<legend>Wird das Ergebnis unverändert nach außen gegeben?</legend>
<label><input type="radio" name="c1" value="ja" required> Ja</label>
<label><input type="radio" name="c1" value="nein"> Nein</label>
</fieldset>
<fieldset class="mit-folgefrage">
<legend>Beeinflusst es eine Entscheidung über einen Menschen?</legend>
<label class="c2-ja"><input type="radio" name="c2" value="ja" required> Ja</label>
<label><input type="radio" name="c2" value="nein"> Nein</label>
<div class="folgefrage">
<label for="c2_folge">Welche Art von Entscheidung?</label>
<select id="c2_folge" name="c2_folge">
<option value="beschaeftigung">Einstellung, Beförderung, Kündigung</option>
<option value="kreditwuerdigkeit">Kreditwürdigkeit</option>
<option value="leistungsbewilligung">Leistungsbewilligung</option>
<option value="bildung">Zugang zu Bildung</option>
<option value="gesundheit">medizinische Beurteilung</option>
<option value="keine">keins davon</option>
</select>
</div>
</fieldset>
<fieldset class="mit-folgefrage">
<legend>Erkennt oder bewertet es Emotionen, Verhalten oder biometrische Merkmale?</legend>
<label class="c3-ja"><input type="radio" name="c3" value="ja" required> Ja</label>
<label><input type="radio" name="c3" value="nein"> Nein</label>
<div class="folgefrage">
<label for="c3_art">Welche Art?</label>
<select id="c3_art" name="c3_art">
<option value="emotionserkennung_arbeitsplatz">Emotionserkennung am Arbeitsplatz</option>
<option value="social_scoring">Social Scoring</option>
<option value="biometrische_kategorisierung">Biometrische Kategorisierung</option>
<option value="keine">etwas anderes</option>
</select>
</div>
</fieldset>
<fieldset>
<legend>Läuft es ohne dass ein Mensch das Ergebnis prüft?</legend>
<label><input type="radio" name="c4" value="ja" required> Ja</label>
<label><input type="radio" name="c4" value="nein"> Nein</label>
</fieldset>
<fieldset>
<legend>Merkt der Empfänger, dass ein Ergebnis von einer KI stammt?</legend>
<label><input type="radio" name="c5" value="ja" required> Ja</label>
<label><input type="radio" name="c5" value="nein"> Nein</label>
</fieldset>
<h2>Werkzeug</h2>
<label for="d_werkzeug_freitext">Haben Sie schon ein Werkzeug im Sinn? (optional)</label>
<input type="text" id="d_werkzeug_freitext" name="d_werkzeug_freitext">
<fieldset>
<legend>Firmenkonto oder privater Zugang?</legend>
<label><input type="radio" name="d_zugang" value="firma" required> Firmenkonto</label>
<label><input type="radio" name="d_zugang" value="privat"> Privater Zugang</label>
</fieldset>
<fieldset>
<legend>Auf Firmengerät oder Privatgerät?</legend>
<label><input type="radio" name="d_geraet" value="firma" required> Firmengerät</label>
<label><input type="radio" name="d_geraet" value="privat"> Privatgerät</label>
</fieldset>
<button type="submit">Antrag einreichen</button>
</form>
</div>
</body>
</html>
{{end}}

View File

@@ -1,13 +0,0 @@
{{define "archived"}}
<p class="archiviert">
Beitrag archiviert und mit RFC-3161-Zeitstempel versehen
({{.TimestampedAt}}).
</p>
<p>
<a href="{{.DossierURL}}">Nachweis-Dossier (PDF) herunterladen</a>
</p>
<p class="disclaimer">
Dieses Dossier dokumentiert die durchgeführte Prüfung. Es ist keine
Rechtsberatung und ersetzt keine anwaltliche Prüfung im Einzelfall.
</p>
{{end}}

View File

@@ -0,0 +1,22 @@
{{define "betreiber-account-detail"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<p><a href="/betreiber/accounts">&larr; Alle Accounts</a></p>
<h1>{{.Name}}</h1>
<p class="hinweis">Angelegt am {{.CreatedAt}}</p>
<h2>Nutzer</h2>
<ul class="beteiligte">
{{range .Users}}
<li class="beteiligter">
<div class="beteiligter-kopf">{{.Email}} <span class="rolle">({{.Role}})</span></div>
</li>
{{end}}
</ul>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,25 @@
{{define "betreiber-accounts"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<p><a href="/betreiber">&larr; Plattform</a></p>
<h1>Accounts</h1>
{{if not .Accounts}}
<p class="hinweis">Noch keine Accounts.</p>
{{else}}
<ul class="beitraege-liste">
{{range .Accounts}}
<li>
<a href="/betreiber/accounts/{{.ID}}">
<strong>{{.Name}}</strong> · {{.CreatedAt}}
</a>
</li>
{{end}}
</ul>
{{end}}
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,24 @@
{{define "betreiber-audit-log"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<p><a href="/betreiber">&larr; Plattform</a></p>
<h1>Audit-Log</h1>
{{if not .Entries}}
<p class="hinweis">Noch keine Einträge.</p>
{{else}}
<ul class="beteiligte">
{{range .Entries}}
<li class="beteiligter">
<div class="beteiligter-kopf">{{.CreatedAt}} — <strong>{{.Action}}</strong> ({{.TargetType}} {{.TargetID}})</div>
{{if .Details}}<p>{{.Details}}</p>{{end}}
</li>
{{end}}
</ul>
{{end}}
</div>
</body>
</html>
{{end}}

Some files were not shown because too many files have changed in this diff Show More