Compare commits

...

22 Commits

Author SHA1 Message Date
noroot
3ec57f2786 fix: admin bekommt Fachebene-Rechte (Posteingang, Entscheiden, Register, Wiedervorlage)
Löst einen Blocker im Kernablauf: eine frisch registrierte Firma hat
nur einen admin-Login und konnte damit bislang keinen einzigen
eingereichten Antrag sehen oder entscheiden, weil requireFachebene nur
verantwortlicher/pruefer durchließ. Die Spezifikation will admin+
KI-Verantwortlicher ohnehin auf derselben Person (siehe CLAUDE.md,
Offene Punkte) — statt eines Datenmodell-Umbaus (roles-Array oder
zwei app_user-Zeilen pro Person) bekommt admin jetzt pragmatisch
dieselben Fachebene-Rechte wie verantwortlicher (hatEntscheidungsrecht
in fachebene_handlers.go). pruefer bleibt unverändert nur lesend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 17:58:06 +02:00
noroot
6884f28109 feat: CSV-Import für Mitarbeiter
Schließt den zweiten Punkt der geplanten Onboarding-Reihenfolge. Admin
lädt eine CSV mit Spalten email (Pflicht) und role (optional, Standard
mitarbeiter) unter GET /verwaltung/nutzer/import hoch. Wie beim
einzelnen Nutzer-Anlegen gibt es keine Einladungsmail — pro Zeile wird
ein Einmalpasswort erzeugt und nur auf der Ergebnisseite direkt nach
dem Import angezeigt, danach nirgendwo mehr abrufbar. Kein
Alles-oder-nichts-Import: eine ungültige Zeile blockiert nicht die
übrigen, jede Zeile bekommt ihr eigenes Ergebnis.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 17:52:02 +02:00
noroot
b91a58ccba feat: Einladungslink für die Mitarbeiter-Selbstanmeldung
Schließt den ersten Punkt der geplanten Onboarding-Reihenfolge (siehe
CLAUDE.md): ein Admin sieht unter GET /verwaltung/einladung einen
Sammellink (account.einladung_token, Migration 0013), den beliebig
viele Mitarbeitende nutzen können, um sich selbst mit eigenem Passwort
anzumelden (GET/POST /einladung/{token}, öffentlich, Rolle
mitarbeiter). Kein Ablaufdatum, aber per Admin jederzeit erneuerbar —
das macht den alten Link sofort ungültig, falls er versehentlich
außerhalb des Unternehmens geteilt wurde. Keine E-Mail-Bestätigung
(bewusst konsistent mit dem Rest des Produkts, das noch keine
E-Mail-Infrastruktur hat).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 17:41:36 +02:00
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
144 changed files with 9788 additions and 4652 deletions

739
CLAUDE.md
View File

@@ -1,102 +1,402 @@
# Deklarix
> Prüfung und Nachweis für Werbe-Content. Pre-Publish-Kennzeichnungsprüfung
> und revisionssichere Archivierung für Creator, Agenturen, Marken und
> Kanzleien. `deklarix.de` / `deklarix.com`
> KI-Antragsprüfung für Unternehmen: Mitarbeitende beschreiben ein
> KI-Vorhaben in eigenen Worten, das System leitet Datenklasse,
> KI-VO-Einstufung und Anforderungsprofil ab, gleicht sie gegen einen
> gepflegten Werkzeugkatalog ab und erzeugt einen begründeten
> Entscheidungsvorschlag. Ein Mensch entscheidet, das System bereitet
> nur vor.
---
## Was das Projekt ist
## Produktwechsel (2026-08-28)
Drei Funktionen, mehr nicht:
1. **Pre-Publish-Prüfung** — Caption, Standbild und Vertragslage rein,
Risikobewertung mit konkreten Korrekturvorschlägen raus.
2. **Beweissicherung** — jeder veröffentlichte Beitrag wird mit Hash und
Zeitstempel archiviert: Screenshot, Caption, sichtbare Kennzeichnung,
verknüpfter Vertrag, Briefing.
3. **Verantwortungsmatrix** — wer hat was vorgegeben, wer hat freigegeben,
wer schuldet welche Prüfung (Creator / Agentur / Marke).
**Wer nutzt es, wer zahlt:** Creator nutzen es täglich, zahlen wenig
(Reichweite/Referenz). Agenturen und Marken zahlen richtig — sie haften
über § 8 Abs. 2 UWG mit und brauchen Freigabe-Workflow plus
Nachweisarchiv. Kanzleien lizenzieren Deklarix als Whitelabel für ihre
Mandanten (zahlen für Software, nicht für Mandantenzugang); das
Kanzlei-Verzeichnis im Produkt bleibt kostenlos.
**Rollen von Anfang an.** Eine Submission gehört nicht einem Nutzer,
sondern hat Beteiligte mit Rollen (`creator`, `agentur`, `marke`,
`kanzlei`). Alle vier existieren im Datenmodell ab dem ersten Commit.
**Kein Demo-Shortcut.** Es wird direkt produktionsreif entwickelt —
keine reduzierte Zwischenstufe mit manuell eingetragenen Daten oder ohne
Login als Selbstzweck.
Deklarix war ursprünglich eine Pre-Publish-Kennzeichnungsprüfung für
Werbe-Content (UWG/MStV) für Creator, Agenturen, Marken und Kanzleien.
Dieser Scope wurde **komplett verworfen** und durch das oben
beschriebene Produkt ersetzt — kein Feature-Zusatz, ein Produktwechsel.
Wiederverwendet wurden nur produktunabhängige Bausteine: Auth/Session/
Account-Mandantentrennung, das Prinzip "Regelwerk als versionierte
YAML-Dateien, kein ML für die Bewertung", die apt/systemd/Gitea-
Deployment-Pipeline und das Design-System. Alles Werberecht-Spezifische
(Kennzeichnungsregeln, Beweisarchiv für Social-Media-Posts, Instagram/
TikTok-OAuth, Insights-Erinnerung) wurde entfernt, nicht nur ersetzt —
siehe Migration `0008_pivot_ki_antragspruefung` für den genauen Schnitt.
---
## Kernprinzip: Fakten trennen von Urteil
## Grundregel
**Stufe 1 bestimmt Fakten (deterministisch, kein LLM). Das Regelwerk in
Go fällt das Urteil.**
**Das System entscheidet nicht. Es bereitet vor.** Der/die
Verantwortliche bestätigt, ändert oder überschreibt einen
Bewertungsvorschlag — mit Begründungsfeld bei Abweichung. Jede
Entscheidung wird als Registereintrag protokolliert. Die Bewertung wird
**nie** ohne ihre vollständige Herleitung angezeigt (welche Antwort
welche Anforderung ausgelöst hat) — ohne das ist ein Vorschlag im Audit
wertlos.
- **Stufe 1 — Extraktion (`internal/extract`, regelbasiert):** Ob eine
Gegenleistung vorlag, kann aus dem Text nicht zuverlässig erraten
werden — ein verschwiegener bezahlter Post sieht textlich identisch
aus wie ein echter organischer Post. Deshalb gibt der/die Einreichende
die Gegenleistung selbst an (Formularfeld). Was sich objektiv aus der
Caption bestimmen lässt, wird per Zeichenketten-Logik erkannt: ob ein
Kennzeichnungswort vorkommt, welcher Wortlaut, und ob es vor der
plattformspezifischen "mehr anzeigen"-Kürzung steht. Kein externer
Dienst, keine API-Kosten, kein Sprachmodell — daher auch keine
Interpretation kreativer/impliziter Formulierungen; was nicht über ein
bekanntes Kennzeichnungswort läuft, wird nicht erkannt.
- **Stufe 2 — Bewertung (Go, deterministisch):** Regelwerk als
versionierte YAML-Dateien (`rules/`). Jedes Finding referenziert
Regel-ID und Version (z. B. `WK-004 v3`). Regeln sind
plattformunabhängig — UWG/MStV gelten überall gleich, nur
Darstellungsdetails unterscheiden sich; die gehören in
Regelbedingungen/Extraktion, nie in separate Regelsätze pro Plattform.
**Absolute Regel:** Fundstellen stehen ausschließlich in den kuratierten
YAML-Regeln, nie in Freitext oder Extraktionslogik erfunden. Eine
halluzinierte Fundstelle ist in diesem Markt ein Totalschaden.
**Historie:** Ursprünglich war Stufe 1 als Claude-API-Aufruf geplant
(Kennzeichnung *und* Gegenleistung vom Modell erraten). Das wurde
bewusst verworfen — der Nutzer will keine laufende Anthropic-Abhängigkeit
für Deklarix selbst (getrennt von Claude Code, mit dem an Deklarix
programmiert wird). Die Idee "LLM sieht Kontext-Hinweise wie
Rabattcodes/Affiliate-Links, die reines Keyword-Matching übersieht"
bleibt ein späterer Ausbaupunkt, kein aktueller Blocker.
Regelbasiert, nicht ML: die Ableitungsregeln (Datenklasse, KI-VO-
Einstufung, Anforderungsprofil) liegen als versionierte YAML-Dateien
vor, nicht im Code — ein fachlich Verantwortlicher soll sie anpassen
können, ohne Go-Code anzufassen. Ein LLM ist v1 höchstens optional zur
Zweckerkennung aus dem Freitext vorgesehen (Textgenerierung/Übersetzung/
Code/Bild/Transkription/Recherche erkennen), nie für die Einstufung
selbst — und auch dort nur als Vorschlag, den der Mensch bestätigt.
---
## Stack
## Fünf Ebenen, fünf Rollen
Bewusst langweilig, ein Binary, lokal lauffähig:
Die Oberfläche gliedert sich in fünf Ebenen mit steigender Sensibilität.
Vier Mandanten-Rollen sind eine Voreinstellung, kein fest verdrahtetes
Modell (spätere Ausbaustufe: konfigurierbare Rollen) — `betreiber` ist
technisch getrennt vom Mandantenbereich, keine Voreinstellung.
| Bereich | Wahl |
|---|---|
| Sprache | Go 1.26, Standard-Library wo möglich |
| Web | `net/http` + `html/template` + htmx, kein Frontend-Build, kein React |
| Datenbank | PostgreSQL über `pgx`, Migrationen über `golang-migrate` |
| PDF | serverseitig erzeugt |
| Zeitstempel | RFC-3161 von einer öffentlichen TSA |
| Extraktion | regelbasiert, `internal/extract`, keine externe Abhängigkeit |
| Design | `design/enterprise.css` (Enterprise Light Theme, enconf-Pattern, Primärfarbe `#1677ff`) |
| Ebene | Zugang | Rolle(n) | Zweck |
|---|---|---|---|
| 1 — Öffentlich | ohne Konto | — | Anmeldung, Passwort zurücksetzen, Einladung annehmen, Antrag über Mandantenlink |
| 2 — Mitarbeiter | Konto nötig | `mitarbeiter` | Antrag stellen, eigene Anträge verfolgen, Rückfragen beantworten, freigegebene Werkzeuge einsehen. Bewusst schlank — dauert der Antrag zu lange, wird er umgangen |
| 3 — Fachebene | Konto nötig | `verantwortlicher` (Entscheidungsrecht), `pruefer` (identische Sicht, kein Entscheidungsrecht) | Posteingang offener Anträge, Bewertungsvorschlag mit Herleitung, entscheiden, Register, Berichte/Exporte, Audit-Log lesend |
| 4 — Mandanten-Verwaltung | Konto nötig | `admin` | Nutzer/Rollen, Abteilungen, Anmeldeverfahren, eigene Werkzeug-Freigaben/-Sperrungen, Rechnungsdaten — bezogen auf GENAU EINEN Mandanten |
| 5 — Plattform | Konto nötig, technisch getrennt | `betreiber` | Werkzeugkatalog, Regelwerk-Versionierung, Mandantenverwaltung/Freischaltung, Nutzungssicht — Netcell-IT-Personal, plattformweit über alle Mandanten |
Kein Docker für die App selbst, kein React, keine Queue. Neue
Abhängigkeiten nur nach Rückfrage.
**Funktionstrennung bleibt bestehen**, auch wenn in kleinen Firmen
dieselbe Person Admin und Verantwortlicher ist — das sind zwei Rollen
auf demselben Menschen, keine verschmolzene Rolle. `admin` (Ebene 4,
Mandanten-Verwaltung für den eigenen Account) und `betreiber` (Ebene 5,
Plattform-Betrieb für Netcell-IT über alle Mandanten) sind nicht
dasselbe, auch wenn beide "Admin"-artige Rechte haben — nur `betreiber`
ist der Nachfolger dessen, was früher (vor dem Produktwechsel)
`admin` hieß. **Praktische Umsetzung dieser Doppelrolle:** `admin`
bekommt zusätzlich dieselben Fachebene-Rechte wie `verantwortlicher`
(siehe `requireFachebene`/`hatEntscheidungsrecht`) — pragmatisch gelöst
ohne Datenmodell-Umbau, siehe Offene Punkte weiter unten.
**Warum Postgres statt SQLite:** Das Produkt läuft von Anfang an für
hunderte gleichzeitige Nutzer über mehrere Mandanten (Creator, Agentur,
Marke, Kanzlei). Ein revisionssicheres Beweisarchiv braucht robuste
Concurrent-Writes sowie Point-in-Time-Recovery/Replikation — das ist mit
SQLites Single-Writer-Modell nicht sauber zu bauen. Gehostet wird
Managed Postgres in der EU (DSGVO).
Rechte werden **als Prüfung an jeder Aktion** durchgesetzt (Middleware
je Handler), nicht als grob unterschiedene Seitenbereiche.
**Pfad:** `/var/www/deklarix` | **Git:** `https://git.netcell-it.de/projekte/deklarix` | **Branch:** `main`
**Ebene 4 ist inzwischen zu großen Teilen umgesetzt** (Anspruch: alles,
was im Datenmodell existiert, muss über das Frontend steuerbar sein,
nicht nur einsehbar — Deklarix soll ein buchbarer Service werden, ein
reiner Anzeige-Screen reicht nicht):
- **Nutzerverwaltung** (`internal/web/admin_handlers.go`, Middleware
`requireAdmin`): `GET /verwaltung/nutzer` listet alle Logins des
eigenen Mandanten mit Status (aktiv/deaktiviert), `GET/POST
/verwaltung/nutzer/neu` legt einen weiteren Login mit einer der vier
Mandanten-Rollen an (`mitarbeiter`, `verantwortlicher`, `pruefer`,
`admin``betreiber` kann kein Mandanten-Admin vergeben, das ist
Ebene 5). Es gibt noch **keine** Einladungsmail — der Admin setzt das
Initialpasswort direkt im Formular. Nutzer werden **nicht gelöscht**
(`app_user` wird von `antrag`/`entscheidung`/`audit_log` per Foreign
Key referenziert — ein Hard-Delete würde die Historie zerstören),
sondern über `POST /verwaltung/nutzer/{id}/deaktivieren` bzw.
`.../aktivieren` (de-)aktiviert (Spalte `app_user.active`, Migration
0012). Ein deaktivierter Login kann sich nicht mehr anmelden
(`handleLogin` prüft `Active` erst NACH der Passwortprüfung, um keine
Kontoexistenz zu verraten) und verliert eine bereits laufende Sitzung
sofort (`authenticate`-Middleware prüft `Active` bei jedem Request).
Ein Admin kann sich nicht selbst deaktivieren (Aussperr-Schutz).
- **Abteilungen** (`GET /verwaltung/abteilungen`, anlegen + löschen):
reine Stammdaten für das Fragebogen-Feld "Abteilung" — ohne diese
Seite blieb die Abteilung-Auswahl im Fragebogen faktisch leer und
unbenutzbar, das war kein Komfort-, sondern ein Funktionsdefizit.
- **Eigene Werkzeug-Sperrungen und -Einträge** (`internal/web/mandant_werkzeug_handlers.go`,
`GET /verwaltung/werkzeuge`): ein Mandant kann einen zentralen
Katalogeintrag für sich sperren/entsperren (`werkzeug_sperre`), ohne
den zentralen Katalog selbst zu verändern — das bleibt Sache der
Plattform (Ebene 5, `betreiber_werkzeug_handlers.go`). Zusätzlich kann
ein Mandant eigene, nur für sich sichtbare Katalogeinträge anlegen/
bearbeiten/löschen (`GET/POST /verwaltung/werkzeuge/neu`, `GET/POST
/verwaltung/werkzeuge/{id}`, `POST /verwaltung/werkzeuge/{id}/loeschen`
`werkzeug.account_id` = der eigene Account). Nutzt dasselbe Formular
wie der zentrale Katalog (`werkzeugFormData`/`betreiber-werkzeug-form.html`,
ein `ActionBase`-Feld unterscheidet Betreiber- von Mandanten-Ziel-URL)
— ein zentraler oder fremder Eintrag ist über diese Route nicht
erreichbar (404).
**Weiterhin nicht gebaut:** Anmeldeverfahren-Konfiguration, Rechnungsdaten/
Abrechnung (kein Abo-System, bewusst "Nicht bauen v1"), Account-
Verwaltung durch den Betreiber (Ebene 5 zeigt Accounts nur lesend an —
Bearbeiten/Sperren hängt an der noch nicht getroffenen Abrechnungs-/
Freischaltungs-Architektur, siehe Offene Punkte: `account.verified`
wurde beim Produktwechsel sogar entfernt).
**Mandantenfähigkeit:** jede Tabelle trägt `account_id`. Aktuell wird
Isolation in der Anwendungsschicht erzwungen (Handler vergleichen
`AccountID` gegen den angemeldeten Account, wie bisher) — die
Zielvorgabe "Isolation auf Datenbankebene erzwungen" (Postgres Row-
Level Security) ist eine bewusst noch nicht getroffene Entscheidung,
siehe Offene Punkte unten.
---
## Fragebogen (Ebene 2, "Antrag stellen")
Adaptiv: Folgefragen erscheinen nur, wenn die Antwort sie auslöst.
Alltagssprache, keine Fachbegriffe — die stehen nur in der Ableitung.
**A — Vorhaben:** `titel`, `beschreibung` (Freitext: was soll die KI
tun?), `ergebnis` (Freitext: was soll herauskommen?), `haeufigkeit`
(einmalig/gelegentlich/täglich/automatisiert), `abteilung` (aus
Stammdaten).
**B — Daten** (bestimmt die Datenklasse). Mehrfachauswahl Ja/Nein/
Unsicher, "Unsicher" zählt wie "Ja": Namen/E-Mail/Personenangaben (B1),
Gesundheit/Religion/Herkunft/Gewerkschaft/Sexualleben (B2), Patienten-/
Mandanten-/Versichertendaten (B3), Bewerbungs-/Personal-/
Leistungsdaten Beschäftigter (B4), von Kunden anvertraute Daten (B5),
Konstruktions-/Kalkulations-/Vertragsunterlagen (B6), nur allgemein
zugängliche/erfundene Inhalte (B7).
**C — Verwendung des Ergebnisses** (bestimmt die KI-VO-Einstufung):
geht das Ergebnis unverändert nach außen (C1)? beeinflusst es eine
Entscheidung über einen Menschen (C2, Folgefrage: Einstellung/
Beförderung/Kündigung, Kreditwürdigkeit, Leistungsbewilligung, Zugang
zu Bildung, medizinische Beurteilung, keins davon)? erkennt/bewertet es
Emotionen, Verhalten oder biometrische Merkmale (C3)? läuft es ohne
menschliche Prüfung (C4)? merkt der Empfänger, dass es von einer KI
stammt (C5)?
**D — Werkzeug:** ggf. schon ein Werkzeug im Sinn (optional, Freitext),
Firmen- oder privater Zugang, Firmen- oder Privatgerät.
---
## Ableitungen (`rules/*.yaml`, `internal/rules`)
**Datenklasse** (`rules/datenklasse.yaml`) — höchste zutreffende Stufe
gewinnt: `oeffentlich` (B7) < `intern` (B6) < `auftragsdaten` (B5) <
`personenbezogen` (B1, B4) < `berufsgeheimnis` (B3, § 203 StGB) <
`besondere_kategorie` (B2, Art. 9 DSGVO). **Die Rangfolge ist eine
Annahme** (Spezifikation nennt keine expliziten Ränge) — siehe
`rules/OPEN.md`, vor Produktiveinsatz mit dem/der Datenschutz-
beauftragten bestätigen.
**KI-VO-Einstufung** (`rules/kivo_einstufung.yaml`) — Prüfreihenfolge,
erste zutreffende Stufe gewinnt: `verboten` (Art. 5 KI-VO: C3 mit
Emotionserkennung am Arbeitsplatz, Social Scoring oder biometrischer
Kategorisierung) → `hochrisiko` (C2 mit Beschäftigung/Kreditwürdigkeit/
Leistungsbewilligung/Bildung/Gesundheit) → `transparenzpflicht` (C1 ja
und C5 nein) → `minimal` (alles übrige). Die genauen Fragebogen-Felder
für die drei "verboten"-Varianten sind noch nicht abschließend
spezifiziert — siehe `rules/OPEN.md`.
**Anforderungsprofil** (`rules/anforderungen.yaml`) — aus Datenklasse
und/oder Einstufung abgeleitete Menge von Anforderungen an ein
Werkzeug: `avv_erforderlich`, `eu_verarbeitung`,
`kein_training_auf_eingabe`, `loeschfrist_max_tage`,
`menschliche_aufsicht`, `kennzeichnungspflicht`, `dsfa_erforderlich`.
`internal/rules` lädt und validiert diese drei Dateien (eindeutige IDs,
eindeutige Ränge, jede Anforderung braucht mindestens einen Auslöser)
und wertet sie seit Schritt 3 auch aus: `EvaluateDatenklasse`,
`EvaluateEinstufung`, `IstVerboten`, `DeriveAnforderungen` operieren auf
`rules.Antworten` (geparst aus `antrag.antworten` via `ParseAntworten`)
— reine, für sich getestete Funktionen ohne DB-/Web-Zugriff.
`FilterWerkzeuge`/`ErfuelltAnforderung` filtern einen Werkzeugkatalog
hart gegen die abgeleiteten Anforderungen (`WerkzeugEigenschaften` ist
ein schlanker, von `store.Werkzeug` unabhängiger Typ, damit
`internal/rules` weiterhin ohne `internal/store` auskommt). Seit
Schritt 4 wird das auch tatsächlich aufgerufen: `POST /antraege`
berechnet nach dem Einreichen sofort eine `bewertung` (append-only,
`internal/web/antrag_handlers.go:bewerteAntrag`) — bei `verboten`
(K.-o.-Prüfung) ohne Werkzeugsuche — und `GET /antraege/{id}` zeigt sie
inklusive Herleitung, RegelwerkVersion/KatalogVersion und dem
Pflicht-Hinweis "Das System entscheidet nicht. Es bereitet vor."
---
## Werkzeugkatalog (`werkzeug`, `werkzeug_sperre`)
Der eigentliche Wert des Produkts. Zentral gepflegt (Ebene 5,
`account_id IS NULL`), für alle Mandanten identisch sichtbar; jeder
Mandant kann zusätzlich eigene Einträge (`account_id` gesetzt) und
Sperrungen zentraler Einträge (`werkzeug_sperre`) führen, ohne den
zentralen Katalog zu verändern.
**Pflicht:** `letzte_pruefung` und `quelle` sind NOT NULL — jede
Zusicherung im Katalog (AVV verfügbar, Verarbeitungsort, Training-Opt-
out, Zertifizierungen) muss belegbar sein, sonst haftet Deklarix für
eine Aussage, die nicht nachweisbar ist. Einträge älter als 180 Tage
sollen im Ergebnis als "nicht aktuell geprüft" markiert werden (noch
nicht in der UI umgesetzt, siehe Offene Punkte).
`CurrentKatalogVersion` liefert eine reproduzierbare Kennung des
aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird in
jeder Bewertung/Entscheidung eingefroren, damit im Audit nachvollziehbar
bleibt, mit welchem Katalogstand ein Vorschlag erzeugt wurde.
---
## Bewertungslogik (geplant, Schritt 3+)
1. **K.-o.-Prüfung.** Einstufung `verboten` → sofort Ablehnung (Art. 5
KI-VO), keine Werkzeugsuche.
2. **Harte Filter.** Werkzeuge aussortieren, die eine Pflichtanforderung
nicht erfüllen — Grund je Werkzeug festhalten, auch aussortierte
Werkzeuge werden im Ergebnis mit Begründung gezeigt.
3. **Zweckabgleich.** Aus `beschreibung`/`ergebnis` den Zweck ableiten
(Textgenerierung, Übersetzung, Code, Bild, Transkription, Recherche)
und gegen `geeignete_zwecke` filtern.
4. **Rangfolge.** Verbleibende Werkzeuge sortieren: EU-Verarbeitung,
Training standardmäßig aus, kurze Aufbewahrung, Aktualität der
Prüfung.
5. **Auflagen ableiten.** Statt Ablehnung Bedingungen erzeugen
(Pseudonymisierung, keine Klarnamen, Vier-Augen-Prüfung,
Kennzeichnung, Aufbewahrung begrenzen).
6. **Vorschlag erzeugen.** Empfehlung, Alternativen, Auflagen,
vollständige Herleitung.
Jede Bewertung wird als eigene Zeile gespeichert (append-only, wie
`finding`/`extraction` im alten Produkt) — eine neue Bewertung ersetzt
nicht die alte, sie ergänzt sie, damit Nachvollziehbarkeit auch bei
mehrfacher Neubewertung eines Antrags erhalten bleibt.
---
## Entscheidung, Register, Wiedervorlage (Schritt 5-7 erledigt)
**Schritt 5 (Entscheidung, Snapshot, Audit-Log) ist umgesetzt.** Ebene 3
(Fachebene) hat einen Posteingang (`GET /faelle`, alle offenen —
Status "eingereicht" — Anträge des Mandanten, nicht nur die eigenen)
und eine Entscheiden-Seite (`GET /faelle/{id}`, `POST
/faelle/{id}/entscheiden`, siehe `internal/web/fachebene_handlers.go`).
Rollen `verantwortlicher` und `pruefer` sehen dieselbe Sicht
(`requireFachebene`), nur `verantwortlicher` hat Entscheidungsrecht —
ein `pruefer`-POST auf `/entscheiden` bekommt 403 (er darf wissen, dass
der Fall existiert, nur nicht entscheiden; anders als bei
`requireFachebene`/`requireBetreiber`, die bei falscher Rolle 404
liefern, um die Existenz der Seite selbst zu verbergen).
Der/die Verantwortliche wählt: genehmigt / genehmigt mit Auflagen /
abgelehnt / Rückfrage. **Vorschlag-Vergleich:** `vorschlagFuer` leitet
aus der Bewertung ab, was das System vorschlagen würde (`abgelehnt` bei
`verboten`, sonst `genehmigt` wenn mindestens ein zulässiges Werkzeug
existiert, sonst `rueckfrage`) — weicht die tatsächliche Entscheidung
davon ab, ist das Begründungsfeld Pflicht (sonst 400). **Snapshot bei
Entscheidung:** `entscheidung.werkzeug_snapshot` (JSONB) friert den
vollständigen `store.Werkzeug`-Datensatz zum Entscheidungszeitpunkt ein
— ein späterer Katalog-Wandel darf nicht rückwirkend verändern, worauf
eine Entscheidung beruhte. Bei "genehmigt"/"genehmigt mit Auflagen" ist
ein Werkzeug aus der zulässigen Liste der Bewertung Pflicht (hart
geprüft, kein Override eines ausgeschlossenen Werkzeugs). `entscheidung`
ist append-only (Migration 0010) wie `bewertung`; jede Entscheidung
erzeugt zusätzlich einen `audit_log`-Eintrag (Action
`antrag_entschieden`).
Genehmigungen erhalten ein Ablaufdatum (`gueltig_bis`: 12 Monate, bei
`hochrisiko` 6 — `gueltigkeitFuer`).
**Schritt 7 (Wiedervorlage, Katalog-Benachrichtigung) ist umgesetzt**
(`internal/web/wiedervorlage_handlers.go`, `GET /wiedervorlage`, Ebene
3). Es gibt keine E-Mail-Infrastruktur — "Benachrichtigung" ist bewusst
eine In-App-Liste, kein Versand. Eine aktive Genehmigung erscheint dort,
wenn mindestens einer zutrifft: (1) `gueltig_bis` ist erreicht oder
liegt innerhalb von 30 Tagen, (2) das zugesagte Werkzeug wurde aus dem
Katalog entfernt, (3) `werkzeugDiff` erkennt eine Abweichung zwischen
dem eingefrorenen `werkzeug_snapshot` und dem aktuellen Katalogeintrag
bei AVV-Verfügbarkeit, Training-Standard oder Verarbeitungsort. Keine
gefundene Abweichung → die Genehmigung erscheint nicht (kein stiller
Blanko-Eintrag für jede Genehmigung). `store.ListAktiveGenehmigungenForAccount`
liefert dafür alle `genehmigt`/`genehmigt_mit_auflagen`-Entscheidungen
eines Mandanten (Join über `antrag.account_id`, da `entscheidung`
selbst kein `account_id` trägt).
**Schritt 6 (Registereintrag, Export) ist umgesetzt.** Jede Genehmigung
erzeugt automatisch einen `registereintrag` (append-only, Migration
0011): Zweck (`antrag.titel`), Abteilung, Werkzeug, Datenklasse,
Einstufung, Auflagen (die Anforderungen der Bewertung), Verantwortliche/r,
Entscheidungsdatum, Gültigkeit — siehe `internal/web/register_handlers.go`.
`GET /registereintraege` (Ebene 3, `verantwortlicher`/`pruefer`) zeigt
sie, `GET /registereintraege/export.{csv,pdf}` exportiert. Abgelehnte
Entscheidungen und Rückfragen erzeugen bewusst KEINEN Registereintrag —
das Register ist eine Genehmigungsliste, kein vollständiges Antragslog
(das leistet bereits `GET /faelle`).
---
## Onboarding
**Firma (Ebene 1, öffentlich, `POST /register`):** Registrierungsformular
mit Firmendaten → Testzugang sofort aktiv → Freischaltung des
Bezahlbetriebs nach Prüfung durch den Betreiber (letzteres noch nicht
gebaut). Beim Anlegen entstehen automatisch: Account, erster Nutzer mit
Rolle `admin` (siehe Rollentabelle — vereinfacht v1: nicht zusätzlich
separat als `verantwortlicher` angelegt, siehe Offene Punkte),
leere Abteilungsliste, Werkzeugkatalog aus der Plattform vorbelegt
(noch nicht automatisiert — der zentrale Katalog ist aktuell leer, bis
Ebene 5/Betreiber-Pflege gebaut ist).
**Mitarbeiter, geplante Reihenfolge:** (1) ~~Einladungslink~~
**erledigt** (`internal/web/einladung_handlers.go`: `account.einladung_token`,
Migration 0013, ein Sammellink pro Mandant statt Einmal-Token pro
Person, kein Ablaufdatum, per Admin unter `GET /verwaltung/einladung`
jederzeit erneuerbar [macht den alten Link sofort ungültig].
`GET/POST /einladung/{token}` — öffentlich, Ebene 1 — legt einen Login
mit Rolle `mitarbeiter` im Mandanten des Links an und meldet direkt an,
ohne E-Mail-Bestätigung [noch nicht gebaut, siehe unten]. Die Abteilung
wählt die Person weiterhin erst beim ersten Antrag, nicht bei der
Einladung.), (2) ~~CSV-Import~~**erledigt** (`internal/web/csv_import_handlers.go`,
`GET /verwaltung/nutzer/import`: CSV mit Kopfzeile, Pflichtspalte
"email", optionale Spalte "role" [Standard `mitarbeiter`]. Wie beim
einzelnen Nutzer-Anlegen gibt es keine Einladungsmail — pro Zeile wird
ein Einmalpasswort erzeugt [wiederverwendet `auth.NewSessionToken()`],
das NUR auf der Ergebnisseite direkt nach dem Import angezeigt wird und
danach nirgendwo mehr abrufbar ist. Kein Alles-oder-nichts-Import:
gültige Zeilen werden angelegt, eine ungültige Rolle oder bereits
vergebene E-Mail in einer Zeile blockiert nicht die übrigen Zeilen —
jede Zeile bekommt ihr eigenes Ergebnis [Passwort oder Fehlermeldung].),
(3) SSO (Entra ID/Google Workspace), (4) Antrag ganz ohne vorheriges
Konto über Mandantenlink + E-Mail-Bestätigungscode (Konto entsteht
nebenbei). (3)-(4) sind weiterhin nicht gebaut.
**Abrechnung:** nach vom Kunden angegebener Mitarbeiterzahl, **nicht**
nach angelegten Nutzern — sonst entsteht der Anreiz, Mitarbeitende
nicht anzulegen. Noch nicht gebaut (kein Abo-System).
---
## Frontend
Anspruch (2026-08-29 explizit vom Nutzer bestätigt, da Deklarix ein
buchbarer Service werden soll): **für Browser UND Mobile optimiert,
und jede Entität im Datenmodell muss über das Frontend steuerbar sein**
— eine reine Anzeige-Seite ohne CRUD ist ein Defizit, kein v1-Kompromiss.
Stack bleibt `html/template` + htmx, kein React/Vue/SPA (siehe
Baureihenfolge/Architektur-Entscheidungen) — die responsive Optik wird
rein mit CSS erreicht, ohne das Template-Modell zu ändern.
**Navigation** (`internal/web/templates/layout.html`, `"nav"`-Block,
einmal definiert und von jeder Seite eingebunden — Änderungen hier
wirken auf das ganze Produkt): ein Checkbox-Hack (versteckte Checkbox +
`<label for=...>` als Klick-Ziel) klappt auf dem Handy ein Menü auf,
ganz ohne JavaScript. **Nicht** das naheliegendere `<details>`/`<summary>`
verwenden — aktuelle Chromium-Versionen steuern dessen Auf-/Zuklapp-
Zustand über eine interne, mit CSS (auch nicht mit `!important`)
nicht überschreibbare Animation; ein Versuch, `.app-nav { display:
flex !important }` bei geschlossenem `<details>` zu erzwingen, bleibt
wirkungslos (mit Chromium-Headless-Screenshot verifiziert). Ab
960px Breite (`@media (min-width: 960px)`, `app.css`) wird exakt
dieselbe Markup-Struktur per CSS zu einer permanenten, dunklen
Seitenleiste (Farb-Tokens aus `design/enterprise.css` übernommen) —
kein separates Desktop-Template.
**Tabellen** (`table`/`th`/`td` in `app.css`) sind seit dieser Änderung
gestylt (vorher komplett ungestylt, betraf v. a. `register_liste.html`);
breite Tabellen stehen in einem `.table-scroll`-Wrapper (horizontales
Scrollen auf dem Handy statt gequetschter Spalten).
**Verifikationsmethode für CSS-Änderungen:** Live-Cookie-Auth per
Chromium-Headless/CDP ist im Sandbox-Environment nicht möglich (kein
websocket-Python-Modul) — stattdessen: Seite per `curl -b cookies.txt`
authentifiziert abrufen, `/static/...`-Pfade im HTML auf eine absolute
`http://localhost:<port>/static/...`-URL umschreiben (Assets brauchen
kein Auth-Cookie), dann `chromium --headless --disable-gpu --no-sandbox
--window-size=<W>,<H> --screenshot=out.png file:///pfad/zur/seite.html`
bei mobiler (z. B. 390×844) und Desktop-Breite (z. B. 1440×900)
screenshotten. Für einen Menü-Aufklapp-Zustand: `checked`-Attribut
manuell ins gespeicherte HTML einfügen vor dem Screenshot.
---
@@ -106,19 +406,18 @@ Managed Postgres in der EU (DSGVO).
/var/www/deklarix/
├── cmd/
│ └── deklarix/
│ └── main.go # Entry Point, HTTP-Server
│ └── main.go # Entry Point: Migration, Regelwerk laden, HTTP-Server
├── internal/
│ ├── extract/ # regelbasierte Fakten-Erkennung (Keywords, Kürzungs-Position)
│ ├── rules/ # YAML-Loader, Auswertung, Versionierung
│ ├── evidence/ # Hashing, Zeitstempel, Append-only-Log
│ ├── dossier/ # PDF-Erzeugung
│ ├── auth/ # Passwort-Hashing, Session-Token (produktunabhängig)
│ ├── rules/ # Lädt/validiert Datenklasse-/Einstufungs-/Anforderungs-YAML
│ ├── store/ # Postgres, Migrationen
│ └── web/ # Handler, Templates
├── rules/ # YAML-Regeln, versioniert im Git
├── testdata/
│ └── golden/ # Golden-Tests: Beispielbeiträge + erwartetes Ergebnis
├── rules/ # YAML-Regelwerk + OPEN.md (offene fachliche Fragen)
├── design/
│ └── enterprise.css # Design-System (enconf-Basis)
│ └── enterprise.css # enconf Ant-Design-Theme — Referenz für Design-Tokens
│ (Farben/Radius/Schatten), nicht direkt eingebunden
│ (Ant-Design-Klassennamen passen nicht zu Deklarix'
│ reinem HTML/htmx-Stack, siehe internal/web/static/app.css)
├── packaging/
│ └── DEBIAN/
│ └── control.tmpl # .deb Package-Control-Template
@@ -135,48 +434,104 @@ Managed Postgres in der EU (DSGVO).
## Datenmodell
Plus drei Tabellen für Auth/Mandantentrennung (`account`, `app_user`,
`session`), mehr braucht der MVP nicht:
- `account` — ein Mandant (ein Unternehmen). Jeder Antrag gehört genau
einem Account.
- `app_user` — ein Login innerhalb eines Accounts. Rolle ist eine von
`mitarbeiter`, `verantwortlicher`, `pruefer`, `admin` (alle vier
Mandanten-bezogen) oder `betreiber` (plattformweit, Ebene 5).
- `session` — eine angemeldete Sitzung (Token, Ablaufzeit); echte
Tabelle statt zustandsloser signierter Tokens, damit Logout eine
Sitzung wirklich beendet.
- `audit_log` — Protokoll (append-only): jede Antragsänderung, jede
Bewertung, jede Entscheidung, jede Katalogänderung mit Zeitstempel
und Akteur.
- `abteilung` — Stammdaten für Fragebogen-Feld A.abteilung, je Mandant.
- `werkzeug` — Katalogeintrag (siehe oben); `account_id` NULL = zentral,
gesetzt = mandantenspezifische Ergänzung. NICHT append-only — der
Katalog wird gepflegt, eine Entscheidung friert den zu ihrem
Zeitpunkt gültigen Datensatz separat ein (geplant).
- `werkzeug_sperre` — Sperrung eines (auch zentralen) Katalogeintrags
durch einen einzelnen Mandanten.
- `antrag` — das Vorhaben aus Fragebogen-Abschnitt A plus die
vollständigen Antworten aus B/C/D als JSON (`antworten`, JSONB) — ein
starres Spaltenschema könnte den adaptiven Fragebogen nicht abbilden.
NICHT append-only (normale Zustandsänderung `entwurf`
`eingereicht``entschieden`, wie `submission` es im alten Produkt
war).
- `bewertung` — der berechnete Vorschlag (Schritt 4).
- `entscheidung` — die Entscheidung eines/einer Verantwortlichen über
einen Antrag, mit Snapshot des gewählten Werkzeugs (`werkzeug_snapshot`,
JSONB) und Ablaufdatum (`gueltig_bis`) bei Genehmigung (Schritt 5).
- `registereintrag` — automatisch bei jeder Genehmigung erzeugt
(Schritt 6), denormalisierter Nachweis (eigene Textspalten statt
Joins über bewertung/entscheidung/werkzeug).
- `account` — ein Mandant (Creator, Agentur, Marke oder Kanzlei als
eigene Organisation); jede Submission gehört genau einem Account
- `app_user` — ein Login innerhalb eines Accounts (E-Mail, Passwort-
Hash, Rolle)
- `session` — eine angemeldete Sitzung (Token, Ablaufzeit); bewusst
eine echte Tabelle statt zustandsloser signierter Tokens, damit
Logout eine Sitzung wirklich beendet
- `submission` — ein eingereichter Beitrag, Status, Zeitpunkte
- `asset` — Bild oder Datei, Pfad, SHA-256
- `extraction` — das JSON aus Stufe 1, Modellversion, Prompt-Version
- `finding` — Ergebnis pro Regel: Regel-ID, Regel-Version, Schwere,
Titel, Korrektur, Fundstellen (zum Zeitpunkt des Findings fixiert,
kein Verweis auf die aktuelle Regel-YAML — die kann sich inzwischen
geändert haben)
- `evidence_package` — Dossier, Hash, Zeitstempel-Token, erzeugt am
- `participant` — Beteiligter an einer Submission mit Rolle
(`creator`, `agentur`, `marke`, `kanzlei`) und Beitrag zur
Verantwortungsmatrix (wer hat vorgegeben, wer freigegeben)
**Append-only:** kein UPDATE auf `audit_log` (Trigger `forbid_update_delete`,
wiederverwendet aus dem alten Produkt), ebenso `bewertung`,
`entscheidung` und `registereintrag`.
**Append-only.** Kein UPDATE auf `finding`, `extraction` oder
`evidence_package`. Korrekturen sind neue Zeilen mit Verweis auf die alte.
Ein Beweisarchiv, in dem man Zeilen ändern kann, ist kein Beweisarchiv.
---
**Beweiskette:** SHA-256 über jedes Asset und über die kanonisierte
JSON-Repräsentation der Metadaten, RFC-3161-Zeitstempel über diesen Hash.
Dossier-PDF enthält Beitrag, Caption, Kennzeichnung, Findings mit
Fundstellen, Vertrags- und Briefing-Bezug, Verantwortungsmatrix, alle
Hashes und das Zeitstempel-Token. Vollständiger Export muss für den
Nutzer jederzeit möglich sein — wer kündigt, bekommt sein Archiv.
## Baureihenfolge
**Auth/Mandantentrennung:** `POST /register` legt einen neuen Account
plus den ersten Nutzer darin an, `POST /login` meldet einen bestehenden
Nutzer an — beides setzt ein `deklarix_session`-Cookie (HttpOnly,
SameSite=Strict, Secure sobald über TLS erreicht). `GET /` verlangt eine
gültige Sitzung (sonst Redirect zu `/login`); `POST /pruefen`,
`POST /veroeffentlichen` und `GET /dossier/{id}` verlangen sie ebenfalls
(sonst 401). Ein Beitrag eines fremden Accounts wird wie ein nicht
existierender behandelt (404), nie mit einer expliziten 403 bestätigt —
sonst würde die Antwort selbst verraten, dass die ID existiert.
1. ~~Datenmodell, Regelwerk als YAML, Katalogstruktur~~**erledigt**
(dieser Stand: `account`/`app_user`/`session`/`audit_log`/
`abteilung`/`werkzeug`/`werkzeug_sperre`/`antrag` in Postgres,
`internal/rules` lädt und validiert die drei YAML-Regelwerke,
Web-Layer kompiliert mit Auth + Plattform-Bereich-Gerüst).
2. ~~Fragebogen mit adaptiver Logik~~**erledigt** (`GET /antraege/neu`
einseitiges Formular, `POST /antraege` legt an und reicht direkt ein,
`GET /antraege` eigene Anträge, `GET /antraege/{id}` Detail. Adaptive
Folgefragen C2/C3 rein per CSS `:has()` ein-/ausgeblendet, kein
JavaScript. `antworten`-JSON nutzt exakt die Fakten-Schlüssel aus
`rules/*.yaml` (b1-b7, c1-c5, c2_folge, c3_art) — das hat auch die
OPEN.md-Frage zu den "verboten"-Fragebogen-Feldern final geklärt.)
3. ~~Ableitungen und harte Filter~~**erledigt** (`internal/rules`:
`EvaluateDatenklasse`/`EvaluateEinstufung`/`DeriveAnforderungen`/
`IstVerboten`/`FilterWerkzeuge`, vollständig getestet gegen die
echten `rules/*.yaml`-Dateien.)
4. ~~Ergebnisdarstellung mit Herleitung~~**erledigt** (`POST /antraege`
berechnet und speichert eine `bewertung` sofort beim Einreichen,
`GET /antraege/{id}` zeigt Datenklasse, KI-VO-Einstufung,
Anforderungen, zulässige/ausgeschlossene Werkzeuge — jeweils mit
Herleitung — sowie RegelwerkVersion/KatalogVersion und den
Pflicht-Hinweis "Das System entscheidet nicht. Es bereitet vor.".
`bewertung` ist append-only. Getestet inkl. K.-o.-Prüfung
(`verboten` überspringt die Werkzeugsuche) und hartem Filter
gegen den Katalog.)
5. ~~Entscheidung, Snapshot, Audit-Log~~**erledigt** (Ebene 3:
`GET /faelle` Posteingang, `GET /faelle/{id}` + `POST
/faelle/{id}/entscheiden`. `verantwortlicher` entscheidet,
`pruefer` sieht dieselbe Seite ohne Entscheidungsrecht [403 bei
Entscheidungsversuch]. Begründung ist Pflicht bei Abweichung vom
abgeleiteten Vorschlag (`vorschlagFuer`), Genehmigung friert den
vollständigen Werkzeugdatensatz ein und braucht ein zulässiges
Werkzeug aus der Bewertung, `entscheidung` ist append-only, jede
Entscheidung erzeugt einen `audit_log`-Eintrag.)
6. ~~Registereintrag und Export~~**erledigt** (`internal/web/register_handlers.go`:
`GET /registereintraege` zeigt alle Registereinträge des Mandanten,
`GET /registereintraege/export.{csv,pdf}` exportiert sie. Jede
Genehmigung — `genehmigt` oder `genehmigt_mit_auflagen` — erzeugt in
`handleFallEntscheiden` automatisch einen `registereintrag`
[append-only, Migration 0011]: Zweck [`antrag.titel`], Abteilung,
Werkzeug, Datenklasse, Einstufung, Auflagen [die Anforderungen der
Bewertung], Verantwortliche/r, Entscheidungsdatum, Gültigkeit —
bewusst denormalisiert [eigene Textspalten statt Joins], damit ein
Eintrag nicht von einem späteren Katalog-/Abteilungs-Wandel abhängt.
Abgelehnte Entscheidungen und Rückfragen erzeugen keinen Eintrag.
Der PDF-Export nutzt das bereits vorhandene, bis dahin ungenutzte
`go-pdf/fpdf` [keine neue Abhängigkeit] mit `UnicodeTranslatorFromDescriptor`
für deutsche Umlaute.)
7. ~~Wiedervorlage und Katalog-Benachrichtigung~~**erledigt**
(`GET /wiedervorlage`: abgelaufene/bald ablaufende Genehmigungen und
Genehmigungen, deren Werkzeug sich seither im Katalog geändert hat
oder entfernt wurde. In-App-Liste, kein E-Mail-Versand.)
Nicht bauen (v1): automatische Genehmigung ohne Mensch, Erkennung
tatsächlicher Werkzeug-Nutzung, Mitarbeiterüberwachung (nichts, was
Nutzung einzelner Personen auswertet), konfigurierbare Rollen mit
eigener Oberfläche, Konzernstrukturen mit Vererbung, mandantenspezifische
Regelwerke, Workflow-Designer, SAML, Schnittstellen zu Fremdsystemen.
---
@@ -297,56 +652,23 @@ zieht dafür aber **kein** eigenes Postgres hoch (kein Docker-Zwang auf
dem lokalen Dev-Rechner) — das ist Aufgabe des dedizierten Testsystems,
dort muss `DATABASE_URL` vorkonfiguriert sein.
**Golden-Test-Suite ist das eigentliche Asset des Projekts, nicht die
UI.** 30 bis 50 Beispielbeiträge in `testdata/golden/`, abgeleitet aus
veröffentlichten Urteilen und Beanstandungen der Medienanstalten, jeweils
mit erwarteten Findings. Jede Regeländerung läuft gegen die komplette
Suite. Eine Regeländerung, die ein Golden-Ergebnis kippt, wird nicht
gemerged, ohne dass der erwartete Wert bewusst und dokumentiert angepasst
wird. Extraktion wird separat getestet: fixierte Eingaben, geprüfte
JSON-Ausgabe.
---
## Leitplanken, die dauerhaft gelten
- **Keine Rechtsdienstleistung.** Deklarix ist ein Werkzeug, keine
Rechtsberatung. Keine Formulierung darf eine individuelle rechtliche
Bewertung suggerieren. Auf jedem Screen mit einem Ergebnis steht ein
entsprechender Hinweis. Keine Erfolgsaussichten-Aussagen.
- **Keine Versicherungssprache.** Nie „Abmahnschutz“, „abgesichert“,
„geschützt“, „Kostenübernahme“ — sonst erlaubnispflichtiges
Versicherungsgeschäft. Immer „Prüfung“, „Hinweis“, „Dokumentation“,
„Nachweis“.
- **Keine erfundenen Fundstellen.** Jede Norm und jedes Urteil steht in
einer Regel-YAML und wurde dort von Hand geprüft.
- **Nur eigene Inhalte.** Kein Scraping fremder Accounts, keine fremden
Posts als Demo-Material. Demo-Material ist synthetisch. Nutzerinhalte
kommen über eigenen OAuth-Zugang oder Datenexport.
- **DSGVO und Hosting in der EU.** Löschkonzept von Anfang an mitdenken,
aber Beweisarchiv und Löschpflicht sauber gegeneinander abgrenzen
(offener Punkt, siehe unten).
- **Berufsrecht der Anwälte (§ 49b Abs. 3 S. 1 BRAO).** Kein Routing
(kein Button, der einen Fall an eine Kanzlei übergibt — Nutzer wählt
selbst aus dem Verzeichnis), kein ergebnisabhängiger Preis, keine
Sachvorteile an gelistete Kanzleien, Verzeichnis bleibt kostenlos
(BGH IX ZR 89/23: entgeltliche Mandatsvermittlung ist nichtig). Vor dem
ersten Kanzlei-Vertrag: formlose Auskunft der zuständigen
Rechtsanwaltskammer einholen.
---
## Domains
| Domain | Verwendung |
|--------|-----------|
| deklarix.de | Primär (Marketing-Site) |
| app.deklarix.de | Die Anwendung selbst (dieser Go-Server) |
| deklarix.com | Redirect auf deklarix.de |
| get.deklarix.com | Installer-Endpoint (apt-Einrichtung, siehe Deployment) |
Kein separates `app.deklarix.com` — der Root-Redirect von `.com` auf
`.de` deckt Fehlnavigation ab, keine zweite Produktions-Subdomain nötig.
- **Keine Rechtsdienstleistung.** Deklarix bereitet eine Einstufung vor,
entscheidet nicht. Keine Formulierung darf eine individuelle
rechtliche Bewertung suggerieren oder Erfolgsaussichten behaupten.
- **Keine erfundenen Einstufungen.** Jede Ableitungsregel steht in einer
Regel-YAML und wurde dort von Hand geprüft; offene fachliche Fragen
stehen in `rules/OPEN.md`, werden nicht geraten.
- **Werkzeugkatalog-Zusicherungen müssen belegbar sein.** `quelle` und
`letzte_pruefung` sind Pflichtfelder, keine Ausnahme.
- **Keine Mitarbeiterüberwachung.** Nichts im Produkt darf auswerten,
welche einzelne Person welches Werkzeug wie oft nutzt — das
widerspricht dem Vertrauensmodell, auf dem die freiwillige
Antragstellung beruht.
- **DSGVO und Hosting in der EU.**
---
@@ -370,8 +692,7 @@ git push origin main
sudo systemctl start deklarix
sudo systemctl status deklarix
# Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, RULES_DIR,
# DOSSIER_DIR, TSA_URL)
# Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, RULES_DIR)
# Logs prüfen
journalctl -u deklarix -f
@@ -390,16 +711,11 @@ journalctl -u deklarix -f
## Arbeitsweise für Claude Code
- Kleine Commits, ein Thema pro Commit
- Erst Test oder Golden-Fall, dann Implementierung
- Erst Test, dann Implementierung
- Keine neue Abhängigkeit ohne Rückfrage
- Keine Umbenennung von Regel-IDs; Regeln werden versioniert, nicht
ersetzt
- Fehler nicht verschlucken, keine stillen Fallbacks bei der Extraktion
- Bei Unsicherheit über eine Rechtsfrage: nicht raten, sondern als offene
Frage in `rules/OPEN.md` notieren
- Der Code-RAG dient der Orientierung im Bestand, nicht als Nachweis von
Korrektheit. Verifiziert wird durch Compiler, Tests und die
Golden-Suite.
- Fehler nicht verschlucken, keine stillen Fallbacks
- Bei fachlicher Unsicherheit: nicht raten, sondern als offene Frage in
`rules/OPEN.md` notieren
## Vor Änderungen
@@ -418,13 +734,48 @@ 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~~ — **pragmatisch
gelöst, kein Datenmodell-Umbau:** `app_user.role` bleibt ein einzelner
Wert (kein `roles`-Array, keine zwei `app_user`-Zeilen pro Person).
Stattdessen bekommt die Rolle `admin` in `requireFachebene` (Ebene 3)
dieselben Rechte wie `verantwortlicher` — sieht Posteingang/Register/
Wiedervorlage und darf entscheiden (`hatEntscheidungsrecht` in
`fachebene_handlers.go`). Grund: ohne das könnte eine frisch
registrierte Firma mit nur einem `admin`-Login keinen einzigen
eingereichten Antrag sehen oder bearbeiten — der Kernablauf wäre für
Einzelpersonen-/Kleinfirmen-Onboarding komplett blockiert. `pruefer`
bleibt unverändert nur lesend, ohne Entscheidungsrecht. Kompromiss statt
"sauberer" Lösung — falls künftig eine Firma admin und verantwortlicher
bewusst auf zwei verschiedene Personen verteilen will, funktioniert das
weiterhin unverändert (zwei separate Logins mit den jeweiligen Rollen).
- **Zentraler Werkzeugkatalog muss noch befüllt werden** — die
Ebene-5/Betreiber-UI zur Katalogpflege ist jetzt gebaut (`GET
/betreiber/werkzeuge` Liste, `GET/POST /betreiber/werkzeuge/neu`
anlegen, `GET/POST /betreiber/werkzeuge/{id}` bearbeiten, `POST
.../loeschen` löschen — siehe `internal/web/betreiber_werkzeug_handlers.go`.
Bearbeitet ausschließlich `account_id IS NULL`-Einträge, ein
mandantenspezifischer Eintrag ist über diese Seiten nicht erreichbar
[404]). "Werkzeugkatalog aus der Plattform vorbelegt" beim Firma-
Onboarding bleibt trotzdem nicht automatisiert — der Katalog ist
inhaltlich weiterhin leer, bis der Betreiber tatsächlich Einträge
pflegt.
- Markenanmeldung Deklarix, sobald erster Kunde zahlt.

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,26 +27,33 @@ func main() {
}
defer db.Close()
extractor := extract.NewEngine()
rulesDir := os.Getenv("RULES_DIR")
if rulesDir == "" {
rulesDir = "rules"
}
ruleSet, err := rules.Load(os.DirFS(rulesDir))
rulesFS := os.DirFS(rulesDir)
// Beim Start laden und validieren, auch wenn die Auswertungslogik
// (Phase 3 der Baureihenfolge) sie noch nicht konsumiert — ein
// kaputtes Regelwerk soll den Dienst nicht erst beim ersten Antrag
// zum Absturz bringen, sondern gar nicht erst starten lassen.
datenklasse, err := rules.LoadDatenklasse(rulesFS, "datenklasse.yaml")
if err != nil {
log.Fatalf("load rules from %s: %v", rulesDir, err)
log.Fatalf("load datenklasse-regelwerk from %s: %v", rulesDir, err)
}
log.Printf("%d Regeln aus %s geladen", len(ruleSet), rulesDir)
timestamper := evidence.NewHTTPTimestamper(os.Getenv("TSA_URL"))
dossierDir := os.Getenv("DOSSIER_DIR")
if dossierDir == "" {
dossierDir = "dossiers"
einstufung, err := rules.LoadEinstufung(rulesFS, "kivo_einstufung.yaml")
if err != nil {
log.Fatalf("load ki-vo-einstufung-regelwerk from %s: %v", rulesDir, err)
}
anforderungen, err := rules.LoadAnforderungen(rulesFS, "anforderungen.yaml")
if err != nil {
log.Fatalf("load anforderungsprofil-regelwerk from %s: %v", rulesDir, err)
}
log.Printf("Regelwerk geladen: %d Datenklassen, %d KI-VO-Stufen, %d Anforderungen (aus %s)",
len(datenklasse.Stufen), len(einstufung.Stufen), len(anforderungen.Anforderungen), rulesDir)
server, err := web.NewServer(extractor, ruleSet, db, timestamper, dossierDir)
server, err := web.NewServer(db, web.Regelwerk{
Datenklasse: datenklasse, Einstufung: einstufung, Anforderungen: anforderungen,
})
if err != nil {
log.Fatalf("web server: %v", err)
}

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,198 +0,0 @@
// Package extract ist Stufe 1 aus dem Kernprinzip: es bestimmt
// ausschließlich beobachtbare Fakten zu einem Beitrag. Es bewertet
// nichts — das Urteil fällt ausschließlich das Regelwerk in
// internal/rules.
//
// Die Gegenleistung (Consideration) wird NICHT aus dem Text geraten —
// aus reinem Keyword-Matching lässt sich eine verschwiegene
// Zusammenarbeit nicht von einem echten organischen Post unterscheiden
// (beide sehen textlich identisch aus). Nur wer den Beitrag einreicht,
// weiß, ob eine Gegenleistung vorlag, darum kommt dieser Wert vom
// Aufrufer (siehe Input.Consideration). Was diese Stufe zuverlässig
// automatisieren kann, ist reine Zeichenketten-Logik: steht ein
// Kennzeichnungswort in der Caption, und steht es vor der
// "mehr anzeigen"-Kürzung der Plattform.
package extract
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/netcell-it/deklarix/internal/rules"
)
// EngineVersion wird zusammen mit jeder Extraktion gespeichert (siehe
// Datenmodell: extraction.model_version) — hochzählen bei jeder
// inhaltlichen Änderung an der Erkennungslogik unten.
const EngineVersion = "regelbasiert-v1"
// PromptVersion wird ebenfalls gespeichert (extraction.prompt_version).
// Es gibt kein Sprachmodell und keinen Prompt mehr, aber die Spalte
// bleibt (keine neue Migration nur für einen Namenswechsel) — der Wert
// markiert weiterhin den Stand der Extraktionslogik.
const PromptVersion = "v1"
// disclosureKeywords sind Kennzeichnungshinweise, nach denen in der
// Caption gesucht wird — bewusst großzügig (auch rechtlich unzureichende
// wie "#ad", siehe rules/OPEN.md/Recherche zu OLG Celle/Kammergericht
// Berlin): ob ein Wortlaut rechtlich ausreicht, entscheidet das
// Regelwerk anhand von DisclosureWording, nicht diese Erkennung.
var disclosureKeywords = []string{
"werbung", "anzeige", "bezahlte partnerschaft", "paid partnership",
"#ad", "#werbung", "#anzeige", "#sponsored", "#sponsoredby", "#sponsoredpost",
}
// truncationThresholds sind ungefähre Zeichen-Schwellen, ab denen
// Instagram/TikTok eine Caption in der Zeitleiste hinter "... mehr" /
// "mehr anzeigen" kürzen. Plattformen ändern das ohne Ankündigung — vor
// echtem Kundeneinsatz stichprobenartig nachprüfen, ob die Werte noch
// stimmen (siehe CLAUDE.md, Offene Punkte).
var truncationThresholds = map[string]int{
"instagram": 125,
"tiktok": 150,
}
const defaultTruncationThreshold = 125
// Input ist, was Stufe 1 braucht. Platform, Jurisdiction UND
// Consideration kommen alle vom Aufrufer — keines davon lässt sich aus
// der Caption zuverlässig ableiten oder ist Sache dieser Stufe.
type Input struct {
Platform string
Jurisdiction string
Consideration string // "bezahlt" | "sachbezug" | "keine" | "unklar"
Caption string
}
// Result ist die Ausgabe von Extract: die für das Regelwerk
// aufbereiteten Facts, plus RawJSON — die vollständige, unveränderte
// Aufzeichnung dessen, was diese Stufe bestimmt hat. RawJSON gehört
// unverändert in extraction.payload (siehe Datenmodell); Facts ist eine
// abgeleitete Sicht darauf und nicht der Beweis-Eintrag selbst.
type Result struct {
Facts rules.Facts
RawJSON []byte
}
// Engine bestimmt die Facts für einen Beitrag über deterministische
// Zeichenketten-Logik — kein externer Dienst, keine Netzwerk-Abhängigkeit.
type Engine struct{}
// NewEngine erstellt eine Engine.
func NewEngine() *Engine {
return &Engine{}
}
// ModelVersion liefert EngineVersion (siehe Datenmodell: extraction.model_version).
func (e *Engine) ModelVersion() string { return EngineVersion }
// Extract bestimmt die Facts für in. Die Gegenleistung wird validiert,
// nicht erraten — ein leerer oder ungültiger Wert ist ein Fehler, keine
// Lücke, die stillschweigend als "keine" interpretiert wird (das wäre
// hier besonders gefährlich: es würde unentdeckte Schleichwerbung
// systematisch als unauffällig durchwinken).
func (e *Engine) Extract(ctx context.Context, in Input) (Result, error) {
if in.Caption == "" {
return Result{}, fmt.Errorf("extract: caption ist leer")
}
consideration, err := parseConsideration(in.Consideration)
if err != nil {
return Result{}, fmt.Errorf("extract: %w", err)
}
present, wording, index := detectDisclosure(in.Caption)
beforeCut := present && index < truncationThreshold(in.Platform)
args := extractionArgs{
Consideration: string(consideration),
DisclosurePresent: present,
DisclosureWording: wording,
DisclosureBeforeCut: beforeCut,
}
raw, err := json.Marshal(args)
if err != nil {
return Result{}, fmt.Errorf("extract: payload marshal: %w", err)
}
return Result{
Facts: rules.Facts{
Platform: in.Platform,
Jurisdiction: in.Jurisdiction,
Consideration: consideration,
DisclosurePresent: present,
DisclosureWording: wording,
DisclosureBeforeCut: beforeCut,
},
RawJSON: raw,
}, nil
}
// detectDisclosure sucht die am frühesten in caption vorkommende
// Kennzeichnung aus disclosureKeywords (case-insensitive) und liefert
// deren Wortlaut in Original-Schreibweise plus Byte-Index.
func detectDisclosure(caption string) (present bool, wording string, index int) {
lower := strings.ToLower(caption)
bestIdx := -1
bestLen := 0
for _, kw := range disclosureKeywords {
if idx := strings.Index(lower, kw); idx != -1 && (bestIdx == -1 || idx < bestIdx) {
bestIdx = idx
bestLen = len(kw)
}
}
if bestIdx == -1 {
return false, "", -1
}
return true, caption[bestIdx : bestIdx+bestLen], bestIdx
}
func truncationThreshold(platform string) int {
if t, ok := truncationThresholds[platform]; ok {
return t
}
return defaultTruncationThreshold
}
// ParsePayload rekonstruiert Facts aus einem zuvor gespeicherten
// RawJSON-Payload (z. B. aus extraction.payload). platform/jurisdiction
// müssen erneut mitgegeben werden — sie sind, wie bei Extract, nie Teil
// des gespeicherten Payloads.
func ParsePayload(payload []byte, platform, jurisdiction string) (rules.Facts, error) {
var args extractionArgs
if err := json.Unmarshal(payload, &args); err != nil {
return rules.Facts{}, fmt.Errorf("extract: payload parse: %w", err)
}
consideration, err := parseConsideration(args.Consideration)
if err != nil {
return rules.Facts{}, fmt.Errorf("extract: %w", err)
}
return rules.Facts{
Platform: platform,
Jurisdiction: jurisdiction,
Consideration: consideration,
DisclosurePresent: args.DisclosurePresent,
DisclosureWording: args.DisclosureWording,
DisclosureBeforeCut: args.DisclosureBeforeCut,
}, nil
}
// extractionArgs ist die gespeicherte Form eines Extraktionsergebnisses.
type extractionArgs struct {
Consideration string `json:"gegenleistung"`
DisclosurePresent bool `json:"kennzeichnung_vorhanden"`
DisclosureWording string `json:"kennzeichnung_wortlaut"`
DisclosureBeforeCut bool `json:"kennzeichnung_vor_kuerzung"`
}
func parseConsideration(s string) (rules.Consideration, error) {
switch rules.Consideration(s) {
case rules.ConsiderationPaid, rules.ConsiderationInKind, rules.ConsiderationNone, rules.ConsiderationUnclear:
return rules.Consideration(s), nil
default:
return "", fmt.Errorf("ungültiger gegenleistung-Wert %q — muss vom Einreichenden angegeben werden", s)
}
}

View File

@@ -1,164 +0,0 @@
package extract_test
import (
"context"
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/extract"
"github.com/netcell-it/deklarix/internal/rules"
)
func TestExtractRejectsEmptyCaption(t *testing.T) {
e := extract.NewEngine()
if _, err := e.Extract(context.Background(), extract.Input{Platform: "instagram", Consideration: "bezahlt"}); err == nil {
t.Fatal("expected error for empty caption, got nil")
}
}
func TestExtractRejectsMissingConsideration(t *testing.T) {
e := extract.NewEngine()
if _, err := e.Extract(context.Background(), extract.Input{Platform: "instagram", Caption: "irgendein Text"}); err == nil {
t.Fatal("expected error for missing consideration, got nil")
}
}
func TestExtractRejectsInvalidConsideration(t *testing.T) {
e := extract.NewEngine()
_, err := e.Extract(context.Background(), extract.Input{
Platform: "instagram", Caption: "Text", Consideration: "vielleicht",
})
if err == nil {
t.Fatal("expected error for an out-of-enum consideration value, got nil")
}
}
func TestExtractDetectsDisclosureKeyword(t *testing.T) {
e := extract.NewEngine()
result, err := e.Extract(context.Background(), extract.Input{
Platform: "instagram", Consideration: "bezahlt",
Caption: "Werbung: Schaut euch dieses tolle Produkt an!",
})
if err != nil {
t.Fatalf("Extract: %v", err)
}
if !result.Facts.DisclosurePresent {
t.Error("expected DisclosurePresent = true")
}
if result.Facts.DisclosureWording != "Werbung" {
t.Errorf("DisclosureWording = %q, want Werbung (original casing preserved)", result.Facts.DisclosureWording)
}
if !result.Facts.DisclosureBeforeCut {
t.Error("expected DisclosureBeforeCut = true (Werbung is at index 0)")
}
}
func TestExtractIsCaseInsensitive(t *testing.T) {
e := extract.NewEngine()
result, err := e.Extract(context.Background(), extract.Input{
Platform: "instagram", Consideration: "bezahlt", Caption: "WERBUNG fuer ein Produkt",
})
if err != nil {
t.Fatalf("Extract: %v", err)
}
if !result.Facts.DisclosurePresent {
t.Fatal("expected case-insensitive match to find the keyword")
}
}
func TestExtractDetectsDisclosureAfterTruncationCut(t *testing.T) {
e := extract.NewEngine()
// 130 Fuellzeichen vor "Werbung" -> jenseits der Instagram-Schwelle (125).
padding := strings.Repeat("x", 130)
result, err := e.Extract(context.Background(), extract.Input{
Platform: "instagram", Consideration: "bezahlt", Caption: padding + " Werbung",
})
if err != nil {
t.Fatalf("Extract: %v", err)
}
if !result.Facts.DisclosurePresent {
t.Fatal("expected the keyword to still be found even though it's late in the caption")
}
if result.Facts.DisclosureBeforeCut {
t.Error("expected DisclosureBeforeCut = false when the keyword appears after the platform's truncation threshold")
}
}
func TestExtractNoDisclosureFound(t *testing.T) {
e := extract.NewEngine()
result, err := e.Extract(context.Background(), extract.Input{
Platform: "instagram", Consideration: "keine", Caption: "Ein ganz normaler Tag im Park.",
})
if err != nil {
t.Fatalf("Extract: %v", err)
}
if result.Facts.DisclosurePresent {
t.Error("expected DisclosurePresent = false when no keyword is present")
}
if result.Facts.DisclosureBeforeCut {
t.Error("expected DisclosureBeforeCut = false when there is no disclosure at all")
}
}
func TestExtractPassesThroughUnclearConsideration(t *testing.T) {
e := extract.NewEngine()
result, err := e.Extract(context.Background(), extract.Input{
Platform: "instagram", Consideration: "unklar", Caption: "Text ohne klare Angabe",
})
if err != nil {
t.Fatalf("Extract: %v", err)
}
if result.Facts.Consideration != rules.ConsiderationUnclear {
t.Fatalf("Consideration = %q, want unklar", result.Facts.Consideration)
}
}
func TestExtractDifferentPlatformThresholds(t *testing.T) {
e := extract.NewEngine()
// 140 Fuellzeichen: unter der TikTok-Schwelle (150), aber ueber der
// Instagram-Schwelle (125) -> gleiche Caption, unterschiedliches Ergebnis.
padding := strings.Repeat("x", 140)
caption := padding + " Werbung"
igResult, err := e.Extract(context.Background(), extract.Input{Platform: "instagram", Consideration: "bezahlt", Caption: caption})
if err != nil {
t.Fatalf("Extract (instagram): %v", err)
}
ttResult, err := e.Extract(context.Background(), extract.Input{Platform: "tiktok", Consideration: "bezahlt", Caption: caption})
if err != nil {
t.Fatalf("Extract (tiktok): %v", err)
}
if igResult.Facts.DisclosureBeforeCut {
t.Error("expected DisclosureBeforeCut = false for instagram at this length")
}
if !ttResult.Facts.DisclosureBeforeCut {
t.Error("expected DisclosureBeforeCut = true for tiktok at this length")
}
}
func TestExtractRawJSONRoundTripsThroughParsePayload(t *testing.T) {
e := extract.NewEngine()
result, err := e.Extract(context.Background(), extract.Input{
Platform: "instagram", Jurisdiction: "DE", Consideration: "sachbezug",
Caption: "Anzeige: dieses Produkt wurde mir geschenkt",
})
if err != nil {
t.Fatalf("Extract: %v", err)
}
facts, err := extract.ParsePayload(result.RawJSON, "instagram", "DE")
if err != nil {
t.Fatalf("ParsePayload: %v", err)
}
if facts != result.Facts {
t.Fatalf("ParsePayload(Extract().RawJSON) = %+v, want %+v", facts, result.Facts)
}
}
func TestParsePayloadRejectsInvalidConsideration(t *testing.T) {
payload := []byte(`{"gegenleistung":"vielleicht","kennzeichnung_vorhanden":false,"kennzeichnung_wortlaut":"","kennzeichnung_vor_kuerzung":false}`)
if _, err := extract.ParsePayload(payload, "tiktok", "DE"); err == nil {
t.Fatal("expected error for out-of-enum stored payload, got nil")
}
}

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,25 +2,43 @@ package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Account ist ein Mandant (Creator, Agentur, Marke oder Kanzlei als
// eigene Organisation). Jeder Beitrag gehört genau einem Account.
// Account ist ein Mandant (ein Unternehmen, das die Antragsprüfung
// nutzt). Jeder Antrag gehört genau einem Account. EinladungToken ist
// der Sammellink für die Mitarbeiter-Selbstanmeldung (Ebene 1,
// "Einladung annehmen") — ein Token pro Account, per Admin erneuerbar.
type Account struct {
ID string
Name string
CreatedAt time.Time
ID string
Name string
EinladungToken string
CreatedAt time.Time
}
// CreateAccount legt einen neuen Mandanten an.
func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) {
const accountColumns = `id, name, einladung_token, created_at`
func scanAccount(row interface {
Scan(dest ...any) error
}) (Account, error) {
var a Account
err := s.Pool.QueryRow(ctx, `
err := row.Scan(&a.ID, &a.Name, &a.EinladungToken, &a.CreatedAt)
return a, err
}
// CreateAccount legt einen neuen Mandanten an. einladung_token wird von
// der Datenbank per DEFAULT erzeugt (siehe Migration 0013).
func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) {
row := s.Pool.QueryRow(ctx, `
INSERT INTO account (name) VALUES ($1)
RETURNING id, name, created_at
`, name).Scan(&a.ID, &a.Name, &a.CreatedAt)
RETURNING `+accountColumns,
name,
)
a, err := scanAccount(row)
if err != nil {
return Account{}, fmt.Errorf("store: create account: %w", err)
}
@@ -29,12 +47,66 @@ func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error)
// GetAccount liest einen Mandanten anhand seiner ID.
func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
var a Account
err := s.Pool.QueryRow(ctx, `
SELECT id, name, created_at FROM account WHERE id = $1
`, id).Scan(&a.ID, &a.Name, &a.CreatedAt)
row := s.Pool.QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE id = $1`, id)
a, err := scanAccount(row)
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, ErrNotFound
}
if err != nil {
return Account{}, fmt.Errorf("store: get account: %w", err)
}
return a, nil
}
// GetAccountByEinladungToken liest den Mandanten zu einem
// Einladungslink — für die öffentliche Mitarbeiter-Selbstanmeldung
// (Ebene 1, kein Login nötig). Liefert ErrNotFound bei unbekanntem
// oder bereits erneuertem (damit ungültig gewordenem) Token.
func (s *Store) GetAccountByEinladungToken(ctx context.Context, token string) (Account, error) {
row := s.Pool.QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE einladung_token = $1`, token)
a, err := scanAccount(row)
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, ErrNotFound
}
if err != nil {
return Account{}, fmt.Errorf("store: get account by einladung token: %w", err)
}
return a, nil
}
// RegenerateEinladungToken ersetzt den Einladungslink eines Mandanten
// durch einen neuen — der alte Link wird damit sofort ungültig (z. B.
// wenn er versehentlich außerhalb des Unternehmens geteilt wurde).
func (s *Store) RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error {
tag, err := s.Pool.Exec(ctx, `UPDATE account SET einladung_token = $2 WHERE id = $1`, accountID, newToken)
if err != nil {
return fmt.Errorf("store: regenerate einladung token: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// ListAccounts liefert alle Mandanten, neueste zuerst — für den
// Admin-Bereich (Accounts-Verwaltung).
func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) {
rows, err := s.Pool.Query(ctx, `SELECT `+accountColumns+` FROM account ORDER BY created_at DESC`)
if err != nil {
return nil, fmt.Errorf("store: list accounts: %w", err)
}
defer rows.Close()
var out []Account
for rows.Next() {
a, err := scanAccount(rows)
if err != nil {
return nil, fmt.Errorf("store: scan account: %w", err)
}
out = append(out, a)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list accounts: %w", err)
}
return out, nil
}

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

@@ -25,6 +25,56 @@ func TestAccountCRUD(t *testing.T) {
if got.Name != "Beispiel Agentur GmbH" {
t.Fatalf("Name = %q, want Beispiel Agentur GmbH", got.Name)
}
if got.EinladungToken == "" {
t.Error("expected a newly created account to have a non-empty EinladungToken")
}
}
func TestGetAccountByEinladungToken(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
got, err := s.GetAccountByEinladungToken(ctx, acc.EinladungToken)
if err != nil {
t.Fatalf("GetAccountByEinladungToken: %v", err)
}
if got.ID != acc.ID {
t.Fatalf("GetAccountByEinladungToken returned a different account")
}
_, err = s.GetAccountByEinladungToken(ctx, "unbekanntes-token")
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound for an unknown token", err)
}
}
func TestRegenerateEinladungToken(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
alterToken := acc.EinladungToken
if err := s.RegenerateEinladungToken(ctx, acc.ID, "ein-neues-token"); err != nil {
t.Fatalf("RegenerateEinladungToken: %v", err)
}
if _, err := s.GetAccountByEinladungToken(ctx, alterToken); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("expected the old token to be invalid, got err = %v", err)
}
got, err := s.GetAccountByEinladungToken(ctx, "ein-neues-token")
if err != nil {
t.Fatalf("GetAccountByEinladungToken (neu): %v", err)
}
if got.ID != acc.ID {
t.Fatalf("GetAccountByEinladungToken (neu) returned a different account")
}
}
func TestUserCRUD(t *testing.T) {
@@ -32,7 +82,7 @@ func TestUserCRUD(t *testing.T) {
ctx := context.Background()
accID := testAccountID(t, s)
user, err := s.CreateUser(ctx, accID, "team@example.com", "bcrypt-hash", "agentur")
user, err := s.CreateUser(ctx, accID, "team@example.com", "bcrypt-hash", "mitarbeiter")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
@@ -52,6 +102,49 @@ func TestUserCRUD(t *testing.T) {
if byID.Email != "team@example.com" || byID.AccountID != accID {
t.Fatalf("GetUser = %+v, unerwartete Werte", byID)
}
if !byID.Active {
t.Error("expected a newly created user to be Active by default")
}
}
func TestSetUserActive(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
user, err := s.CreateUser(ctx, accID, "deaktivierbar@example.com", "bcrypt-hash", "mitarbeiter")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
if err := s.SetUserActive(ctx, user.ID, false); err != nil {
t.Fatalf("SetUserActive (false): %v", err)
}
deaktiviert, err := s.GetUser(ctx, user.ID)
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if deaktiviert.Active {
t.Fatal("expected the user to be inactive")
}
if err := s.SetUserActive(ctx, user.ID, true); err != nil {
t.Fatalf("SetUserActive (true): %v", err)
}
reaktiviert, err := s.GetUser(ctx, user.ID)
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if !reaktiviert.Active {
t.Fatal("expected the user to be active again")
}
}
func TestSetUserActiveNotFound(t *testing.T) {
s := openTestStore(t)
err := s.SetUserActive(context.Background(), "00000000-0000-0000-0000-000000000000", false)
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound", err)
}
}
func TestGetUserByEmailNotFound(t *testing.T) {
@@ -67,10 +160,10 @@ func TestUserEmailIsUnique(t *testing.T) {
ctx := context.Background()
accID := testAccountID(t, s)
if _, err := s.CreateUser(ctx, accID, "doppelt@example.com", "hash1", "creator"); err != nil {
if _, err := s.CreateUser(ctx, accID, "doppelt@example.com", "hash1", "mitarbeiter"); err != nil {
t.Fatalf("CreateUser (1): %v", err)
}
if _, err := s.CreateUser(ctx, accID, "doppelt@example.com", "hash2", "creator"); err == nil {
if _, err := s.CreateUser(ctx, accID, "doppelt@example.com", "hash2", "mitarbeiter"); err == nil {
t.Fatal("expected error for a duplicate email, got nil")
}
}
@@ -80,7 +173,7 @@ func TestSessionCRUD(t *testing.T) {
ctx := context.Background()
accID := testAccountID(t, s)
user, err := s.CreateUser(ctx, accID, "session@example.com", "hash", "marke")
user, err := s.CreateUser(ctx, accID, "session@example.com", "hash", "verantwortlicher")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}

139
internal/store/bewertung.go Normal file
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,80 +0,0 @@
package store
import (
"context"
"fmt"
"time"
)
// Finding ist das Ergebnis einer Regel für einen Beitrag. Append-only:
// siehe Migration. ExtractionID ist optional (nil wenn ein Finding nicht
// direkt aus einer Extraktion, sondern z. B. manuell erzeugt wurde).
// Title/Fix/Sources sind zum Zeitpunkt der Regelauswertung fixiert
// gespeichert (nicht nur rule_id/rule_version referenziert), weil ein
// späteres Update der Regel-YAML eine ältere Version sonst nicht mehr
// nachträglich auflösen könnte — der Wortlaut zum Zeitpunkt des
// Findings ist der Beweis, kein Verweis darauf.
type Finding struct {
ID string
SubmissionID string
ExtractionID *string
RuleID string
RuleVersion int
Severity string
Title string
Fix string
Sources []string
CreatedAt time.Time
}
// CreateFinding speichert ein Finding. sources ist NOT NULL in der DB
// (TEXT[]) — ein nil-Slice (z. B. eine Regel ohne fundstelle-Eintrag)
// würde als SQL-NULL ankommen und mit einer wenig hilfreichen Constraint-
// Fehlermeldung abgelehnt; hier stattdessen auf eine leere Liste normiert.
func (s *Store) CreateFinding(ctx context.Context, submissionID string, extractionID *string, ruleID string, ruleVersion int, severity, title, fix string, sources []string) (Finding, error) {
if sources == nil {
sources = []string{}
}
var f Finding
err := s.Pool.QueryRow(ctx, `
INSERT INTO finding (submission_id, extraction_id, rule_id, rule_version, severity, title, fix, sources)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, submission_id, extraction_id, rule_id, rule_version, severity, title, fix, sources, created_at
`, submissionID, extractionID, ruleID, ruleVersion, severity, title, fix, sources).Scan(
&f.ID, &f.SubmissionID, &f.ExtractionID, &f.RuleID, &f.RuleVersion, &f.Severity, &f.Title, &f.Fix, &f.Sources, &f.CreatedAt,
)
if err != nil {
return Finding{}, fmt.Errorf("store: create finding: %w", err)
}
return f, nil
}
// ListCurrentFindings liefert die aktuell gültigen Findings eines
// Beitrags — Zeilen, die von keiner anderen Zeile per supersedes
// ersetzt wurden (siehe Migrationskommentar zu finding.supersedes).
func (s *Store) ListCurrentFindings(ctx context.Context, submissionID string) ([]Finding, error) {
rows, err := s.Pool.Query(ctx, `
SELECT f.id, f.submission_id, f.extraction_id, f.rule_id, f.rule_version, f.severity, f.title, f.fix, f.sources, f.created_at
FROM finding f
WHERE f.submission_id = $1
AND NOT EXISTS (SELECT 1 FROM finding f2 WHERE f2.supersedes = f.id)
ORDER BY f.created_at
`, submissionID)
if err != nil {
return nil, fmt.Errorf("store: list current findings: %w", err)
}
defer rows.Close()
var findings []Finding
for rows.Next() {
var f Finding
if err := rows.Scan(&f.ID, &f.SubmissionID, &f.ExtractionID, &f.RuleID, &f.RuleVersion, &f.Severity, &f.Title, &f.Fix, &f.Sources, &f.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan finding: %w", err)
}
findings = append(findings, f)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list current findings: %w", err)
}
return findings, nil
}

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 @@
ALTER TABLE account DROP COLUMN einladung_token;

View File

@@ -0,0 +1,8 @@
-- Sammellink für die Mitarbeiter-Selbstanmeldung (Ebene 1, "Einladung
-- annehmen" — siehe CLAUDE.md, Onboarding: "Einladungslink [Sammellink,
-- Selbstanmeldung, Abteilung beim ersten Antrag]"). Ein Token pro
-- Account, kein Ablaufdatum, per Admin erneuerbar (macht den alten
-- Link ungültig). DEFAULT gen_random_uuid()::text befüllt bestehende
-- Zeilen automatisch und liefert künftigen INSERTs einen Wert, ohne
-- dass store.CreateAccount etwas ändern muss.
ALTER TABLE account ADD COLUMN einladung_token TEXT UNIQUE NOT NULL DEFAULT gen_random_uuid()::text;

View File

@@ -1,121 +0,0 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Participant ist ein Beteiligter an einer Submission (Verantwortungs-
// matrix). Anders als finding/extraction/evidence_package ist participant
// NICHT append-only — wer vorgegeben/freigegeben hat, kann sich klären
// oder korrigieren, ohne dass das ein Beweis-Eintrag ist.
type Participant struct {
ID string
SubmissionID string
Role string
Name string
Vorgegeben bool
Freigegeben bool
ApprovedAt *time.Time
CreatedAt time.Time
}
// CreateParticipant fügt einen Beteiligten zu einer Submission hinzu.
func (s *Store) CreateParticipant(ctx context.Context, submissionID, role, name string, vorgegeben, freigegeben bool) (Participant, error) {
var p Participant
err := s.Pool.QueryRow(ctx, `
INSERT INTO participant (submission_id, role, name, vorgegeben, freigegeben, approved_at)
VALUES ($1, $2, $3, $4, $5, CASE WHEN $5 THEN now() ELSE NULL END)
RETURNING id, submission_id, role, name, vorgegeben, freigegeben, approved_at, created_at
`, submissionID, role, name, vorgegeben, freigegeben).Scan(
&p.ID, &p.SubmissionID, &p.Role, &p.Name, &p.Vorgegeben, &p.Freigegeben, &p.ApprovedAt, &p.CreatedAt,
)
if err != nil {
return Participant{}, fmt.Errorf("store: create participant: %w", err)
}
return p, nil
}
// ListParticipants liefert alle Beteiligten einer Submission.
func (s *Store) ListParticipants(ctx context.Context, submissionID string) ([]Participant, error) {
rows, err := s.Pool.Query(ctx, `
SELECT id, submission_id, role, name, vorgegeben, freigegeben, approved_at, created_at
FROM participant WHERE submission_id = $1 ORDER BY created_at
`, submissionID)
if err != nil {
return nil, fmt.Errorf("store: list participants: %w", err)
}
defer rows.Close()
var participants []Participant
for rows.Next() {
var p Participant
if err := rows.Scan(&p.ID, &p.SubmissionID, &p.Role, &p.Name, &p.Vorgegeben, &p.Freigegeben, &p.ApprovedAt, &p.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan participant: %w", err)
}
participants = append(participants, p)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list participants: %w", err)
}
return participants, nil
}
// GetParticipant liest einen Beteiligten anhand seiner ID — u. a. um vor
// einem Update/Delete zu prüfen, zu welcher Submission (und damit zu
// welchem Account) er gehört.
func (s *Store) GetParticipant(ctx context.Context, id string) (Participant, error) {
var p Participant
err := s.Pool.QueryRow(ctx, `
SELECT id, submission_id, role, name, vorgegeben, freigegeben, approved_at, created_at
FROM participant WHERE id = $1
`, id).Scan(&p.ID, &p.SubmissionID, &p.Role, &p.Name, &p.Vorgegeben, &p.Freigegeben, &p.ApprovedAt, &p.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return Participant{}, ErrNotFound
}
if err != nil {
return Participant{}, fmt.Errorf("store: get participant: %w", err)
}
return p, nil
}
// UpdateParticipant setzt vorgegeben/freigegeben. approved_at wird beim
// ersten Wechsel zu freigegeben=true gesetzt und danach nicht mehr
// verändert (er hält fest, wann zuerst freigegeben wurde).
func (s *Store) UpdateParticipant(ctx context.Context, id string, vorgegeben, freigegeben bool) (Participant, error) {
var p Participant
err := s.Pool.QueryRow(ctx, `
UPDATE participant
SET vorgegeben = $2,
freigegeben = $3,
approved_at = CASE WHEN $3 AND approved_at IS NULL THEN now() ELSE approved_at END
WHERE id = $1
RETURNING id, submission_id, role, name, vorgegeben, freigegeben, approved_at, created_at
`, id, vorgegeben, freigegeben).Scan(
&p.ID, &p.SubmissionID, &p.Role, &p.Name, &p.Vorgegeben, &p.Freigegeben, &p.ApprovedAt, &p.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return Participant{}, ErrNotFound
}
if err != nil {
return Participant{}, fmt.Errorf("store: update participant: %w", err)
}
return p, nil
}
// DeleteParticipant entfernt einen Beteiligten (z. B. versehentlich
// falsch angelegt).
func (s *Store) DeleteParticipant(ctx context.Context, id string) error {
tag, err := s.Pool.Exec(ctx, `DELETE FROM participant WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("store: delete participant: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}

View File

@@ -1,133 +0,0 @@
package store_test
import (
"context"
"errors"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
func TestParticipantCRUD(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
sub, err := s.CreateSubmission(ctx, accID, "instagram", "reel", "...")
if err != nil {
t.Fatalf("CreateSubmission: %v", err)
}
p, err := s.CreateParticipant(ctx, sub.ID, "creator", "Max Mustermann", false, false)
if err != nil {
t.Fatalf("CreateParticipant: %v", err)
}
if p.ApprovedAt != nil {
t.Fatalf("ApprovedAt should be nil when freigegeben=false, got %v", p.ApprovedAt)
}
list, err := s.ListParticipants(ctx, sub.ID)
if err != nil {
t.Fatalf("ListParticipants: %v", err)
}
if len(list) != 1 || list[0].ID != p.ID {
t.Fatalf("ListParticipants = %+v, want exactly the created participant", list)
}
got, err := s.GetParticipant(ctx, p.ID)
if err != nil {
t.Fatalf("GetParticipant: %v", err)
}
if got.SubmissionID != sub.ID {
t.Fatalf("GetParticipant.SubmissionID = %q, want %q", got.SubmissionID, sub.ID)
}
updated, err := s.UpdateParticipant(ctx, p.ID, true, true)
if err != nil {
t.Fatalf("UpdateParticipant: %v", err)
}
if !updated.Vorgegeben || !updated.Freigegeben {
t.Fatalf("UpdateParticipant did not apply new flags: %+v", updated)
}
if updated.ApprovedAt == nil {
t.Fatal("expected ApprovedAt to be set once freigegeben became true")
}
firstApproval := *updated.ApprovedAt
// Ein erneutes Update (weiterhin freigegeben) darf approved_at nicht
// verschieben — es haelt fest, wann ZUERST freigegeben wurde.
updated2, err := s.UpdateParticipant(ctx, p.ID, true, true)
if err != nil {
t.Fatalf("UpdateParticipant (2): %v", err)
}
if !updated2.ApprovedAt.Equal(firstApproval) {
t.Fatalf("ApprovedAt changed on a no-op update: %v -> %v", firstApproval, *updated2.ApprovedAt)
}
if err := s.DeleteParticipant(ctx, p.ID); err != nil {
t.Fatalf("DeleteParticipant: %v", err)
}
if _, err := s.GetParticipant(ctx, p.ID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err after delete = %v, want store.ErrNotFound", err)
}
}
func TestUpdateParticipantNotFound(t *testing.T) {
s := openTestStore(t)
_, err := s.UpdateParticipant(context.Background(), "00000000-0000-0000-0000-000000000000", true, true)
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound", err)
}
}
func TestDeleteParticipantNotFound(t *testing.T) {
s := openTestStore(t)
err := s.DeleteParticipant(context.Background(), "00000000-0000-0000-0000-000000000000")
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound", err)
}
}
func TestListSubmissionsForAccount(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
subNoFindings, err := s.CreateSubmission(ctx, accID, "instagram", "reel", "organic")
if err != nil {
t.Fatalf("CreateSubmission: %v", err)
}
subWithFinding, err := s.CreateSubmission(ctx, accID, "tiktok", "video", "unmarked ad")
if err != nil {
t.Fatalf("CreateSubmission: %v", err)
}
// sources bewusst nil statt []string{} — CreateFinding muss das
// selbst abfangen (siehe Kommentar dort), nicht der Aufrufer.
if _, err := s.CreateFinding(ctx, subWithFinding.ID, nil, "WK-001", 1, "hoch", "t", "f", nil); err != nil {
t.Fatalf("CreateFinding: %v", err)
}
// Andere Mandanten duerfen nicht auftauchen.
otherAccID := testAccountID(t, s)
if _, err := s.CreateSubmission(ctx, otherAccID, "instagram", "reel", "anderer Mandant"); err != nil {
t.Fatalf("CreateSubmission (other account): %v", err)
}
list, err := s.ListSubmissionsForAccount(ctx, accID)
if err != nil {
t.Fatalf("ListSubmissionsForAccount: %v", err)
}
if len(list) != 2 {
t.Fatalf("expected 2 submissions for this account, got %d: %+v", len(list), list)
}
byID := map[string]store.SubmissionSummary{}
for _, s := range list {
byID[s.ID] = s
}
if byID[subNoFindings.ID].FindingCount != 0 || byID[subNoFindings.ID].HighestSeverity != "" {
t.Errorf("subNoFindings summary = %+v, want 0 findings and no severity", byID[subNoFindings.ID])
}
if byID[subWithFinding.ID].FindingCount != 1 || byID[subWithFinding.ID].HighestSeverity != "hoch" {
t.Errorf("subWithFinding summary = %+v, want 1 finding, severity hoch", byID[subWithFinding.ID])
}
}

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,135 +0,0 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Submission ist ein eingereichter Beitrag. AccountID ist der Mandant,
// dem der Beitrag gehört (Mandantentrennung) — jede Abfrage, die einen
// Beitrag ausliefert, muss AccountID gegen den angemeldeten Account
// prüfen (siehe internal/web-Middleware), store selbst erzwingt das
// nicht auf Zeilenebene.
type Submission struct {
ID string
AccountID string
Platform string
PostType string
Caption string
Status string
CreatedAt time.Time
UpdatedAt time.Time
}
// CreateSubmission legt einen neuen Beitrag für einen Mandanten an
// (Status "draft").
func (s *Store) CreateSubmission(ctx context.Context, accountID, platform, postType, caption string) (Submission, error) {
var sub Submission
err := s.Pool.QueryRow(ctx, `
INSERT INTO submission (account_id, platform, post_type, caption)
VALUES ($1, $2, $3, $4)
RETURNING id, account_id, platform, post_type, caption, status, created_at, updated_at
`, accountID, platform, postType, caption).Scan(
&sub.ID, &sub.AccountID, &sub.Platform, &sub.PostType, &sub.Caption, &sub.Status, &sub.CreatedAt, &sub.UpdatedAt,
)
if err != nil {
return Submission{}, fmt.Errorf("store: create submission: %w", err)
}
return sub, nil
}
// GetSubmission liest einen Beitrag anhand seiner ID — ohne
// Mandanten-Prüfung, das ist Sache des Aufrufers (siehe Submission.AccountID).
func (s *Store) GetSubmission(ctx context.Context, id string) (Submission, error) {
var sub Submission
err := s.Pool.QueryRow(ctx, `
SELECT id, account_id, platform, post_type, caption, status, created_at, updated_at
FROM submission WHERE id = $1
`, id).Scan(
&sub.ID, &sub.AccountID, &sub.Platform, &sub.PostType, &sub.Caption, &sub.Status, &sub.CreatedAt, &sub.UpdatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return Submission{}, ErrNotFound
}
if err != nil {
return Submission{}, fmt.Errorf("store: get submission: %w", err)
}
return sub, nil
}
// SubmissionSummary ist eine Submission plus einer Kurzfassung ihrer
// aktuell gültigen Findings, wie sie eine Übersichtsliste braucht (ohne
// für jede Zeile extra ListCurrentFindings aufzurufen).
type SubmissionSummary struct {
Submission
FindingCount int
HighestSeverity string // "" wenn keine Findings
}
// ListSubmissionsForAccount liefert alle Beiträge eines Mandanten,
// neueste zuerst, mit Findings-Kurzfassung.
func (s *Store) ListSubmissionsForAccount(ctx context.Context, accountID string) ([]SubmissionSummary, error) {
rows, err := s.Pool.Query(ctx, `
SELECT
s.id, s.account_id, s.platform, s.post_type, s.caption, s.status, s.created_at, s.updated_at,
COUNT(f.id) AS finding_count,
COALESCE(MAX(CASE f.severity WHEN 'hoch' THEN 3 WHEN 'mittel' THEN 2 WHEN 'niedrig' THEN 1 ELSE 0 END), 0) AS severity_rank
FROM submission s
LEFT JOIN finding f
ON f.submission_id = s.id
AND NOT EXISTS (SELECT 1 FROM finding f2 WHERE f2.supersedes = f.id)
WHERE s.account_id = $1
GROUP BY s.id
ORDER BY s.created_at DESC
`, accountID)
if err != nil {
return nil, fmt.Errorf("store: list submissions for account: %w", err)
}
defer rows.Close()
var out []SubmissionSummary
for rows.Next() {
var sub SubmissionSummary
var severityRank int
if err := rows.Scan(
&sub.ID, &sub.AccountID, &sub.Platform, &sub.PostType, &sub.Caption, &sub.Status, &sub.CreatedAt, &sub.UpdatedAt,
&sub.FindingCount, &severityRank,
); err != nil {
return nil, fmt.Errorf("store: scan submission summary: %w", err)
}
switch severityRank {
case 3:
sub.HighestSeverity = "hoch"
case 2:
sub.HighestSeverity = "mittel"
case 1:
sub.HighestSeverity = "niedrig"
}
out = append(out, sub)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list submissions for account: %w", err)
}
return out, nil
}
// SetSubmissionStatus setzt den Status eines Beitrags (submission ist,
// anders als extraction/finding/evidence_package, NICHT append-only —
// der Lebenszyklus draft → checked → published → archived ist eine
// normale Zustandsänderung, kein Beweis-Eintrag).
func (s *Store) SetSubmissionStatus(ctx context.Context, id, status string) error {
tag, err := s.Pool.Exec(ctx, `
UPDATE submission SET status = $2, updated_at = now() WHERE id = $1
`, id, status)
if err != nil {
return fmt.Errorf("store: set submission status: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("store: set submission status: submission %s nicht gefunden", id)
}
return nil
}

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,72 @@
package web_test
import (
"context"
"net/http"
"net/url"
"strings"
"testing"
)
// TestAdminHatFachebeneRechte prüft die pragmatische Lösung für "Admin
// und KI-Verantwortlicher auf derselben Person" (siehe CLAUDE.md,
// Offene Punkte / requireFachebene): ein admin sieht den Posteingang
// und kann entscheiden, genau wie ein verantwortlicher.
func TestAdminSiehtPosteingang(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
_, adminCookie := seedFallImAccount(t, fs, s, "admin")
resp := getWithCookie(t, s, adminCookie, "/faelle")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "Angebotstexte generieren") {
t.Errorf("expected the open antrag in the Posteingang, got: %s", resp.Body.String())
}
}
func TestAdminSiehtEntscheidenFormular(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
antragID, adminCookie := seedFallImAccount(t, fs, s, "admin")
resp := getWithCookie(t, s, adminCookie, "/faelle/"+antragID)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), `name="entscheidung"`) {
t.Errorf("expected the Entscheiden form for admin, got: %s", resp.Body.String())
}
}
func TestAdminKannEntscheiden(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
antragID, adminCookie := seedFallImAccount(t, fs, s, "admin")
resp := postForm(t, s, adminCookie, "/faelle/"+antragID+"/entscheiden", url.Values{"entscheidung": {"rueckfrage"}})
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
antrag, err := fs.GetAntrag(context.Background(), antragID)
if err != nil {
t.Fatalf("GetAntrag: %v", err)
}
if antrag.Status != "entschieden" {
t.Errorf("Status = %q, want entschieden", antrag.Status)
}
}
func TestAdminSiehtRegisterUndWiedervorlage(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
for _, path := range []string{"/registereintraege", "/wiedervorlage"} {
resp := getWithCookie(t, s, adminCookie, path)
if resp.Code != http.StatusOK {
t.Errorf("GET %s status = %d, want 200 for admin", path, resp.Code)
}
}
}

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

@@ -1,237 +0,0 @@
package web
import (
"net/http"
"github.com/netcell-it/deklarix/internal/store"
)
type submissionListItem struct {
ID string
Platform string
PostType string
Status string
CreatedAt string
FindingCount int
HighestSeverity string
}
type submissionListData struct {
Title string
Submissions []submissionListItem
}
// handleSubmissionList zeigt die Archiv-Übersicht: alle Beiträge des
// angemeldeten Mandanten mit Kurzfassung der Findings.
func (s *Server) handleSubmissionList(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
summaries, err := s.store.ListSubmissionsForAccount(ctx, currentUser(r).AccountID)
if err != nil {
http.Error(w, "Beiträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := submissionListData{Title: "Beiträge"}
for _, sum := range summaries {
data.Submissions = append(data.Submissions, submissionListItem{
ID: sum.ID, Platform: sum.Platform, PostType: sum.PostType, Status: sum.Status,
CreatedAt: sum.CreatedAt.Format("02.01.2006 15:04"),
FindingCount: sum.FindingCount, HighestSeverity: sum.HighestSeverity,
})
}
if err := s.templates.ExecuteTemplate(w, "beitraege", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type participantView struct {
ID string
Role string
Name string
Vorgegeben bool
Freigegeben bool
ApprovedAt string // leer, wenn noch nicht freigegeben
}
type submissionDetailData struct {
Title string
SubmissionID string
Platform string
PostType string
Caption string
Status string
CreatedAt string
CanArchive bool
IsPublished bool
DossierURL string
Findings []findingView
Participants []participantView
}
// loadOwnSubmission lädt eine Submission und prüft die Mandantenzugehörigkeit.
// Wie in handleArchive/handleDossierDownload (siehe handlers.go): ein Beitrag
// eines anderen Accounts wird wie ein nicht existierender behandelt.
func (s *Server) loadOwnSubmission(r *http.Request, id string) (store.Submission, error) {
sub, err := s.store.GetSubmission(r.Context(), id)
if err != nil {
return store.Submission{}, err
}
if sub.AccountID != currentUser(r).AccountID {
return store.Submission{}, store.ErrNotFound
}
return sub, nil
}
func toParticipantViews(participants []store.Participant) []participantView {
views := make([]participantView, len(participants))
for i, p := range participants {
v := participantView{ID: p.ID, Role: p.Role, Name: p.Name, Vorgegeben: p.Vorgegeben, Freigegeben: p.Freigegeben}
if p.ApprovedAt != nil {
v.ApprovedAt = p.ApprovedAt.Format("02.01.2006 15:04")
}
views[i] = v
}
return views
}
// handleSubmissionDetail zeigt einen einzelnen Beitrag: Fakten, Findings,
// Verantwortungsmatrix (Beteiligte) inklusive Verwaltung.
func (s *Server) handleSubmissionDetail(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
sub, err := s.loadOwnSubmission(r, r.PathValue("id"))
if err != nil {
http.Error(w, "Beitrag nicht gefunden", http.StatusNotFound)
return
}
storeFindings, err := s.store.ListCurrentFindings(ctx, sub.ID)
if err != nil {
http.Error(w, "Findings konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
findings := make([]findingView, len(storeFindings))
for i, f := range storeFindings {
findings[i] = findingView{
RuleID: f.RuleID, Version: f.RuleVersion, Severity: f.Severity,
Title: f.Title, Fix: f.Fix, Sources: f.Sources,
}
}
participants, err := s.store.ListParticipants(ctx, sub.ID)
if err != nil {
http.Error(w, "Beteiligte konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := submissionDetailData{
Title: "Beitrag", SubmissionID: sub.ID, Platform: sub.Platform, PostType: sub.PostType,
Caption: sub.Caption, Status: sub.Status, CreatedAt: sub.CreatedAt.Format("02.01.2006 15:04"),
CanArchive: sub.Status == "checked", IsPublished: sub.Status == "published",
DossierURL: "/dossier/" + sub.ID, Findings: findings, Participants: toParticipantViews(participants),
}
if err := s.templates.ExecuteTemplate(w, "beitrag", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type participantListData struct {
SubmissionID string
Participants []participantView
}
// renderParticipantList rendert das Beteiligten-Fragment neu — Ziel für
// htmx-Swaps nach Hinzufügen/Ändern/Löschen, damit die Seite nicht neu
// geladen werden muss.
func (s *Server) renderParticipantList(w http.ResponseWriter, r *http.Request, submissionID string) {
participants, err := s.store.ListParticipants(r.Context(), submissionID)
if err != nil {
http.Error(w, "Beteiligte konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := participantListData{SubmissionID: submissionID, Participants: toParticipantViews(participants)}
if err := s.templates.ExecuteTemplate(w, "beteiligte-liste", data); err != nil {
http.Error(w, "Liste konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// handleAddParticipant fügt einen Beteiligten zu einem Beitrag hinzu.
func (s *Server) handleAddParticipant(w http.ResponseWriter, r *http.Request) {
sub, err := s.loadOwnSubmission(r, r.PathValue("id"))
if err != nil {
http.Error(w, "Beitrag nicht gefunden", http.StatusNotFound)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
role := r.FormValue("role")
name := r.FormValue("name")
if role == "" || name == "" {
http.Error(w, "Rolle und Name sind Pflichtfelder", http.StatusBadRequest)
return
}
if _, err := s.store.CreateParticipant(r.Context(), sub.ID, role, name, false, false); err != nil {
http.Error(w, "Beteiligter konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
return
}
s.renderParticipantList(w, r, sub.ID)
}
// participantBelongsToOwnSubmission prüft, dass der Beteiligte tatsächlich
// zu einem Beitrag des angemeldeten Mandanten gehört — sonst könnte ein
// fremder Mandant über eine erratene participant_id einen Beteiligten
// eines anderen Accounts ändern oder löschen.
func (s *Server) participantBelongsToOwnSubmission(r *http.Request, submissionIDFromURL, participantID string) (store.Participant, error) {
p, err := s.store.GetParticipant(r.Context(), participantID)
if err != nil {
return store.Participant{}, err
}
if p.SubmissionID != submissionIDFromURL {
return store.Participant{}, store.ErrNotFound
}
if _, err := s.loadOwnSubmission(r, p.SubmissionID); err != nil {
return store.Participant{}, err
}
return p, nil
}
// handleUpdateParticipant setzt vorgegeben/freigegeben für einen Beteiligten.
func (s *Server) handleUpdateParticipant(w http.ResponseWriter, r *http.Request) {
submissionID := r.PathValue("id")
p, err := s.participantBelongsToOwnSubmission(r, submissionID, r.PathValue("pid"))
if err != nil {
http.Error(w, "Beteiligter nicht gefunden", http.StatusNotFound)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
vorgegeben := r.FormValue("vorgegeben") == "on"
freigegeben := r.FormValue("freigegeben") == "on"
if _, err := s.store.UpdateParticipant(r.Context(), p.ID, vorgegeben, freigegeben); err != nil {
http.Error(w, "Beteiligter konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
return
}
s.renderParticipantList(w, r, submissionID)
}
// handleDeleteParticipant entfernt einen Beteiligten.
func (s *Server) handleDeleteParticipant(w http.ResponseWriter, r *http.Request) {
submissionID := r.PathValue("id")
p, err := s.participantBelongsToOwnSubmission(r, submissionID, r.PathValue("pid"))
if err != nil {
http.Error(w, "Beteiligter nicht gefunden", http.StatusNotFound)
return
}
if err := s.store.DeleteParticipant(r.Context(), p.ID); err != nil {
http.Error(w, "Beteiligter konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
return
}
s.renderParticipantList(w, r, submissionID)
}

View File

@@ -1,202 +0,0 @@
package web_test
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/rules"
)
// checkAndReturnSubID führt eine Pre-Publish-Prüfung durch und liefert die
// dabei angelegte submission_id — Hilfsfunktion für Tests, die einen
// bereits existierenden Beitrag brauchen, ohne den ganzen Ablauf jedes Mal
// auszuschreiben.
func checkAndReturnSubID(t *testing.T, s interface {
ServeHTTP(w http.ResponseWriter, r *http.Request)
}, cookie *http.Cookie) string {
t.Helper()
form := checkForm()
req := httptest.NewRequest(http.MethodPost, "/pruefen", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("check status = %d, body: %s", w.Code, w.Body.String())
}
body := w.Body.String()
const marker = `name="submission_id" value="`
idx := strings.Index(body, marker)
if idx == -1 {
t.Fatalf("expected a submission_id field in the result, got: %s", body)
}
rest := body[idx+len(marker):]
return rest[:strings.Index(rest, `"`)]
}
func TestSubmissionListShowsOwnSubmissionsOnly(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}}, fs)
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
subA := checkAndReturnSubID(t, s, cookieA)
_ = checkAndReturnSubID(t, s, cookieB)
resp := getWithCookie(t, s, cookieA, "/beitraege")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", resp.Code, resp.Body.String())
}
body := resp.Body.String()
if !strings.Contains(body, "/beitraege/"+subA) {
t.Errorf("expected Mandant A's own submission link, got: %s", body)
}
// Mandant B hat genau einen eigenen Beitrag, keinen von A.
respB := getWithCookie(t, s, cookieB, "/beitraege")
if strings.Contains(respB.Body.String(), "/beitraege/"+subA) {
t.Errorf("Mandant B should not see Mandant A's submission, got: %s", respB.Body.String())
}
}
func TestSubmissionListRequiresSession(t *testing.T) {
s, _, _ := newAuthedTestServer(t, fakeExtractor{})
req := httptest.NewRequest(http.MethodGet, "/beitraege", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303 redirect to /login", w.Code)
}
}
func TestSubmissionDetailShowsFactsAndFindings(t *testing.T) {
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationPaid,
DisclosurePresent: true, DisclosureWording: "Werbung", DisclosureBeforeCut: false,
}})
subID := checkAndReturnSubID(t, s, cookie)
_ = fs
resp := getWithCookie(t, s, cookie, "/beitraege/"+subID)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", resp.Code, resp.Body.String())
}
body := resp.Body.String()
if !strings.Contains(body, "WK-004") {
t.Errorf("expected the finding to be shown, got: %s", body)
}
if !strings.Contains(body, "/veroeffentlichen") {
t.Errorf("expected an archive option for a checked submission, got: %s", body)
}
}
func TestSubmissionDetailTenantIsolation(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}}, fs)
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
subA := checkAndReturnSubID(t, s, cookieA)
resp := getWithCookie(t, s, cookieB, "/beitraege/"+subA)
if resp.Code != http.StatusNotFound {
t.Fatalf("cross-tenant detail status = %d, want 404", resp.Code)
}
}
func TestParticipantAddUpdateDeleteFlow(t *testing.T) {
s, _, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}})
subID := checkAndReturnSubID(t, s, cookie)
addResp := postForm(t, s, cookie, "/beitraege/"+subID+"/beteiligte", url.Values{
"role": {"creator"}, "name": {"Max Mustermann"},
})
if addResp.Code != http.StatusOK {
t.Fatalf("add participant status = %d, body: %s", addResp.Code, addResp.Body.String())
}
if !strings.Contains(addResp.Body.String(), "Max Mustermann") {
t.Fatalf("expected the new participant in the response, got: %s", addResp.Body.String())
}
detailResp := getWithCookie(t, s, cookie, "/beitraege/"+subID)
if !strings.Contains(detailResp.Body.String(), "Max Mustermann") {
t.Fatalf("expected the participant on the detail page, got: %s", detailResp.Body.String())
}
// Beteiligten-ID aus dem Update-Formular extrahieren.
body := addResp.Body.String()
const marker = "/beteiligte/"
idx := strings.Index(body, marker)
if idx == -1 {
t.Fatalf("expected a participant action URL, got: %s", body)
}
rest := body[idx+len(marker):]
pID := rest[:strings.Index(rest, "/")]
updateResp := postForm(t, s, cookie, "/beitraege/"+subID+"/beteiligte/"+pID+"/aktualisieren", url.Values{
"vorgegeben": {"on"}, "freigegeben": {"on"},
})
if updateResp.Code != http.StatusOK {
t.Fatalf("update participant status = %d, body: %s", updateResp.Code, updateResp.Body.String())
}
if !strings.Contains(updateResp.Body.String(), "freigegeben am") {
t.Fatalf("expected an approval timestamp after freigegeben=true, got: %s", updateResp.Body.String())
}
deleteResp := postForm(t, s, cookie, "/beitraege/"+subID+"/beteiligte/"+pID+"/loeschen", url.Values{})
if deleteResp.Code != http.StatusOK {
t.Fatalf("delete participant status = %d, body: %s", deleteResp.Code, deleteResp.Body.String())
}
if strings.Contains(deleteResp.Body.String(), "Max Mustermann") {
t.Fatalf("expected the participant to be gone after delete, got: %s", deleteResp.Body.String())
}
}
func TestParticipantActionsRejectCrossTenantAccess(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}}, fs)
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
subA := checkAndReturnSubID(t, s, cookieA)
// Mandant B darf für As Beitrag gar keinen Beteiligten anlegen.
addResp := postForm(t, s, cookieB, "/beitraege/"+subA+"/beteiligte", url.Values{
"role": {"creator"}, "name": {"Fremd"},
})
if addResp.Code != http.StatusNotFound {
t.Fatalf("cross-tenant add status = %d, want 404", addResp.Code)
}
p, err := fs.CreateParticipant(context.Background(), subA, "creator", "Eigener Beteiligter", false, false)
if err != nil {
t.Fatalf("CreateParticipant: %v", err)
}
updateResp := postForm(t, s, cookieB, "/beitraege/"+subA+"/beteiligte/"+p.ID+"/aktualisieren", url.Values{
"vorgegeben": {"on"},
})
if updateResp.Code != http.StatusNotFound {
t.Fatalf("cross-tenant update status = %d, want 404", updateResp.Code)
}
deleteResp := postForm(t, s, cookieB, "/beitraege/"+subA+"/beteiligte/"+p.ID+"/loeschen", url.Values{})
if deleteResp.Code != http.StatusNotFound {
t.Fatalf("cross-tenant delete status = %d, want 404", deleteResp.Code)
}
if _, err := fs.GetParticipant(context.Background(), p.ID); err != nil {
t.Fatalf("participant should still exist after rejected cross-tenant delete: %v", err)
}
}

View File

@@ -26,10 +26,14 @@ func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden"})
}
// handleRegister legt einen neuen Mandanten (Account) und den ersten
// Nutzer darin an. Es gibt bewusst keinen separaten "Account beitreten"-
// Flow — das wäre Schritt 4 (Agentur-/Markensicht mit mehreren Nutzern
// pro Account), hier reicht ein Nutzer pro neuem Account.
// handleRegister ist die "Firma"-Registrierung (Ebene 1 der Frontend-
// Spezifikation): legt einen neuen Mandanten (Account) und den ersten
// Nutzer als admin an. Testzugang ist sofort aktiv, der Bezahlbetrieb
// wird separat vom Betreiber freigeschaltet (nicht Teil dieses Flows).
// Es gibt bewusst KEINE offene Selbstregistrierung für einzelne
// Mitarbeiter über dieses Formular — die kommen über den
// Einladungslink (siehe einladung_handlers.go) oder künftig CSV-
// Import/Mandantenlink (weiterhin nicht gebaut).
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "ungültiges Formular"})
@@ -39,8 +43,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
accountName := r.FormValue("account_name")
email := r.FormValue("email")
password := r.FormValue("password")
role := r.FormValue("role")
if accountName == "" || email == "" || password == "" || role == "" {
if accountName == "" || email == "" || password == "" {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Alle Felder sind Pflicht"})
return
}
@@ -57,7 +60,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Konto konnte nicht angelegt werden"})
return
}
user, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, role)
user, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, "admin")
if err != nil {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"})
return
@@ -91,11 +94,25 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "E-Mail oder Passwort falsch"})
return
}
// Erst NACH erfolgreicher Passwortprüfung unterscheiden — sonst würde
// diese Meldung einem Angreifer ohne gültiges Passwort verraten, dass
// die E-Mail-Adresse zu einem (deaktivierten) Konto gehört.
if !user.Active {
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "Dieses Konto ist deaktiviert"})
return
}
if err := s.startSession(w, r, user.ID); err != nil {
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "Sitzung konnte nicht gestartet werden"})
return
}
// Ein Betreiber-Login hat keinen eigenen Antrag zu stellen — der
// Plattform-Bereich ist der sinnvolle Einstieg (Ebene 5, technisch
// getrennt vom Mandantenbereich).
if user.Role == "betreiber" {
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}

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,184 @@
// Ebene 4 — CSV-Import für Mitarbeiter (zweiter Punkt der geplanten
// Onboarding-Reihenfolge, siehe CLAUDE.md, Onboarding). Es gibt keine
// E-Mail-Infrastruktur (siehe Einladungslink, Nutzerverwaltung) — wie
// beim einzelnen "Nutzer anlegen" generiert der Import ein
// Einmalpasswort pro Zeile, das der Admin nach dem Import EINMALIG
// sieht und selbst weitergeben muss. Kein Rollback bei Teilfehlern:
// gültige Zeilen werden angelegt, fehlerhafte werden einzeln gemeldet
// (keine Alles-oder-nichts-Transaktion — eine falsche Zeile in einer
// Liste von 50 Mitarbeitenden soll nicht die anderen 49 blockieren).
package web
import (
"encoding/csv"
"fmt"
"io"
"net/http"
"strings"
"github.com/netcell-it/deklarix/internal/auth"
)
type csvMitarbeiterZeile struct {
Zeile int // 1-basiert, Kopfzeile ist Zeile 1 — für Fehlermeldungen
Email string
Role string
}
// parseCSVMitarbeiter liest eine Mitarbeiter-CSV mit einer Kopfzeile.
// Pflichtspalte "email" (oder "e-mail"), optionale Spalte "role" (oder
// "rolle") — fehlt sie oder ist der Wert leer/ungültig, wird das erst
// beim Anlegen selbst gemeldet, nicht schon beim Parsen (damit eine
// einzelne falsche Rolle nicht den ganzen Import verhindert).
func parseCSVMitarbeiter(r io.Reader) ([]csvMitarbeiterZeile, error) {
cr := csv.NewReader(r)
cr.TrimLeadingSpace = true
cr.FieldsPerRecord = -1
header, err := cr.Read()
if err != nil {
return nil, fmt.Errorf("Kopfzeile konnte nicht gelesen werden: %w", err)
}
emailCol, roleCol := -1, -1
for i, h := range header {
switch strings.ToLower(strings.TrimSpace(h)) {
case "email", "e-mail":
emailCol = i
case "role", "rolle":
roleCol = i
}
}
if emailCol == -1 {
return nil, fmt.Errorf(`Kopfzeile muss eine Spalte "email" enthalten`)
}
var out []csvMitarbeiterZeile
zeile := 1
for {
record, err := cr.Read()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("Zeile %d: %w", zeile+1, err)
}
zeile++
if emailCol >= len(record) {
continue
}
email := strings.TrimSpace(record[emailCol])
if email == "" {
continue
}
role := "mitarbeiter"
if roleCol != -1 && roleCol < len(record) {
if v := strings.TrimSpace(record[roleCol]); v != "" {
role = v
}
}
out = append(out, csvMitarbeiterZeile{Zeile: zeile, Email: email, Role: role})
}
return out, nil
}
// generateEinmalPasswort erzeugt ein zufälliges Initialpasswort für
// eine importierte Zeile — wiederverwendet auth.NewSessionToken() statt
// einen eigenen Zufallsgenerator zu schreiben, nur gekürzt auf eine
// Länge, die sich noch manuell weitergeben lässt.
func generateEinmalPasswort() (string, error) {
token, err := auth.NewSessionToken()
if err != nil {
return "", err
}
return token[:16], nil
}
type csvImportFormData struct {
Title string
Nav navData
Error string
}
func (s *Server) handleNutzerCSVImportForm(w http.ResponseWriter, r *http.Request) {
data := csvImportFormData{Title: "Mitarbeiter per CSV importieren", Nav: navFor(r)}
if err := s.templates.ExecuteTemplate(w, "nutzer-import", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
func (s *Server) renderCSVImportForm(w http.ResponseWriter, r *http.Request, errMsg string) {
data := csvImportFormData{Title: "Mitarbeiter per CSV importieren", Nav: navFor(r), Error: errMsg}
if err := s.templates.ExecuteTemplate(w, "nutzer-import", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type csvImportErgebnisZeile struct {
Zeile int
Email string
Role string
Passwort string // nur bei Erfolg gesetzt
Fehler string // nur bei Fehler gesetzt
}
type csvImportErgebnisData struct {
Title string
Nav navData
Ergebnisse []csvImportErgebnisZeile
}
// handleNutzerCSVImportUpload liest die hochgeladene CSV-Datei und legt
// pro gültiger Zeile einen Login im eigenen Mandanten an. Das Ergebnis
// (inkl. Einmalpasswort) wird nur EINMAL auf dieser Antwortseite
// gezeigt — danach ist nur noch der Hash in der Datenbank vorhanden.
func (s *Server) handleNutzerCSVImportUpload(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(1 << 20); err != nil { // 1 MB reicht für eine Mitarbeiterliste
s.renderCSVImportForm(w, r, "Datei konnte nicht gelesen werden: "+err.Error())
return
}
file, _, err := r.FormFile("csv")
if err != nil {
s.renderCSVImportForm(w, r, "Bitte eine CSV-Datei auswählen")
return
}
defer file.Close()
zeilen, err := parseCSVMitarbeiter(file)
if err != nil {
s.renderCSVImportForm(w, r, err.Error())
return
}
if len(zeilen) == 0 {
s.renderCSVImportForm(w, r, "Die Datei enthält keine gültigen Zeilen")
return
}
accountID := currentUser(r).AccountID
var ergebnisse []csvImportErgebnisZeile
for _, z := range zeilen {
if !istMandantenRolle(z.Role) {
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: fmt.Sprintf("ungültige Rolle %q", z.Role)})
continue
}
passwort, err := generateEinmalPasswort()
if err != nil {
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: "Passwort konnte nicht erzeugt werden"})
continue
}
hash, err := auth.HashPassword(passwort)
if err != nil {
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: err.Error()})
continue
}
if _, err := s.store.CreateUser(r.Context(), accountID, z.Email, hash, z.Role); err != nil {
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Fehler: "E-Mail evtl. schon vergeben"})
continue
}
ergebnisse = append(ergebnisse, csvImportErgebnisZeile{Zeile: z.Zeile, Email: z.Email, Role: z.Role, Passwort: passwort})
}
data := csvImportErgebnisData{Title: "Import-Ergebnis", Nav: navFor(r), Ergebnisse: ergebnisse}
if err := s.templates.ExecuteTemplate(w, "nutzer-import-ergebnis", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,149 @@
package web_test
import (
"bytes"
"context"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/web"
)
// postCSV lädt csvContent als multipart-Formular mit Feldname "csv" hoch
// — reale Browser-Uploads laufen genauso über multipart/form-data.
func postCSV(t *testing.T, s *web.Server, cookie *http.Cookie, path, csvContent string) *httptest.ResponseRecorder {
t.Helper()
var body bytes.Buffer
mw := multipart.NewWriter(&body)
fw, err := mw.CreateFormFile("csv", "mitarbeiter.csv")
if err != nil {
t.Fatalf("CreateFormFile: %v", err)
}
if _, err := fw.Write([]byte(csvContent)); err != nil {
t.Fatalf("Write: %v", err)
}
if err := mw.Close(); err != nil {
t.Fatalf("mw.Close: %v", err)
}
req := httptest.NewRequest(http.MethodPost, path, &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
if cookie != nil {
req.AddCookie(cookie)
}
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
return w
}
func TestAdminCanImportMitarbeiterPerCSV(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email,role\nlisa@example.com,verantwortlicher\ntom@example.com,\n")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
body := resp.Body.String()
if !strings.Contains(body, "lisa@example.com") || !strings.Contains(body, "tom@example.com") {
t.Fatalf("expected both imported emails in the result, got: %s", body)
}
lisa, err := fs.GetUserByEmail(context.Background(), "lisa@example.com")
if err != nil {
t.Fatalf("GetUserByEmail (lisa): %v", err)
}
if lisa.Role != "verantwortlicher" {
t.Errorf("lisa.Role = %q, want verantwortlicher", lisa.Role)
}
tom, err := fs.GetUserByEmail(context.Background(), "tom@example.com")
if err != nil {
t.Fatalf("GetUserByEmail (tom): %v", err)
}
if tom.Role != "mitarbeiter" {
t.Errorf("tom.Role = %q, want default mitarbeiter", tom.Role)
}
}
func TestCSVImportZeigtEinmalpasswortDasFunktioniert(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email\nneu@example.com\n")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
// Das angezeigte Einmalpasswort aus der <code>-Zelle extrahieren.
body := resp.Body.String()
start := strings.Index(body, "<code>")
end := strings.Index(body, "</code>")
if start == -1 || end == -1 {
t.Fatalf("expected a <code>-Zelle mit dem Einmalpasswort, got: %s", body)
}
passwort := body[start+len("<code>") : end]
loginResp := postForm(t, s, nil, "/login", map[string][]string{
"email": {"neu@example.com"}, "password": {passwort},
})
if loginResp.Code != http.StatusSeeOther {
t.Fatalf("login with the generated password: status = %d, body: %s", loginResp.Code, loginResp.Body.String())
}
}
func TestCSVImportMeldetUngueltigeRolleOhneDieAnderenZuBlockieren(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
resp := postCSV(t, s, adminCookie, "/verwaltung/nutzer/import", "email,role\nboese@example.com,betreiber\ngut@example.com,mitarbeiter\n")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if _, err := fs.GetUserByEmail(context.Background(), "boese@example.com"); err == nil {
t.Error("expected no user to be created for an invalid role (betreiber)")
}
if _, err := fs.GetUserByEmail(context.Background(), "gut@example.com"); err != nil {
t.Error("expected the valid row to still be imported despite the earlier invalid row")
}
}
func TestCSVImportOhneDateiZeigtFehler(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
var body bytes.Buffer
mw := multipart.NewWriter(&body)
if err := mw.Close(); err != nil {
t.Fatalf("mw.Close: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/verwaltung/nutzer/import", &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.AddCookie(adminCookie)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "CSV-Datei auswählen") {
t.Errorf("expected an error asking for a file, got: %s", w.Body.String())
}
}
func TestMitarbeiterCannotAccessCSVImport(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
resp := getWithCookie(t, s, cookie, "/verwaltung/nutzer/import")
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
}
}

View File

@@ -0,0 +1,64 @@
package web
import (
"strings"
"testing"
)
func TestParseCSVMitarbeiterMitRolle(t *testing.T) {
csv := "email,role\nlisa@example.com,verantwortlicher\ntom@example.com,\n"
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
if err != nil {
t.Fatalf("parseCSVMitarbeiter: %v", err)
}
if len(zeilen) != 2 {
t.Fatalf("zeilen = %+v, want 2", zeilen)
}
if zeilen[0].Email != "lisa@example.com" || zeilen[0].Role != "verantwortlicher" {
t.Errorf("zeilen[0] = %+v, unerwartet", zeilen[0])
}
if zeilen[1].Email != "tom@example.com" || zeilen[1].Role != "mitarbeiter" {
t.Errorf("zeilen[1] = %+v, want default role mitarbeiter", zeilen[1])
}
}
func TestParseCSVMitarbeiterOhneRollenspalte(t *testing.T) {
csv := "email\nlisa@example.com\n"
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
if err != nil {
t.Fatalf("parseCSVMitarbeiter: %v", err)
}
if len(zeilen) != 1 || zeilen[0].Role != "mitarbeiter" {
t.Fatalf("zeilen = %+v, unerwartet", zeilen)
}
}
func TestParseCSVMitarbeiterUeberspringtLeereEmail(t *testing.T) {
csv := "email,role\n,mitarbeiter\nlisa@example.com,mitarbeiter\n"
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
if err != nil {
t.Fatalf("parseCSVMitarbeiter: %v", err)
}
if len(zeilen) != 1 || zeilen[0].Email != "lisa@example.com" {
t.Fatalf("zeilen = %+v, want exactly lisa@example.com", zeilen)
}
}
func TestParseCSVMitarbeiterOhneEmailSpalteSchlaegtFehl(t *testing.T) {
csv := "name,role\nLisa,mitarbeiter\n"
_, err := parseCSVMitarbeiter(strings.NewReader(csv))
if err == nil {
t.Fatal("expected an error when the header has no email column")
}
}
func TestParseCSVMitarbeiterZeilennummernZaehlenKopfzeileAlsEins(t *testing.T) {
csv := "email\nlisa@example.com\ntom@example.com\n"
zeilen, err := parseCSVMitarbeiter(strings.NewReader(csv))
if err != nil {
t.Fatalf("parseCSVMitarbeiter: %v", err)
}
if len(zeilen) != 2 || zeilen[0].Zeile != 2 || zeilen[1].Zeile != 3 {
t.Fatalf("zeilen = %+v, want Zeile 2 and 3", zeilen)
}
}

View File

@@ -0,0 +1,140 @@
// Ebene 1 (öffentlich) — Mitarbeiter-Selbstanmeldung über den
// Sammellink eines Mandanten ("Einladung annehmen", siehe CLAUDE.md,
// Onboarding: "Einladungslink [Sammellink, Selbstanmeldung, Abteilung
// beim ersten Antrag]"). Ein Link gilt für beliebig viele Mitarbeiter
// desselben Mandanten, kein Einmal-Token pro Person — die Abteilung
// wählt der/die Mitarbeiter:in erst beim ersten Antrag (Fragebogen
// Abschnitt A), nicht hier.
package web
import (
"net/http"
"github.com/netcell-it/deklarix/internal/auth"
)
type einladungFormData struct {
Title string
Firmenname string
Error string
}
// handleEinladungForm zeigt das Selbstanmelde-Formular für einen
// gültigen Einladungslink. Ein unbekanntes oder erneuertes (damit
// ungültig gewordenes) Token bekommt dieselbe Fehlermeldung wie ein
// abgelaufener Link — es wird nicht unterschieden, ob das Token nie
// existiert hat oder nur nicht mehr gültig ist.
func (s *Server) handleEinladungForm(w http.ResponseWriter, r *http.Request) {
acc, err := s.store.GetAccountByEinladungToken(r.Context(), r.PathValue("token"))
if err != nil {
s.renderEinladungError(w)
return
}
s.renderEinladungForm(w, acc.Name, "")
}
func (s *Server) renderEinladungForm(w http.ResponseWriter, firmenname, errMsg string) {
data := einladungFormData{Title: "Einladung annehmen", Firmenname: firmenname, Error: errMsg}
if err := s.templates.ExecuteTemplate(w, "einladung", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
func (s *Server) renderEinladungError(w http.ResponseWriter) {
if err := s.templates.ExecuteTemplate(w, "einladung-ungueltig", authPageData{Title: "Einladung"}); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// handleEinladungAnnehmen legt einen neuen Login mit Rolle
// "mitarbeiter" im Mandanten des Einladungslinks an und meldet die
// Person direkt an — wie bei der Firma-Registrierung gibt es keine
// E-Mail-Bestätigung (noch nicht gebaut, siehe CLAUDE.md, Onboarding).
func (s *Server) handleEinladungAnnehmen(w http.ResponseWriter, r *http.Request) {
acc, err := s.store.GetAccountByEinladungToken(r.Context(), r.PathValue("token"))
if err != nil {
s.renderEinladungError(w)
return
}
if err := r.ParseForm(); err != nil {
s.renderEinladungForm(w, acc.Name, "ungültiges Formular")
return
}
email := r.FormValue("email")
password := r.FormValue("password")
if email == "" || password == "" {
s.renderEinladungForm(w, acc.Name, "E-Mail und Passwort sind Pflicht")
return
}
passwordHash, err := auth.HashPassword(password)
if err != nil {
s.renderEinladungForm(w, acc.Name, err.Error())
return
}
user, err := s.store.CreateUser(r.Context(), acc.ID, email, passwordHash, "mitarbeiter")
if err != nil {
s.renderEinladungForm(w, acc.Name, "Konto konnte nicht angelegt werden — E-Mail evtl. schon vergeben")
return
}
if err := s.startSession(w, r, user.ID); err != nil {
s.renderEinladungForm(w, acc.Name, "Sitzung konnte nicht gestartet werden")
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
type einladungVerwaltungData struct {
Title string
Nav navData
Link string
}
// einladungBasisURL leitet Schema+Host aus dem Request ab, damit der
// vollständige Link (nicht nur das Token) angezeigt werden kann — hinter
// einem Reverse-Proxy (siehe CLAUDE.md, Testsystem: nginx+TLS) trägt
// r.TLS bei HTTPS-Terminierung am Proxy nichts, deshalb zusätzlich
// X-Forwarded-Proto berücksichtigen.
func einladungBasisURL(r *http.Request) string {
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
return scheme + "://" + r.Host
}
// handleEinladungVerwaltung zeigt den aktuellen Sammellink des eigenen
// Mandanten zum Kopieren.
func (s *Server) handleEinladungVerwaltung(w http.ResponseWriter, r *http.Request) {
acc, err := s.store.GetAccount(r.Context(), currentUser(r).AccountID)
if err != nil {
http.Error(w, "Account konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := einladungVerwaltungData{
Title: "Einladungslink", Nav: navFor(r),
Link: einladungBasisURL(r) + "/einladung/" + acc.EinladungToken,
}
if err := s.templates.ExecuteTemplate(w, "einladung-verwaltung", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// handleEinladungErneuern ersetzt den Sammellink des eigenen Mandanten
// — der alte Link wird sofort ungültig (z. B. wenn er versehentlich
// außerhalb des Unternehmens geteilt wurde).
func (s *Server) handleEinladungErneuern(w http.ResponseWriter, r *http.Request) {
newToken, err := auth.NewSessionToken()
if err != nil {
http.Error(w, "Neuer Link konnte nicht erzeugt werden: "+err.Error(), http.StatusInternalServerError)
return
}
if err := s.store.RegenerateEinladungToken(r.Context(), currentUser(r).AccountID, newToken); err != nil {
http.Error(w, "Link konnte nicht erneuert werden: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/verwaltung/einladung", http.StatusSeeOther)
}

View File

@@ -0,0 +1,144 @@
package web_test
import (
"context"
"net/http"
"net/url"
"strings"
"testing"
)
func TestEinladungFormZeigtFirmenname(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
acc, err := fs.CreateAccount(context.Background(), "Beispiel GmbH")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
resp := getWithCookie(t, s, nil, "/einladung/"+acc.EinladungToken)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "Beispiel GmbH") {
t.Errorf("expected the Firmenname on the Einladung page, got: %s", resp.Body.String())
}
}
func TestEinladungMitUnbekanntemTokenZeigtFehler(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
resp := getWithCookie(t, s, nil, "/einladung/unbekanntes-token")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "ungültig") {
t.Errorf("expected an invalid-link message, got: %s", resp.Body.String())
}
}
func TestEinladungAnnehmenLegtMitarbeiterAn(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
acc, err := fs.CreateAccount(context.Background(), "Beispiel GmbH")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
resp := postForm(t, s, nil, "/einladung/"+acc.EinladungToken, url.Values{
"email": {"neu@example.com"}, "password": {"ein-langes-passwort"},
})
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
}
if len(resp.Result().Cookies()) == 0 {
t.Fatal("expected a session cookie to be set")
}
user, err := fs.GetUserByEmail(context.Background(), "neu@example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
if user.Role != "mitarbeiter" || user.AccountID != acc.ID {
t.Errorf("User = %+v, want role mitarbeiter in account %s", user, acc.ID)
}
cookie := resp.Result().Cookies()[0]
protected := getWithCookie(t, s, cookie, "/antraege")
if protected.Code != http.StatusOK {
t.Fatalf("expected the new session to work, status = %d", protected.Code)
}
}
func TestEinladungAnnehmenMitUnbekanntemTokenSchlaegtFehl(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
resp := postForm(t, s, nil, "/einladung/unbekanntes-token", url.Values{
"email": {"neu@example.com"}, "password": {"ein-langes-passwort"},
})
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "ungültig") {
t.Errorf("expected an invalid-link message, got: %s", resp.Body.String())
}
if len(fs.users) != 0 {
t.Error("expected no user to be created for an invalid token")
}
}
func TestAdminSiehtEinladungslink(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
resp := getWithCookie(t, s, adminCookie, "/verwaltung/einladung")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "/einladung/") {
t.Errorf("expected the Einladungslink on the page, got: %s", resp.Body.String())
}
}
func TestAdminKannEinladungslinkErneuern(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
adminCookie := seedAccountWithRole(t, fs, "Test-Mandant", "admin@example.com", "admin")
admin, err := fs.GetUserByEmail(context.Background(), "admin@example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
altesToken := fs.accounts[admin.AccountID].EinladungToken
resp := postForm(t, s, adminCookie, "/verwaltung/einladung/erneuern", url.Values{})
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
neuesToken := fs.accounts[admin.AccountID].EinladungToken
if neuesToken == altesToken {
t.Fatal("expected the token to change")
}
oldLinkResp := getWithCookie(t, s, nil, "/einladung/"+altesToken)
if !strings.Contains(oldLinkResp.Body.String(), "ungültig") {
t.Errorf("expected the old link to be invalid, got: %s", oldLinkResp.Body.String())
}
newLinkResp := getWithCookie(t, s, nil, "/einladung/"+neuesToken)
if newLinkResp.Code != http.StatusOK || strings.Contains(newLinkResp.Body.String(), "ungültig") {
t.Errorf("expected the new link to work, status=%d body: %s", newLinkResp.Code, newLinkResp.Body.String())
}
}
func TestMitarbeiterCannotAccessEinladungsverwaltung(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter")
resp := getWithCookie(t, s, cookie, "/verwaltung/einladung")
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for role mitarbeiter", resp.Code)
}
}

View File

@@ -0,0 +1,273 @@
// 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
}
// hatEntscheidungsrecht — "admin" ist bewusst mit eingeschlossen, siehe
// requireFachebene: die Spezifikation will admin+verantwortlicher auf
// derselben Person, ohne dass app_user.role zwei Werte tragen kann.
func hatEntscheidungsrecht(role string) bool {
return role == "verantwortlicher" || role == "admin"
}
// vorschlagFuer leitet aus einer Bewertung ab, welche Entscheidung das
// System vorschlagen würde — nicht gespeichert, nur zur Bestimmung, ob
// eine tatsächliche Entscheidung davon abweicht (dann ist die
// Begründung Pflicht, siehe CLAUDE.md, Grundregel).
func vorschlagFuer(b store.Bewertung) string {
if b.Verboten {
return "abgelehnt"
}
if len(b.ZulaessigeWerkzeuge) > 0 {
return "genehmigt"
}
return "rueckfrage"
}
// handleFallDetail zeigt einen Antrag mit Bewertung für die Fachebene
// — anders als GET /antraege/{id} (nur der eigene Antrag) ist hier
// jeder Antrag des Mandanten sichtbar, unabhängig vom Ersteller.
func (s *Server) handleFallDetail(w http.ResponseWriter, r *http.Request) {
antrag, err := s.store.GetAntrag(r.Context(), r.PathValue("id"))
if err != nil || antrag.AccountID != currentUser(r).AccountID {
http.Error(w, "Antrag nicht gefunden", http.StatusNotFound)
return
}
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 = hatEntscheidungsrecht(currentUser(r).Role) && antrag.Status == "eingereicht" && data.Bewertung != nil
entscheidung, err := s.store.GetLatestEntscheidungForAntrag(r.Context(), antrag.ID)
if err == nil {
v := &entscheidungView{
Entscheidung: entscheidung.Entscheidung, Begruendung: entscheidung.Begruendung,
CreatedAt: entscheidung.CreatedAt.Format("02.01.2006 15:04"),
}
if entscheidung.WerkzeugSnapshot != nil {
v.WerkzeugName = entscheidung.WerkzeugSnapshot.Name
}
if entscheidung.GueltigBis != nil {
v.GueltigBis = entscheidung.GueltigBis.Format("02.01.2006")
}
data.Entscheidung = v
}
if err := s.templates.ExecuteTemplate(w, "fall-detail", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// gueltigkeitFuer liefert das Ablaufdatum einer Genehmigung — 12 Monate,
// bei hochrisiko 6 (siehe CLAUDE.md, Entscheidung/Register/Wiedervorlage).
func gueltigkeitFuer(einstufung string) time.Time {
if einstufung == "hochrisiko" {
return time.Now().AddDate(0, 6, 0)
}
return time.Now().AddDate(1, 0, 0)
}
// handleFallEntscheiden speichert die Entscheidung eines/einer
// Verantwortlichen (oder admin, siehe hatEntscheidungsrecht). Prüfer
// haben dieselbe Sicht, aber kein Entscheidungsrecht — anders als bei
// requireFachebene (404, weil die Seite für sie gar nicht existieren
// soll) ist das hier ein 403: ein Prüfer darf wissen, dass der Fall
// existiert, nur nicht entscheiden.
func (s *Server) handleFallEntscheiden(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
if !hatEntscheidungsrecht(user.Role) {
http.Error(w, "kein Entscheidungsrecht", http.StatusForbidden)
return
}
antrag, err := s.store.GetAntrag(r.Context(), r.PathValue("id"))
if err != nil || antrag.AccountID != user.AccountID {
http.Error(w, "Antrag nicht gefunden", http.StatusNotFound)
return
}
if antrag.Status != "eingereicht" {
http.Error(w, "Antrag ist bereits entschieden", http.StatusBadRequest)
return
}
bewertung, err := s.store.GetLatestBewertungForAntrag(r.Context(), antrag.ID)
if err != nil {
http.Error(w, "Bewertung nicht gefunden", http.StatusNotFound)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
entscheidungTyp := r.FormValue("entscheidung")
begruendung := r.FormValue("begruendung")
werkzeugID := r.FormValue("werkzeug_id")
switch entscheidungTyp {
case "genehmigt", "genehmigt_mit_auflagen", "abgelehnt", "rueckfrage":
default:
http.Error(w, "ungültige Entscheidung", http.StatusBadRequest)
return
}
if vorschlagFuer(bewertung) != entscheidungTyp && begruendung == "" {
http.Error(w, "Begründung erforderlich, wenn die Entscheidung vom Vorschlag abweicht", http.StatusBadRequest)
return
}
istGenehmigung := entscheidungTyp == "genehmigt" || entscheidungTyp == "genehmigt_mit_auflagen"
in := store.EntscheidungInput{
AntragID: antrag.ID, BewertungID: bewertung.ID, EntscheiderUserID: user.ID,
Entscheidung: entscheidungTyp, Begruendung: begruendung,
}
var werkzeugName string
if istGenehmigung {
zulaessig := false
for _, id := range bewertung.ZulaessigeWerkzeuge {
if id == werkzeugID {
zulaessig = true
break
}
}
if werkzeugID == "" || !zulaessig {
http.Error(w, "bei Genehmigung muss ein zulässiges Werkzeug gewählt werden", http.StatusBadRequest)
return
}
werkzeug, err := s.store.GetWerkzeug(r.Context(), werkzeugID)
if err != nil {
http.Error(w, "Werkzeug nicht gefunden", http.StatusNotFound)
return
}
werkzeugName = werkzeug.Name
in.WerkzeugID = &werkzeugID
in.WerkzeugSnapshot = &werkzeug
gueltigBis := gueltigkeitFuer(bewertung.Einstufung)
in.GueltigBis = &gueltigBis
}
entscheidung, err := s.store.CreateEntscheidung(r.Context(), in)
if err != nil {
http.Error(w, "Entscheidung konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
return
}
if err := s.store.SetAntragStatus(r.Context(), antrag.ID, "entschieden"); err != nil {
http.Error(w, "Antrag konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
return
}
if _, err := s.store.CreateAuditEntry(r.Context(), user.ID, "antrag_entschieden", "antrag", antrag.ID, entscheidungTyp); err != nil {
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
return
}
// Jede Genehmigung erzeugt automatisch einen Registereintrag (siehe
// CLAUDE.md) — bei "abgelehnt"/"rueckfrage" gibt es nichts
// Genehmigtes, das im Register stehen müsste.
if istGenehmigung {
abteilungName := ""
if antrag.AbteilungID != nil {
if abt, err := s.store.GetAbteilung(r.Context(), *antrag.AbteilungID); err == nil {
abteilungName = abt.Name
}
}
var auflagen []string
for _, a := range bewertung.Anforderungen {
auflagen = append(auflagen, a.Beschreibung)
}
if _, err := s.store.CreateRegistereintrag(r.Context(), store.RegistereintragInput{
AccountID: user.AccountID, AntragID: antrag.ID, EntscheidungID: entscheidung.ID,
Zweck: antrag.Titel, Abteilung: abteilungName, Werkzeug: werkzeugName,
Datenklasse: bewertung.Datenklasse, Einstufung: bewertung.Einstufung, Auflagen: auflagen,
Verantwortlicher: user.Email, EntschiedenAm: entscheidung.CreatedAt, GueltigBis: entscheidung.GueltigBis,
}); err != nil {
http.Error(w, "Registereintrag konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
return
}
}
http.Redirect(w, r, "/faelle/"+antrag.ID, http.StatusSeeOther)
}

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,254 +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")
consideration := r.FormValue("consideration")
if platform == "" || postType == "" || caption == "" || consideration == "" {
http.Error(w, "Plattform, Beitragstyp, Gegenleistung und Caption sind Pflichtfelder", http.StatusBadRequest)
return
}
ctx := r.Context()
accountID := currentUser(r).AccountID
result, err := s.extractor.Extract(ctx, extract.Input{
Platform: platform,
Jurisdiction: "DE",
Consideration: consideration,
Caption: caption,
})
if err != nil {
http.Error(w, "Extraktion fehlgeschlagen: "+err.Error(), http.StatusBadGateway)
return
}
sub, err := s.store.CreateSubmission(ctx, accountID, platform, postType, caption)
if err != nil {
http.Error(w, "Beitrag konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
return
}
ext, err := s.store.CreateExtraction(ctx, sub.ID, result.RawJSON, s.extractor.ModelVersion(), extract.PromptVersion)
if err != nil {
http.Error(w, "Extraktion konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
return
}
findings, needsClarification := rules.Evaluate(s.ruleSet, result.Facts)
data := resultData{SubmissionID: sub.ID, NeedsClarification: needsClarification}
if !needsClarification {
extractionID := ext.ID
for _, f := range findings {
if _, err := s.store.CreateFinding(ctx, sub.ID, &extractionID, f.RuleID, f.RuleVersion, string(f.Severity), f.Title, f.Fix, f.Sources); err != nil {
http.Error(w, "Finding konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
return
}
data.Findings = append(data.Findings, findingView{
RuleID: f.RuleID, Version: f.RuleVersion, Severity: string(f.Severity),
Title: f.Title, Fix: f.Fix, Sources: f.Sources,
})
}
if err := s.store.SetSubmissionStatus(ctx, sub.ID, "checked"); err != nil {
http.Error(w, "Status konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
return
}
data.CanArchive = true
}
if err := s.templates.ExecuteTemplate(w, "result", data); err != nil {
http.Error(w, "Ergebnis konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type archivedData struct {
SubmissionID string
DossierURL string
TimestampedAt string
}
// handleArchive markiert einen geprüften Beitrag als veröffentlicht und
// archiviert ihn: Hash der Metadaten, RFC-3161-Zeitstempel, PDF-Dossier.
// Setzt eine vorherige Prüfung voraus (Submission + Extraction +
// Findings müssen bereits existieren).
func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
submissionID := r.FormValue("submission_id")
if submissionID == "" {
http.Error(w, "submission_id ist Pflicht", http.StatusBadRequest)
return
}
ctx := r.Context()
sub, err := s.store.GetSubmission(ctx, submissionID)
if err != nil {
http.Error(w, "Beitrag nicht gefunden: "+err.Error(), http.StatusNotFound)
return
}
// Mandantentrennung: ein Beitrag eines anderen Accounts wird wie ein
// nicht existierender behandelt, nicht mit einer 403 bestätigt —
// eine 403 würde einem anderen Mandanten verraten, dass die ID
// existiert.
if sub.AccountID != currentUser(r).AccountID {
http.Error(w, "Beitrag nicht gefunden", http.StatusNotFound)
return
}
ext, err := s.store.GetLatestExtraction(ctx, submissionID)
if err != nil {
http.Error(w, "Extraktion nicht gefunden: "+err.Error(), http.StatusNotFound)
return
}
facts, err := extract.ParsePayload(ext.Payload, sub.Platform, "DE")
if err != nil {
http.Error(w, "gespeicherte Extraktion konnte nicht gelesen werden: "+err.Error(), http.StatusInternalServerError)
return
}
storeFindings, err := s.store.ListCurrentFindings(ctx, submissionID)
if err != nil {
http.Error(w, "Findings konnten nicht gelesen werden: "+err.Error(), http.StatusInternalServerError)
return
}
dossierFindings := make([]rules.Finding, len(storeFindings))
for i, f := range storeFindings {
dossierFindings[i] = rules.Finding{
RuleID: f.RuleID, RuleVersion: f.RuleVersion, Severity: rules.Severity(f.Severity),
Title: f.Title, Fix: f.Fix, Sources: f.Sources,
}
}
metadataHash, err := evidence.HashMetadata(struct {
SubmissionID string
Platform string
PostType string
Caption string
Facts rules.Facts
Findings []rules.Finding
}{sub.ID, sub.Platform, sub.PostType, sub.Caption, facts, dossierFindings})
if err != nil {
http.Error(w, "Metadaten-Hash fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
timestampToken, err := s.timestamper.Timestamp(ctx, metadataHash)
if err != nil {
http.Error(w, "Zeitstempel fehlgeschlagen: "+err.Error(), http.StatusBadGateway)
return
}
if err := os.MkdirAll(s.dossierDir, 0o750); err != nil {
http.Error(w, "Dossier-Verzeichnis konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
return
}
dossierPath := filepath.Join(s.dossierDir, submissionID+".pdf")
dossierFile, err := os.Create(dossierPath)
if err != nil {
http.Error(w, "Dossier-Datei konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
return
}
defer dossierFile.Close()
genErr := dossier.Generate(dossierFile, dossier.Data{
Submission: dossier.Submission{
Platform: sub.Platform, PostType: sub.PostType, Caption: sub.Caption, CreatedAt: sub.CreatedAt,
},
Facts: facts,
Findings: dossierFindings,
MetadataHash: metadataHash,
TimestampToken: timestampToken,
GeneratedAt: time.Now(),
})
if genErr != nil {
http.Error(w, "Dossier konnte nicht erzeugt werden: "+genErr.Error(), http.StatusInternalServerError)
return
}
if _, err := s.store.CreateEvidencePackage(ctx, submissionID, dossierPath, hex.EncodeToString(metadataHash), timestampToken); err != nil {
http.Error(w, "Evidence-Package konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
return
}
if err := s.store.SetSubmissionStatus(ctx, submissionID, "published"); err != nil {
http.Error(w, "Status konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
return
}
timestampedAt, err := evidence.TimestampTime(timestampToken)
if err != nil {
http.Error(w, "Zeitstempel konnte nicht gelesen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := archivedData{
SubmissionID: submissionID,
DossierURL: "/dossier/" + submissionID,
TimestampedAt: timestampedAt.Format("02.01.2006 15:04:05 MST"),
}
if err := s.templates.ExecuteTemplate(w, "archived", data); err != nil {
http.Error(w, "Ergebnis konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// handleDossierDownload liefert das erzeugte PDF-Dossier eines
// archivierten Beitrags aus.
func (s *Server) handleDossierDownload(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
ctx := r.Context()
sub, err := s.store.GetSubmission(ctx, id)
if err != nil || sub.AccountID != currentUser(r).AccountID {
http.Error(w, "Kein Dossier für diesen Beitrag gefunden", http.StatusNotFound)
return
}
pkg, err := s.store.GetLatestEvidencePackage(ctx, id)
if err != nil {
http.Error(w, "Kein Dossier für diesen Beitrag gefunden", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/pdf")
http.ServeFile(w, r, pkg.DossierPath)
}

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,98 @@ func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
}
}
// requireBetreiber schützt den Plattform-Bereich (Ebene 5 — "nur
// Betreiber", technisch getrennt vom Mandantenbereich, siehe CLAUDE.md).
// Ohne Sitzung geht es wie bei requirePage zu /login; mit einer
// Sitzung, aber ohne Betreiber-Rolle, gibt es 404 statt 403 — sonst
// würde eine 403 einem angemeldeten, aber unprivilegierten Nutzer
// verraten, dass unter dieser URL überhaupt etwas existiert.
func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := s.authenticate(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if user.Role != "betreiber" {
http.Error(w, "nicht gefunden", http.StatusNotFound)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
}
}
// requireFachebene schützt die Fachebene (Ebene 3 — Posteingang und
// Entscheiden, Rollen "verantwortlicher" und "pruefer", siehe
// CLAUDE.md). "admin" ist hier bewusst mit zugelassen: die Spezifikation
// will, dass der erste Nutzer einer neuen Firma gleichzeitig admin UND
// verantwortlicher ist ("Admin und KI-Verantwortlicher" — siehe CLAUDE.md,
// Offene Punkte), app_user.role kennt aber nur einen Wert. Statt eines
// Datenmodell-Umbaus (roles-Array oder zwei app_user-Zeilen pro Person)
// bekommt admin hier pragmatisch dieselben Fachebene-Rechte wie
// verantwortlicher (inkl. Entscheidungsrecht, siehe handleFallEntscheiden)
// — ohne das könnte eine frisch registrierte Firma mit nur einem
// admin-Login keinen einzigen eingereichten Antrag sehen oder
// bearbeiten. pruefer bleibt unverändert nur lesend. Wie bei
// requireBetreiber: 404 statt 403 bei falscher Rolle, damit ein
// angemeldeter, aber unprivilegierter Nutzer (z. B. "mitarbeiter")
// nicht erfährt, dass es die Seite überhaupt gibt.
func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := s.authenticate(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if user.Role != "verantwortlicher" && user.Role != "pruefer" && user.Role != "admin" {
http.Error(w, "nicht gefunden", http.StatusNotFound)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
}
}
// requireAdmin schützt die Mandanten-Verwaltung (Ebene 4 — Nutzer/
// Rollen für GENAU EINEN Mandanten, siehe CLAUDE.md). Wie bei
// requireBetreiber/requireFachebene: 404 statt 403 bei falscher Rolle.
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := s.authenticate(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if user.Role != "admin" {
http.Error(w, "nicht gefunden", http.StatusNotFound)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
}
}
// navData steuert die gemeinsame Navigation (layout.html, "nav"-Block).
// Eigenes, kleines Struct statt jeder Seite Zugriff auf den vollen
// currentUser zu geben — die Navigation braucht nur, ob ein
// Plattform-Link gezeigt werden soll.
type navData struct {
IsBetreiber bool
IsFachebene bool
IsAdmin bool
}
// navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage.
// Nur für Seiten hinter requirePage/requireBetreiber aufrufbar (braucht
// currentUser).
func navFor(r *http.Request) navData {
role := currentUser(r).Role
return navData{
IsBetreiber: role == "betreiber",
// admin sieht die Fachebene mit, siehe requireFachebene.
IsFachebene: role == "verantwortlicher" || role == "pruefer" || role == "admin",
IsAdmin: role == "admin",
}
}
// currentUser liest den Nutzer, den requirePage/requireAPI in den
// Kontext gelegt haben. Panics, wenn es aufgerufen wird, ohne dass eine
// dieser Middlewares vorgeschaltet war — das ist ein Programmierfehler,

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,91 +23,142 @@ var templatesFS embed.FS
//go:embed static/*
var staticFS embed.FS
// Extractor ist die Schnittstelle, die der Server für Stufe 1 braucht.
// *extract.Engine erfüllt sie (regelbasiert, kein externer Dienst);
// Tests injizieren einen Fake, um Facts unabhängig von der echten
// Erkennungslogik vorzugeben.
type Extractor interface {
Extract(ctx context.Context, in extract.Input) (extract.Result, error)
ModelVersion() string
}
// Store ist die Schnittstelle, die der Server zur Persistenz braucht —
// bewusst schmal (nur was diese Handler tatsächlich nutzen), nicht der
// komplette *store.Store. *store.Store erfüllt sie; Tests injizieren
// einen Fake statt eine echte Datenbank zu brauchen.
type Store interface {
CreateSubmission(ctx context.Context, accountID, platform, postType, caption string) (store.Submission, error)
GetSubmission(ctx context.Context, id string) (store.Submission, error)
SetSubmissionStatus(ctx context.Context, id, status string) error
CreateExtraction(ctx context.Context, submissionID string, payload []byte, modelVersion, promptVersion string) (store.Extraction, error)
GetLatestExtraction(ctx context.Context, submissionID string) (store.Extraction, error)
CreateFinding(ctx context.Context, submissionID string, extractionID *string, ruleID string, ruleVersion int, severity, title, fix string, sources []string) (store.Finding, error)
ListCurrentFindings(ctx context.Context, submissionID string) ([]store.Finding, error)
CreateEvidencePackage(ctx context.Context, submissionID, dossierPath, sha256Hex string, timestampToken []byte) (store.EvidencePackage, error)
GetLatestEvidencePackage(ctx context.Context, submissionID string) (store.EvidencePackage, error)
ListSubmissionsForAccount(ctx context.Context, accountID string) ([]store.SubmissionSummary, error)
CreateParticipant(ctx context.Context, submissionID, role, name string, vorgegeben, freigegeben bool) (store.Participant, error)
ListParticipants(ctx context.Context, submissionID string) ([]store.Participant, error)
GetParticipant(ctx context.Context, id string) (store.Participant, error)
UpdateParticipant(ctx context.Context, id string, vorgegeben, freigegeben bool) (store.Participant, error)
DeleteParticipant(ctx context.Context, id string) error
CreateAccount(ctx context.Context, name string) (store.Account, error)
GetAccount(ctx context.Context, id string) (store.Account, error)
GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error)
RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error
ListAccounts(ctx context.Context) ([]store.Account, error)
CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error)
GetUserByEmail(ctx context.Context, email string) (store.User, error)
GetUser(ctx context.Context, id string) (store.User, error)
ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error)
SetUserActive(ctx context.Context, id string, active bool) error
CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error)
GetSession(ctx context.Context, token string) (store.Session, error)
DeleteSession(ctx context.Context, token string) error
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error)
ListAbteilungenForAccount(ctx context.Context, accountID string) ([]store.Abteilung, error)
CreateAbteilung(ctx context.Context, accountID, name string) (store.Abteilung, error)
DeleteAbteilung(ctx context.Context, id string) error
CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (store.Antrag, error)
GetAntrag(ctx context.Context, id string) (store.Antrag, error)
UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (store.Antrag, error)
SetAntragStatus(ctx context.Context, id, status string) error
ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]store.Antrag, error)
ListAntraegeForAccount(ctx context.Context, accountID string) ([]store.Antrag, error)
ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]store.Werkzeug, error)
ListZentraleWerkzeuge(ctx context.Context) ([]store.Werkzeug, error)
CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID, grund string) (store.WerkzeugSperre, error)
DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID string) error
ListWerkzeugSperrenForAccount(ctx context.Context, accountID string) ([]store.WerkzeugSperre, error)
GetWerkzeug(ctx context.Context, id string) (store.Werkzeug, error)
CreateWerkzeug(ctx context.Context, in store.WerkzeugInput) (store.Werkzeug, error)
UpdateWerkzeug(ctx context.Context, id string, in store.WerkzeugInput) (store.Werkzeug, error)
DeleteWerkzeug(ctx context.Context, id string) error
CurrentKatalogVersion(ctx context.Context) (string, error)
CreateBewertung(ctx context.Context, in store.BewertungInput) (store.Bewertung, error)
GetLatestBewertungForAntrag(ctx context.Context, antragID string) (store.Bewertung, error)
CreateEntscheidung(ctx context.Context, in store.EntscheidungInput) (store.Entscheidung, error)
GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (store.Entscheidung, error)
ListAktiveGenehmigungenForAccount(ctx context.Context, accountID string) ([]store.Entscheidung, error)
GetAbteilung(ctx context.Context, id string) (store.Abteilung, error)
CreateRegistereintrag(ctx context.Context, in store.RegistereintragInput) (store.Registereintrag, error)
ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]store.Registereintrag, error)
}
// Regelwerk bündelt die drei geladenen Regelwerk-Dateien, die die
// Bewertung eines Antrags braucht (siehe internal/rules). Ein eigener
// Typ statt drei einzelne Server-Felder, damit main.go sie an einer
// Stelle zusammenstellt.
type Regelwerk struct {
Datenklasse rules.DatenklasseRegelwerk
Einstufung rules.EinstufungRegelwerk
Anforderungen rules.AnforderungsRegelwerk
}
// Server bündelt Routing und Abhängigkeiten der Web-Schicht.
type Server struct {
mux *http.ServeMux
extractor Extractor
ruleSet []rules.Rule
store Store
timestamper evidence.Timestamper
dossierDir string
templates *template.Template
mux *http.ServeMux
store Store
regelwerk Regelwerk
templates *template.Template
}
// NewServer erstellt den Server. ruleSet kommt von rules.Load und wird
// einmal beim Start geladen, nicht pro Request. dossierDir ist das
// Verzeichnis, in das erzeugte Nachweis-Dossiers geschrieben werden.
func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper evidence.Timestamper, dossierDir string) (*Server, error) {
// NewServer erstellt den Server. regelwerk kommt von rules.Load* und
// wird einmal beim Start geladen, nicht pro Request.
func NewServer(st Store, regelwerk Regelwerk) (*Server, error) {
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
if err != nil {
return nil, fmt.Errorf("web: templates parsen: %w", err)
}
s := &Server{
extractor: extractor,
ruleSet: ruleSet,
store: st,
timestamper: timestamper,
dossierDir: dossierDir,
templates: tmpl,
store: st,
regelwerk: regelwerk,
templates: tmpl,
}
mux := http.NewServeMux()
mux.HandleFunc("GET /health", s.handleHealth)
mux.HandleFunc("GET /register", s.handleRegisterForm)
mux.HandleFunc("POST /register", s.handleRegister)
mux.HandleFunc("GET /einladung/{token}", s.handleEinladungForm)
mux.HandleFunc("POST /einladung/{token}", s.handleEinladungAnnehmen)
mux.HandleFunc("GET /login", s.handleLoginForm)
mux.HandleFunc("POST /login", s.handleLogin)
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /{$}", s.requirePage(s.handleIndex))
mux.HandleFunc("POST /pruefen", s.requireAPI(s.handleCheck))
mux.HandleFunc("POST /veroeffentlichen", s.requireAPI(s.handleArchive))
mux.HandleFunc("GET /dossier/{id}", s.requireAPI(s.handleDossierDownload))
mux.HandleFunc("GET /beitraege", s.requirePage(s.handleSubmissionList))
mux.HandleFunc("GET /beitraege/{id}", s.requirePage(s.handleSubmissionDetail))
mux.HandleFunc("POST /beitraege/{id}/beteiligte", s.requireAPI(s.handleAddParticipant))
mux.HandleFunc("POST /beitraege/{id}/beteiligte/{pid}/aktualisieren", s.requireAPI(s.handleUpdateParticipant))
mux.HandleFunc("POST /beitraege/{id}/beteiligte/{pid}/loeschen", s.requireAPI(s.handleDeleteParticipant))
mux.HandleFunc("GET /antraege", s.requirePage(s.handleAntragList))
mux.HandleFunc("GET /antraege/neu", s.requirePage(s.handleAntragNewForm))
mux.HandleFunc("POST /antraege", s.requirePage(s.handleAntragCreate))
mux.HandleFunc("GET /antraege/{id}", s.requirePage(s.handleAntragDetail))
mux.HandleFunc("GET /faelle", s.requireFachebene(s.handleFaelleListe))
mux.HandleFunc("GET /faelle/{id}", s.requireFachebene(s.handleFallDetail))
mux.HandleFunc("POST /faelle/{id}/entscheiden", s.requireFachebene(s.handleFallEntscheiden))
mux.HandleFunc("GET /registereintraege", s.requireFachebene(s.handleRegisterListe))
mux.HandleFunc("GET /registereintraege/export.csv", s.requireFachebene(s.handleRegisterExportCSV))
mux.HandleFunc("GET /registereintraege/export.pdf", s.requireFachebene(s.handleRegisterExportPDF))
mux.HandleFunc("GET /wiedervorlage", s.requireFachebene(s.handleWiedervorlageListe))
mux.HandleFunc("GET /verwaltung/nutzer", s.requireAdmin(s.handleNutzerListe))
mux.HandleFunc("GET /verwaltung/nutzer/neu", s.requireAdmin(s.handleNutzerNeuForm))
mux.HandleFunc("POST /verwaltung/nutzer", s.requireAdmin(s.handleNutzerCreate))
mux.HandleFunc("POST /verwaltung/nutzer/{id}/deaktivieren", s.requireAdmin(s.handleNutzerDeaktivieren))
mux.HandleFunc("POST /verwaltung/nutzer/{id}/aktivieren", s.requireAdmin(s.handleNutzerAktivieren))
mux.HandleFunc("GET /verwaltung/nutzer/import", s.requireAdmin(s.handleNutzerCSVImportForm))
mux.HandleFunc("POST /verwaltung/nutzer/import", s.requireAdmin(s.handleNutzerCSVImportUpload))
mux.HandleFunc("GET /verwaltung/einladung", s.requireAdmin(s.handleEinladungVerwaltung))
mux.HandleFunc("POST /verwaltung/einladung/erneuern", s.requireAdmin(s.handleEinladungErneuern))
mux.HandleFunc("GET /verwaltung/abteilungen", s.requireAdmin(s.handleAbteilungListe))
mux.HandleFunc("POST /verwaltung/abteilungen", s.requireAdmin(s.handleAbteilungCreate))
mux.HandleFunc("POST /verwaltung/abteilungen/{id}/loeschen", s.requireAdmin(s.handleAbteilungDelete))
mux.HandleFunc("GET /verwaltung/werkzeuge", s.requireAdmin(s.handleMandantWerkzeugListe))
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/sperren", s.requireAdmin(s.handleMandantWerkzeugSperren))
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/entsperren", s.requireAdmin(s.handleMandantWerkzeugEntsperren))
mux.HandleFunc("GET /verwaltung/werkzeuge/neu", s.requireAdmin(s.handleMandantWerkzeugNeuForm))
mux.HandleFunc("POST /verwaltung/werkzeuge", s.requireAdmin(s.handleMandantWerkzeugCreate))
mux.HandleFunc("GET /verwaltung/werkzeuge/{id}", s.requireAdmin(s.handleMandantWerkzeugEditForm))
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}", s.requireAdmin(s.handleMandantWerkzeugUpdate))
mux.HandleFunc("POST /verwaltung/werkzeuge/{id}/loeschen", s.requireAdmin(s.handleMandantWerkzeugDelete))
mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard))
mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList))
mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail))
mux.HandleFunc("GET /betreiber/audit-log", s.requireBetreiber(s.handleBetreiberAuditLog))
mux.HandleFunc("GET /betreiber/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugListe))
mux.HandleFunc("GET /betreiber/werkzeuge/neu", s.requireBetreiber(s.handleBetreiberWerkzeugNeuForm))
mux.HandleFunc("POST /betreiber/werkzeuge", s.requireBetreiber(s.handleBetreiberWerkzeugCreate))
mux.HandleFunc("GET /betreiber/werkzeuge/{id}", s.requireBetreiber(s.handleBetreiberWerkzeugEditForm))
mux.HandleFunc("POST /betreiber/werkzeuge/{id}", s.requireBetreiber(s.handleBetreiberWerkzeugUpdate))
mux.HandleFunc("POST /betreiber/werkzeuge/{id}/loeschen", s.requireBetreiber(s.handleBetreiberWerkzeugDelete))
mux.Handle("GET /static/", http.FileServerFS(staticFS))
s.mux = mux

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,10 @@
/* 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), aber ohne dessen
Ant-Design-/Desktop-Sidebar-Layout, das für Deklarix nicht passt.
Mobile-first: Basis-Stile gelten fürs Telefon, @media (min-width)
erweitert für größere Bildschirme. */
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');
@@ -14,6 +15,7 @@
--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;
@@ -27,6 +29,12 @@
--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);
}
* {
@@ -74,23 +82,109 @@ a {
margin: 0 auto;
}
nav {
/* ── 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: flex-end;
gap: 16px;
justify-content: space-between;
padding: 12px 16px;
background: #fff;
border-bottom: 1px solid var(--color-border);
}
nav a {
color: var(--color-muted);
.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;
}
nav a:hover {
color: var(--color-text);
.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
@@ -260,7 +354,8 @@ nav button {
color: var(--color-niedrig);
}
.status-checked {
.status-checked,
.status-mittel {
background: var(--color-mittel-bg);
color: var(--color-mittel);
}
@@ -274,7 +369,12 @@ nav button {
gap: 8px;
}
.beitraege-liste li a {
.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;
@@ -287,6 +387,15 @@ nav button {
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;
@@ -352,7 +461,116 @@ button.entfernen {
font-size: 0.875rem;
}
/* Ab hier mehr Platz (Tablet/Desktop) — der Container bekommt spürbaren
.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 {
@@ -363,3 +581,74 @@ button.entfernen {
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%;
}
}

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