Compare commits

...

35 Commits

Author SHA1 Message Date
noroot
d1e80dd19f feat: Posteingang und Entscheiden für die Fachebene (Schritt 5 der Baureihenfolge)
Ebene 3 (Rollen verantwortlicher/pruefer) bekommt GET /faelle
(Posteingang aller offenen Anträge des Mandanten) und GET/POST
/faelle/{id} zum Entscheiden. Eine Entscheidung friert Regelwerk-,
Katalog- und den vollständigen Werkzeugdatensatz ein, verlangt eine
Begründung bei Abweichung vom abgeleiteten Vorschlag, erlaubt bei
Genehmigung nur ein durch die Bewertung zulässiges Werkzeug und
protokolliert die Entscheidung im Audit-Log. entscheidung ist
append-only wie bewertung.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:15:39 +02:00
noroot
8f638df387 docs: document app.deklarix.de and the reverse proxy setup
app.deklarix.de is the canonical app URL (DNS now points at the test
server, 89.163.205.4); no separate app.deklarix.com since the root
.com already redirects to .de. nginx + certbot now terminate TLS there
and proxy to the local deklarix service, verified end-to-end over real
HTTPS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 16:50:52 +02:00
noroot
5156fe62da feat: wire auth into the web layer (Schritt 2, part 2/2)
Registration creates a new account plus its first user; login
authenticates an existing one; both set a deklarix_session cookie
(HttpOnly, SameSite=Strict, Secure only when the request itself came
over TLS — hardcoding Secure=true would break local http://localhost
development, since browsers won't store a Secure cookie over plaintext).

requirePage protects full-page GETs (redirects to /login); requireAPI
protects the htmx/download endpoints (401, since those are only ever
called from an already-authenticated page — an unauthenticated hit
there is the exception, e.g. a session expiring mid-use).

handleCheck now creates submissions under the current account.
handleArchive and handleDossierDownload compare the submission's
account against the caller's and return 404 on mismatch — not 403,
which would confirm the ID exists to a different tenant. Login failure
uses the same message for "no such email" and "wrong password" to avoid
account enumeration.

Restructured templates along the way: layout.html now only holds
reusable fragments ("head", "nav"); each full page (index/login/register)
is its own top-level named template. The previous layout+content nesting
would have broken the moment a second page defined "content" — Go's
html/template keys blocks by name across the whole parsed set, not per
file, so two pages both defining "content" would silently overwrite each
other.

Verified against a real running instance (not just Go's test recorder):
started the compiled binary against a fresh Postgres and drove the whole
flow with curl — anonymous redirect, registration setting a real cookie,
authenticated page load, logout clearing both the cookie and the
server-side session row, and being locked out again afterward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 15:56:11 +02:00
noroot
2a16dc2200 feat: add auth and multi-tenancy (Schritt 2, part 1/2)
internal/auth is pure logic (bcrypt hashing, session token generation)
with no DB access — persistence for account/app_user/session lives in
internal/store like everything else, via migration 0003.

account is the tenant (Mandant); app_user is a login inside one account;
session is a real server-side row (not a signed stateless token) so
logout can actually end a session rather than the client just
forgetting a JWT. submission.account_id is NOT NULL — added directly
rather than the nullable-then-backfill dance, since no submission rows
exist anywhere yet (verified empty on the test server before writing
the migration). Added as migration 0003 (new file), not folded into an
earlier one, since 0001/0002 are already applied on the test server.

store.ErrNotFound lets callers distinguish "wrong email" / "unknown
session" from a genuine DB error — matters for login, where those two
cases should both fail closed but for different reasons.

Not yet wired into internal/web — that's the next commit. All of this
is tested against real Postgres (14 store tests green) but isn't
reachable from any HTTP handler yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 15:48:40 +02:00
noroot
f5ad08cebd feat: wire persistence into the web layer (check → archive → dossier)
This is the piece that turns a Pre-Publish-Prüfung into an actual
archived, provable record instead of a one-off form response.

extract.Client.Extract now returns Result{Facts, RawJSON} instead of
just Facts — RawJSON is the model's exact, unmodified JSON, which is
what belongs in extraction.payload (the audit trail), not a re-encoded
view through our own Facts struct. extract.ParsePayload reconstructs
Facts from a stored payload later, reusing the same parsing/validation
path Extract uses (including the enum guard), so a previously-saved
extraction can be read back exactly as it would have been the first
time.

internal/dossier.BuildContent no longer requires AssetHash: most checks
right now are caption-only (no image/video upload wired yet), and
inventing a placeholder hash for a nonexistent asset would itself be an
integrity problem in an evidence tool. Content shows "kein Asset
hinterlegt" instead.

internal/web gains a narrow Store interface (mirroring the Extractor
pattern — only the methods these handlers use, not the full
*store.Store) so its test suite stays network/DB-free via an in-memory
fake:

- POST /pruefen persists submission + extraction + findings and marks
  the submission "checked". A needsClarification result persists the
  extraction (there's something worth keeping) but no findings and no
  status change, and the template omits the archive option entirely.
- POST /veroeffentlichen re-derives Facts from the stored payload, hashes
  the canonical submission+facts+findings metadata, gets an RFC-3161
  timestamp, generates the PDF dossier to disk, and persists the
  evidence_package row before marking the submission "published".
- GET /dossier/{id} serves the generated PDF.

Tested end-to-end offline: a fake Timestamper builds a real, structurally
valid self-signed RFC-3161 response so the full check→archive→download
flow runs against an in-memory store, verifying the downloaded bytes are
an actual PDF and the dossier file lands on disk — without hitting a
real database, TSA, or the Claude API.

cmd/deklarix/main.go now wires store.Store, evidence.NewHTTPTimestamper
(TSA_URL, default FreeTSA), and DOSSIER_DIR (default "dossiers") into
web.NewServer alongside the extractor and rule set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 14:39:38 +02:00
noroot
72005fc326 feat: store finding as structured title/fix/sources, not flat message
Migration 0002 replaces finding.message with title/fix/sources (a
Postgres text[]). A finding needs to render into the dossier the way it
looked at the moment it was raised — referencing the current rules/*.yaml
by rule_id+version isn't safe once that file is edited for a later
version, since old wording isn't kept around as a separate live file.
Added as a new migration rather than editing 0001, since that's already
applied on the test server.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 14:39:14 +02:00
noroot
e0218337e7 feat: add store CRUD methods for submission/extraction/finding/evidence
Create/Get for submission (including the one legitimate status
transition — submission is not append-only, unlike the other three),
Create/GetLatest for extraction and evidence_package, Create for
finding plus ListCurrentFindings which applies the anti-join documented
in the migration (a finding referenced by another row's `supersedes`
is not "current").

Tested against real Postgres, including that the append-only trigger
still rejects UPDATE on evidence_package via this new code path, and
that ListCurrentFindings actually hides a finding once a correction
supersedes it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 14:30:08 +02:00
noroot
31caa8f66a feat: add web layer (internal/web) and wire it into main.go
Routing, html/template layout/content pattern, and the Pre-Publish
check flow: POST /pruefen runs extraction (Stufe 1) then rules.Evaluate
(Stufe 2) and renders the result as an htmx fragment. Nothing is
persisted yet — that's the next step (wiring internal/store in).

The needsClarification case is rendered explicitly as a request for
more information rather than "no findings", matching the core
principle. Every result carries the legal-advice disclaimer required by
CLAUDE.md's guardrails.

Server depends on a narrow Extractor interface rather than *extract.
Client directly, so tests inject a fake instead of calling the real API
— internal/web's test suite never touches the network. htmx is vendored
locally (internal/web/static/htmx.min.js) instead of loaded from a CDN,
keeping the UI usable without runtime internet access.

cmd/deklarix/main.go now wires all of this together: reads
ANTHROPIC_API_KEY (required) and RULES_DIR (default "rules"), builds
the extract client and loads the rule set, and serves web.Server instead
of the old inline health-only mux.

This exposed the same crash-loop risk fixed earlier for DATABASE_URL:
postinst's start guard only checked DATABASE_URL, so a fresh install
would now crash-loop on a missing ANTHROPIC_API_KEY instead. The guard
checks both. scripts/build.sh also now ships rules/*.yaml into the .deb
under /usr/share/deklarix/rules (not a conffile — rules are updated via
the release pipeline, never hand-edited on a server), and
deklarix.env.example points RULES_DIR there by default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 14:23:45 +02:00
noroot
db373e51ce feat: add PDF dossier generation (internal/dossier)
Content assembly (BuildContent) is separate from PDF drawing (Render),
so the actual business logic — what goes into the evidence dossier, in
what form, with which mandatory fields — is unit-testable without
parsing PDF bytes. BuildContent refuses to produce a dossier missing
its evidentiary fields (timestamp token, asset/metadata hash, platform)
rather than emitting one with silently empty proof sections. Every
dossier carries the "this is not legal advice" disclaimer required by
CLAUDE.md's guardrails.

Uses github.com/go-pdf/fpdf (actively maintained fork of jung-kurt/
gofpdf, no dependencies beyond the Go stdlib) for rendering. Its core
fonts use cp1252 internally, so a small cp1252.map (copied from the
fpdf module, embedded via go:embed) drives UnicodeTranslator — German
umlauts render correctly without needing an external font file at
runtime, keeping Deklarix a single binary. Verified visually with
pdftotext/pdfinfo against a generated sample.

Tests build a real, structurally valid RFC-3161 token offline (a
throwaway self-signed cert + timestamp.Timestamp.CreateResponse), so
BuildContent/Render/Generate are fully tested without hitting a real
TSA — unlike the network-gated integration test in internal/evidence.

Also adds evidence.TimestampTime(), extracted from the parsing logic
already used by the TSA client, since the dossier needs to show the
timestamped time to a human reader.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 14:18:01 +02:00
noroot
917b51299b feat: add RFC-3161 timestamping (internal/evidence)
HTTPTimestamper builds a Request directly from a precomputed SHA-256
digest (Request{HashAlgorithm, HashedMessage, Nonce}.Marshal(), not
CreateRequest(io.Reader) which would hash the input itself), POSTs it to
a TSA, and validates that the parsed response's hash algorithm, hashed
message and nonce actually match what was sent before accepting the
token — a response that doesn't match the request isn't a valid
timestamp for that hash, regardless of whether it parses.

Uses github.com/digitorus/timestamp for the RFC-3161/ASN.1 encoding
rather than hand-rolling it.

TSA choice (open point in CLAUDE.md): FreeTSA.org for now — free,
RFC-3161-compliant, verified end-to-end with a live smoke test, but
not eIDAS-qualified. Documented as needing an upgrade to a qualified
provider (D-Trust, Bundesdruckerei, ...) before real customer use, same
treatment as the open legal questions in rules/OPEN.md.

Tests cover request/response validation without network (hash size
guard, a fake server that parses and checks the incoming request,
non-200 and malformed-response handling) plus a real integration test
against FreeTSA gated behind DEKLARIX_TSA_INTEGRATION, mirroring the
DATABASE_URL-gated pattern in internal/store.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 14:07:53 +02:00
noroot
d54636f57a feat: add evidence hashing (internal/evidence)
SHA-256 over raw asset bytes and over the canonical JSON form of
metadata (encoding/json already sorts map keys and preserves struct
field order deterministically, so no separate canonicalization library
is needed for our own fixed types). A Timestamper interface stands in
for the RFC-3161 timestamp step — no concrete implementation yet, since
which TSA to use is an open decision (CLAUDE.md "Offene Punkte") that
directly affects the archive's evidentiary weight, not a purely
technical choice to make silently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:54:29 +02:00
noroot
15bf277bee feat: add jurisdiction field to rules, facts and extraction
Rule now carries a required Jurisdiction (land) field, and Facts a
matching Jurisdiction supplied by the caller (like Platform — no
legal jurisdiction can be read off a caption or image, so the model
never guesses it). Evaluate() only lets a rule fire when its
jurisdiction matches the facts' jurisdiction.

This is the structural half of "deutsche Rechtslage zuerst, Struktur
für Österreich und Schweiz vorgesehen": a future AT/CH rule set can be
added as plain new YAML files without touching existing DE rules, but
no AT/CH content is added now — that needs its own legal research
first, same as WK-001/WK-004 needed for Germany.

WK-001 and WK-004 are tagged land: DE, all golden fixtures carry
jurisdiction: DE, and extract.Input passes Jurisdiction through
unchanged into the returned Facts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:47:08 +02:00
noroot
da91c3e2c1 feat: add Claude API extraction (internal/extract)
Stufe 1 from the core principle: Client calls the Claude Messages API
directly over net/http (no SDK dependency, stays consistent with
"Go-Standard-Library wo möglich") and forces tool-use with a strict JSON
schema instead of parsing free text. Extract() returns rules.Facts
directly rather than an intermediate DTO, since producing exactly that
is the point of this stage. Platform is supplied by the caller, never
guessed by the model.

Every failure mode returns an error instead of a zero-value Facts:
network errors, non-200 API responses, a missing tool_use block, and —
critically — a gegenleistung value outside the four allowed enum
values, which would otherwise get silently coerced into a wrong fact.
Tested entirely against an httptest fake server, no real API calls.

Building this surfaced a real gap in internal/rules: Evaluate() treated
Consideration=="unklar" the same as any other value, i.e. it just
produced an empty finding list — indistinguishable from "everything's
fine". That contradicts the core principle (uncertain extraction should
trigger a user clarification, never a judgment). Evaluate() now returns
(findings, needsClarification), with a golden case covering it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:43:01 +02:00
noroot
bec34b5988 feat: add rules engine (internal/rules) with first two disclosure rules
internal/rules implements Stufe 2 from the core principle: the LLM
extracts facts, this deterministic engine judges them against versioned
YAML rules. Facts/Condition/Rule/Finding types, a loader that refuses to
load on a missing id/version or a duplicate rule id rather than silently
skipping a bad file, and Evaluate() matching facts against rules.

Two real rules grounded in verified research (see rules/OPEN.md for the
open questions that surfaced along the way):
- WK-001: no disclosure at all despite consideration (§ 5a Abs. 4 UWG,
  § 22 Abs. 1 MStV)
- WK-004: disclosure present but hidden behind a "mehr anzeigen" cut
  (§ 5a Abs. 4 UWG, Leitfaden der Medienanstalten, LG Köln 12.05.2026)

The two are deliberately disjoint (WK-004 requires disclosure_present=
true) so a post with no disclosure at all doesn't double-fire both
rules. Golden suite in testdata/golden/ covers both rules plus two clean
cases; it's this suite, not the UI, that's the actual asset per
CLAUDE.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:35:07 +02:00
noroot
8b08b39725 feat: apt-based deployment via Gitea Debian registry
Deklarix now ships as a .deb package instead of a raw binary, matching
the enconf-webpanel infrastructure standard: scripts/build.sh assembles
a real .deb (systemd unit, env template, postinst/prerm), scripts/release.sh
uploads it to Gitea's built-in Debian package registry after a green
test run. Target servers add one apt source and get updates via
`apt upgrade` from then on.

postinst only starts the service once DATABASE_URL is actually set in
/etc/deklarix/deklarix.env — the shipped template ships it commented out
on purpose, since an uncommented but unfilled placeholder URL is
syntactically indistinguishable from a real one and caused exactly that
crash-loop during verification.

Verified end-to-end against the real test server and Gitea registry:
upload -> apt-get update -> apt-cache policy -> apt-get install -> a
service that stays down until configured, then runs migrations and
serves /health once a real database is set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:34:58 +02:00
noroot
e929e5bf26 docs: rewrite CLAUDE.md for full product spec and Postgres stack
Replaces the generic React/Vite/SQLite scaffold description with the
actual Deklarix product spec: what it does, the two-stage extract/rules
principle, the Postgres-based stack and cmd/deklarix structure, data
model, evidence chain, legal guardrails (UWG, Versicherungsaufsicht,
BRAO), QA via the golden-test suite, and the DATABASE_URL/test-system
split for scripts/test.sh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 00:51:48 +02:00
noroot
667343fa81 fix: require DATABASE_URL in test.sh instead of provisioning Postgres
Spinning up a throwaway Postgres via Docker in test.sh forced Docker
onto every local dev machine — that's the job of the dedicated test
system, not local iteration. test.sh now fails loudly if DATABASE_URL
is unset instead, so the append-only guarantee still can't be silently
skipped on the release path, without assuming Docker locally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 00:51:31 +02:00
noroot
e9e386df85 feat: add Postgres store with append-only schema and migrations
internal/store connects via pgx and runs golang-migrate migrations
embedded in the binary (go:embed), so Deklarix stays a single binary
despite the move to Postgres. Schema covers the five MVP tables
(submission, asset, extraction, finding, evidence_package, participant).

extraction, finding and evidence_package are append-only by design: a
Postgres trigger rejects UPDATE/DELETE outright, since a corrigible
evidence archive isn't an evidence archive. Corrections to a finding are
new rows whose supersedes column points at the row they replace (set at
INSERT time on the new row, since the trigger blocks UPDATE on the old
one) — "currently valid" findings are the ones no other row supersedes.

scripts/test.sh now spins up a disposable Postgres container so the
store's integration tests (including the append-only guarantee) actually
run on every test.sh/release.sh invocation instead of silently skipping
for lack of DATABASE_URL.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 00:50:30 +02:00
noroot
bfe52c73eb refactor: rename cmd/server to cmd/deklarix
Matches the project structure and binary name defined in the Deklarix
product spec.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 00:50:22 +02:00
92 changed files with 7981 additions and 113 deletions

1
.gitignore vendored
View File

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

536
CLAUDE.md
View File

@@ -1,18 +1,274 @@
# Deklarix
> Projekt für deklarix.de und 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.
---
## Stack
## Produktwechsel (2026-08-28)
| Backend | Frontend (geplant) |
|---------|---------------------|
| Go 1.26 | React 19, TypeScript 5.9 |
| net/http (Standard-Library-first) | Vite 8, Tailwind CSS |
| SQLite (geplant) | enterprise.css Design-System (enconf-Pattern) |
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.
**Pfad:** `/var/www/deklarix` | **Git:** `https://git.netcell-it.de/projekte/deklarix` | **Branch:** `main`
---
## Grundregel
**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.
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.
---
## Fünf Ebenen, fünf Rollen
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.
| 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 |
**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ß.
Rechte werden **als Prüfung an jeder Aktion** durchgesetzt (Middleware
je Handler), nicht als grob unterschiedene Seitenbereiche.
**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 erledigt, Schritt 6-7 geplant)
**Schritt 5 (Entscheidung, Snapshot, Audit-Log) ist umgesetzt.** Ebene 3
(Fachebene) hat einen Posteingang (`GET /faelle`, alle offenen —
Status "eingereicht" — Anträge des Mandanten, nicht nur die eigenen)
und eine Entscheiden-Seite (`GET /faelle/{id}`, `POST
/faelle/{id}/entscheiden`, siehe `internal/web/fachebene_handlers.go`).
Rollen `verantwortlicher` und `pruefer` sehen dieselbe Sicht
(`requireFachebene`), nur `verantwortlicher` hat Entscheidungsrecht —
ein `pruefer`-POST auf `/entscheiden` bekommt 403 (er darf wissen, dass
der Fall existiert, nur nicht entscheiden; anders als bei
`requireFachebene`/`requireBetreiber`, die bei falscher Rolle 404
liefern, um die Existenz der Seite selbst zu verbergen).
Der/die Verantwortliche wählt: genehmigt / genehmigt mit Auflagen /
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`). **Noch nicht gebaut (Schritt 7):**
die Benachrichtigung, wenn sich im Katalog eine Eigenschaft ändert, auf
der eine aktive Genehmigung beruht (Wiedervorlage).
**Noch nicht gebaut (Schritt 6):** ein `registereintrag` wird bislang
NICHT automatisch aus einer Genehmigung erzeugt, und es gibt keinen
PDF-/CSV-Export. Geplant: Zweck, Abteilung, Werkzeug, Datenklasse,
Einstufung, Auflagen, Verantwortliche/r, Datum, Gültigkeit.
---
## Onboarding
**Firma (Ebene 1, öffentlich, `POST /register`):** Registrierungsformular
mit Firmendaten → Testzugang sofort aktiv → Freischaltung des
Bezahlbetriebs nach Prüfung durch den Betreiber (letzteres noch nicht
gebaut). Beim Anlegen entstehen automatisch: Account, erster Nutzer mit
Rolle `admin` (siehe Rollentabelle — vereinfacht v1: nicht zusätzlich
separat als `verantwortlicher` angelegt, siehe Offene Punkte),
leere Abteilungsliste, Werkzeugkatalog aus der Plattform vorbelegt
(noch nicht automatisiert — der zentrale Katalog ist aktuell leer, bis
Ebene 5/Betreiber-Pflege gebaut ist).
**Mitarbeiter, geplante Reihenfolge:** (1) Einladungslink
(Sammellink, Selbstanmeldung, Abteilung beim ersten Antrag), (2) CSV-
Import, (3) SSO (Entra ID/Google Workspace), (4) Antrag ganz ohne
vorheriges Konto über Mandantenlink + E-Mail-Bestätigungscode (Konto
entsteht nebenbei). Keins davon ist gebaut — aktuell nur Firma-
Registrierung.
**Abrechnung:** nach vom Kunden angegebener Mitarbeiterzahl, **nicht**
nach angelegten Nutzern — sonst entsteht der Anreiz, Mitarbeitende
nicht anzulegen. Noch nicht gebaut (kein Abo-System).
---
@@ -21,21 +277,23 @@
```
/var/www/deklarix/
├── cmd/
│ └── server/
│ └── main.go # Entry Point
│ └── deklarix/
│ └── main.go # Entry Point: Migration, Regelwerk laden, HTTP-Server
├── internal/
│ ├── config/ # Konfiguration (env-basiert)
│ ├── handler/ # HTTP Handler
── middleware/ # Auth, Logging, CORS
│ ├── auth/ # Passwort-Hashing, Session-Token (produktunabhängig)
│ ├── rules/ # Lädt/validiert Datenklasse-/Einstufungs-/Anforderungs-YAML
── store/ # Postgres, Migrationen
│ └── web/ # Handler, Templates
├── rules/ # YAML-Regelwerk + OPEN.md (offene fachliche Fragen)
├── design/
│ └── enterprise.css # Gemeinsames Design-System (enconf-Basis)
│ └── enterprise.css # Design-System (enconf-Basis, ungenutzt seit Pivot)
├── packaging/
│ └── DEBIAN/
│ └── control.tmpl # .deb Package-Control-Template
│ └── control.tmpl # .deb Package-Control-Template
├── scripts/
│ ├── build.sh # Cross-Compile amd64 + arm64
│ ├── test.sh # Tests + vet + build-check
│ └── release.sh # Vollständiger Release-Prozess
│ ├── build.sh # Cross-Compile amd64 + arm64
│ ├── test.sh # Tests + vet + build-check
│ └── release.sh # Vollständiger Release-Prozess
├── go.mod
├── go.sum
└── CLAUDE.md
@@ -43,13 +301,99 @@
---
## Datenmodell
- `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`**noch nicht gebaut**, geplant für Schritt 6.
**Append-only:** kein UPDATE auf `audit_log` (Trigger `forbid_update_delete`,
wiederverwendet aus dem alten Produkt), ebenso `bewertung` und
`entscheidung`. `registereintrag` wird bei seiner Einführung ebenfalls
append-only.
---
## Baureihenfolge
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
7. Wiedervorlage und Katalog-Benachrichtigung
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.
---
## Go Commands
```bash
export PATH=$PATH:/usr/local/go/bin # Immer setzen!
# Entwickeln
go run ./cmd/server/
go run ./cmd/deklarix/
# Tests
./scripts/test.sh
@@ -78,28 +422,56 @@ go vet ./...
# 1. Alle Änderungen committen
git add -p && git commit -m "feat: ..."
# 2. Release-Skript (macht Tests → Build → Tag → Push)
# 2. Release-Skript (macht Tests → .deb-Build → Gitea-Upload → Tag → Push)
./scripts/release.sh 1.2.0
# Danach liegt in dist/:
# deklarix_1.2.0_amd64
# deklarix_1.2.0_arm64
# deklarix_1.2.0_amd64.deb
# deklarix_1.2.0_arm64.deb
```
### Was das Release-Skript tut
1. Prüft: sauberer Git-Status (keine uncommitted changes)
2. Führt `./scripts/test.sh` aus (vet + race tests + build-check)
3. Kompiliert für `linux/amd64` und `linux/arm64`
4. Setzt Git-Tag `v<version>` mit Annotierung
5. Pusht `main` + Tag nach `origin`
2. Führt `./scripts/test.sh` aus (vet + race tests + build-check, braucht `DATABASE_URL`)
3. Baut `.deb`-Pakete für `linux/amd64` und `linux/arm64` (`scripts/build.sh`)
4. Lädt die Pakete in Giteas Debian-Paketregistrierung hoch (Kanal `testing`,
Suites `bookworm` + `trixie`) — siehe „Deployment“ unten
5. Setzt Git-Tag `v<version>` mit Annotierung
6. Pusht `main` + Tag nach `origin`
### Deployment (apt, Infrastruktur-Standard wie bei enconf)
Updates laufen ausschließlich über ein apt-Paket, nie über manuelles
Kopieren eines Binaries. `.deb`-Pakete landen in Giteas eingebauter
Debian-Paketregistrierung unter der Gitea-Org `projekte` (gleicher
Mechanismus wie beim Schwesterprojekt enconf-webpanel). Zielserver
richten sich einmalig ein:
```bash
sudo mkdir -p /etc/apt/keyrings
sudo curl -fsSL https://git.netcell-it.de/api/packages/projekte/debian/repository.key \
-o /etc/apt/keyrings/deklarix-gitea.asc
echo "deb [signed-by=/etc/apt/keyrings/deklarix-gitea.asc] https://git.netcell-it.de/api/packages/projekte/debian $(lsb_release -cs) testing" \
| sudo tee /etc/apt/sources.list.d/deklarix.list
sudo apt-get update && sudo apt-get install deklarix
```
Danach: `apt upgrade` für Updates, `systemctl {start,stop,status} deklarix`
für den Dienst. Konfiguration liegt in `/etc/deklarix/deklarix.env`
(aus `deklarix.env.example` beim Erstinstall kopiert, `DATABASE_URL` ist
dort absichtlich auskommentiert — der Dienst startet erst, wenn sie
gesetzt ist; `postinst` startet ihn sonst bewusst nicht, um keine
Crash-Restart-Schleife gegen einen Platzhalter-Host zu erzeugen).
Aktuell gibt es nur den Kanal `testing` (kein `stable` — es gibt noch
keine Kunden, für die eine Trennung nötig wäre).
---
## Testing-Pattern
## Testing-Pattern & Qualitätssicherung
```go
// Datei: internal/handler/health_test.go
package handler_test
// Datei: internal/web/health_test.go
package web_test
import (
"net/http"
@@ -117,39 +489,38 @@ func TestHealth(t *testing.T) {
}
```
- Tests liegen neben dem Code: `handler/foo_test.go`
- Tests liegen neben dem Code: `paket/foo_test.go`
- Package: `package foo_test` (Black-Box-Test) oder `package foo` (White-Box)
- Race-Detector immer an: `go test -race ./...`
- Tabellenbasierte Tests für mehrere Inputs
---
## Design-System
Das Frontend folgt dem **Enterprise Light Theme** aus enconf (`design/enterprise.css`).
- **Primärfarbe:** `#1677ff` (Blau)
- **Sidebar:** Dunkel (`#0B1426``#101D33`) mit weißen Icons
- **Body:** `#F8FAFC` Hintergrund, `#334155` Text
- **Font:** Inter (von `/fonts/inter.css` oder Google Fonts)
- **Radius:** 6px / 8px / 10px
Für neue Frontend-Projekte unter `/var/www/deklarix`:
```bash
# Frontend-Scaffold (wenn benötigt)
npm create vite@latest frontend -- --template react-ts
cd frontend && npm install
# enterprise.css aus design/ einbinden
```
**DATABASE_URL und Testsystem.** `internal/store` hat Integrationstests
gegen echtes Postgres (u. a. die Append-only-Garantie). Ohne
`DATABASE_URL` überspringt `go test` diese Tests einfach — praktisch für
schnelle lokale Iteration ohne lokales Postgres. `./scripts/test.sh`
(und damit `release.sh`) ist strenger: es bricht ohne `DATABASE_URL` mit
Fehler ab, statt die Append-only-Garantie still zu überspringen. Es
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.
---
## Domains
## Leitplanken, die dauerhaft gelten
| Domain | Verwendung |
|--------|-----------|
| deklarix.de | Primär |
| deklarix.com | Redirect / International |
- **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.**
---
@@ -169,15 +540,35 @@ git push origin main
### Server-Prozess
```bash
# Start (manuell)
PORT=8080 ./dist/deklarix_latest_amd64 &
# Dienst läuft über systemd (installiert per apt, siehe Deployment oben)
sudo systemctl start deklarix
sudo systemctl status deklarix
# Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, RULES_DIR)
# Logs prüfen
journalctl -u deklarix -f
```
### Bekannte Server
| Rolle | Host | Zugang |
|---|---|---|
| Testsystem + `app.deklarix.de` (DB-Integrationstests, apt-Verifikation, nginx-Reverse-Proxy + Let's-Encrypt-TLS auf den lokalen Dienst) | 89.163.205.4 | SSH `noroot`, sudo passwortlos |
| Installer-Endpoint (`get.deklarix.com`) | 89.163.205.110 | noch nicht eingerichtet |
| Marketing-Site (`deklarix.de`/`.com`) | 89.163.205.80 | noch nicht eingerichtet |
---
## Arbeitsweise für Claude Code
- Kleine Commits, ein Thema pro Commit
- Erst Test, dann Implementierung
- Keine neue Abhängigkeit ohne Rückfrage
- Fehler nicht verschlucken, keine stillen Fallbacks
- Bei fachlicher Unsicherheit: nicht raten, sondern als offene Frage in
`rules/OPEN.md` notieren
## Vor Änderungen
1. `go vet ./...` — keine Fehler
@@ -190,3 +581,36 @@ journalctl -u deklarix -f
2. `./scripts/build.sh <version>` → erfolgreich
3. Commit mit semantischer Message: `feat:`, `fix:`, `refactor:`, `docs:`
4. Bei Release: `./scripts/release.sh <version>`
---
## Offene Punkte
- **Rangfolge der Datenklassen und Grenzfälle im Anforderungsprofil**
(siehe `rules/OPEN.md`, Punkte 1/2/5/6) — mit dem/der fachlich
Verantwortlichen (z. B. Datenschutzbeauftragte/r) bestätigen. Schritt
3 ist trotzdem schon umgesetzt (auf Basis dieser dokumentierten
Annahmen) — nicht auf die Klärung gewartet, um nicht blockiert zu
bleiben, aber die Ableitung kann sich noch ändern.
- ~~Genaue Fragebogen-Felder für die drei "verboten"-Varianten~~ —
**erledigt in Schritt 2**, C3 hat jetzt eine Folgefrage `c3_art`.
Weiterhin offen: ob die drei Varianten fachlich vollständig sind
(siehe `rules/OPEN.md`, Punkt 3).
- **Löschfristen je Datenklasse** (konkrete Tageswerte für
`loeschfrist_max_tage`) — noch nicht fachlich festgelegt.
- **Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)**
— die Frontend-Spezifikation fordert das explizit ("nicht in der
Anwendungslogik"), aktuell ist Isolation ausschließlich
anwendungsseitig (Handler-Vergleich `AccountID`). Umstellung auf RLS
ist ein bewusst noch nicht getroffener Architektur-Entscheid —
Aufwand und Zeitpunkt mit dem Nutzer klären, bevor mehr Tabellen
entstehen, die sonst nachträglich migriert werden müssten.
- **"Admin und KI-Verantwortlicher" beim Firma-Onboarding** — die
Spezifikation will, dass der erste Nutzer beide Rollen gleichzeitig
hat; `app_user.role` ist aktuell ein einzelner Wert. Muss geklärt
werden: zwei Rollen pro Nutzer zulassen (Datenmodell-Änderung) oder
zwei `app_user`-Zeilen für dieselbe Person?
- **Zentraler Werkzeugkatalog ist leer** — "Werkzeugkatalog aus der
Plattform vorbelegt" beim Onboarding ist nicht automatisiert, es gibt
noch keine Ebene-5/Betreiber-UI zur Katalogpflege.
- Markenanmeldung Deklarix, sobald erster Kunde zahlt.

71
cmd/deklarix/main.go Normal file
View File

@@ -0,0 +1,71 @@
package main
import (
"context"
"log"
"net/http"
"os"
"github.com/netcell-it/deklarix/internal/rules"
"github.com/netcell-it/deklarix/internal/store"
"github.com/netcell-it/deklarix/internal/web"
)
func main() {
databaseURL := os.Getenv("DATABASE_URL")
if databaseURL == "" {
log.Fatal("DATABASE_URL is required")
}
if err := store.Migrate(databaseURL); err != nil {
log.Fatalf("migrate: %v", err)
}
db, err := store.Open(context.Background(), databaseURL)
if err != nil {
log.Fatalf("open store: %v", err)
}
defer db.Close()
rulesDir := os.Getenv("RULES_DIR")
if rulesDir == "" {
rulesDir = "rules"
}
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 datenklasse-regelwerk from %s: %v", rulesDir, err)
}
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(db, web.Regelwerk{
Datenklasse: datenklasse, Einstufung: einstufung, Anforderungen: anforderungen,
})
if err != nil {
log.Fatalf("web server: %v", err)
}
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
addr := ":" + port
log.Printf("Deklarix server starting on %s", addr)
if err := http.ListenAndServe(addr, server); err != nil {
log.Fatal(err)
}
}

View File

@@ -1,27 +0,0 @@
package main
import (
"fmt"
"log"
"net/http"
"os"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
addr := ":" + port
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"ok":true}`)
})
log.Printf("Deklarix server starting on %s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatal(err)
}
}

21
go.mod
View File

@@ -1,3 +1,24 @@
module github.com/netcell-it/deklarix
go 1.26.6
require (
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea
github.com/go-pdf/fpdf v0.9.0
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/jackc/pgx/v5 v5.10.0
golang.org/x/crypto v0.55.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49 // indirect
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/rogpeppe/go-internal v1.16.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.41.0 // indirect
)

100
go.sum Normal file
View File

@@ -0,0 +1,100 @@
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49 h1:h+XMRXf+WLY0h/3itqE8OT3TgjCMHK4nq2FNGi0au2c=
github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc=
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea h1:ALRwvjsSP53QmnN3Bcj0NpR8SsFLnskny/EIMebAk1c=
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw=
github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

56
internal/auth/auth.go Normal file
View File

@@ -0,0 +1,56 @@
// Package auth enthält die reine Logik für Anmeldung: Passwort-Hashing,
// Session-Token-Erzeugung. Es fasst keine Datenbank an — das macht
// internal/store (account, app_user, session), damit Persistenz an
// einer Stelle im Projekt gebündelt bleibt.
package auth
import (
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"golang.org/x/crypto/bcrypt"
)
// MinPasswordLength ist die einzige Passwort-Regel, die wir prüfen —
// keine erzwungenen Sonderzeichen/Ziffern-Vorgaben, die Nutzer nur zu
// vorhersehbaren Mustern verleiten.
const MinPasswordLength = 8
// SessionDuration ist die Gültigkeitsdauer einer neu erstellten Sitzung.
const SessionDuration = 30 * 24 * time.Hour
// HashPassword hasht ein Klartext-Passwort für die Speicherung. Lehnt
// zu kurze Passwörter ab, statt sie stillschweigend zu hashen.
func HashPassword(password string) (string, error) {
if len(password) < MinPasswordLength {
return "", fmt.Errorf("auth: passwort muss mindestens %d Zeichen haben", MinPasswordLength)
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", fmt.Errorf("auth: passwort hashen: %w", err)
}
return string(hash), nil
}
// VerifyPassword prüft ein Klartext-Passwort gegen einen gespeicherten
// Hash. Ein Fehler bedeutet: falsches Passwort oder ungültiger Hash —
// beides führt zu "Anmeldung abgelehnt", nie zu einem Unterschied, den
// ein Angreifer für User-Enumeration nutzen könnte.
func VerifyPassword(hash, password string) error {
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
return fmt.Errorf("auth: passwort ungültig: %w", err)
}
return nil
}
// NewSessionToken erzeugt ein kryptographisch zufälliges Session-Token
// (32 Byte, hex-kodiert).
func NewSessionToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("auth: token generieren: %w", err)
}
return hex.EncodeToString(buf), nil
}

View File

@@ -0,0 +1,50 @@
package auth_test
import (
"testing"
"github.com/netcell-it/deklarix/internal/auth"
)
func TestHashPasswordRejectsShortPassword(t *testing.T) {
if _, err := auth.HashPassword("kurz"); err == nil {
t.Fatal("expected error for a too-short password, got nil")
}
}
func TestHashAndVerifyRoundTrip(t *testing.T) {
hash, err := auth.HashPassword("ein-langes-passwort")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
if err := auth.VerifyPassword(hash, "ein-langes-passwort"); err != nil {
t.Fatalf("VerifyPassword: %v", err)
}
}
func TestVerifyPasswordRejectsWrongPassword(t *testing.T) {
hash, err := auth.HashPassword("ein-langes-passwort")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
if err := auth.VerifyPassword(hash, "falsches-passwort"); err == nil {
t.Fatal("expected error for a wrong password, got nil")
}
}
func TestNewSessionTokenIsRandomAndCorrectLength(t *testing.T) {
a, err := auth.NewSessionToken()
if err != nil {
t.Fatalf("NewSessionToken: %v", err)
}
b, err := auth.NewSessionToken()
if err != nil {
t.Fatalf("NewSessionToken: %v", err)
}
if a == b {
t.Fatal("expected two distinct tokens, got the same value twice")
}
if len(a) != 64 {
t.Fatalf("token length = %d, want 64 (hex of 32 random bytes)", len(a))
}
}

264
internal/rules/evaluate.go Normal file
View File

@@ -0,0 +1,264 @@
package rules
import (
"encoding/json"
"fmt"
"sort"
"strings"
)
// Antworten ist die im Antrag gespeicherte Fragebogen-Antwort-Menge —
// dieselbe Struktur, die internal/web/antrag_handlers.go erzeugt
// (antwortenFromForm): ein flaches JSON-Objekt mit denselben
// Fakten-Schlüsseln wie in den Regelwerk-YAML-Dateien (b1..b7, c1..c5,
// c2_folge, c3_art).
type Antworten map[string]any
// ParseAntworten liest antrag.antworten (rohes JSON aus der Datenbank).
func ParseAntworten(raw []byte) (Antworten, error) {
if len(raw) == 0 {
return Antworten{}, nil
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("rules: antworten parsen: %w", err)
}
return Antworten(m), nil
}
// istJaOderUnsicher prüft eine B-Frage. "Unsicher zählt wie Ja" ist eine
// Auswertungsregel (siehe CLAUDE.md, Fragebogen-Abschnitt B) — deshalb
// hier und nicht schon beim Speichern des Antrags angewendet.
func (a Antworten) istJaOderUnsicher(key string) bool {
v, _ := a[key].(string)
return v == "ja" || v == "unsicher"
}
// DatenklasseErgebnis ist die abgeleitete Datenklasse mit Herleitung —
// "Jede Anforderung/Ableitung trägt ihre Herleitung, sonst ist das
// Ergebnis im Audit wertlos" (siehe CLAUDE.md).
type DatenklasseErgebnis struct {
ID string
Herleitung string
}
// EvaluateDatenklasse leitet die Datenklasse aus den B-Antworten ab.
// "Höchste zutreffende Stufe gewinnt" (höherer Rang gewinnt). Trifft
// keine Stufe zu (z. B. wenn versehentlich auch B7 mit "nein"
// beantwortet wurde, obwohl keine andere Kategorie zutrifft — ein
// eigentlich widersprüchlicher Fragebogen-Zustand), wird konservativ
// "intern" angenommen statt "oeffentlich": im Zweifel mehr Schutz, nicht
// weniger. Siehe rules/OPEN.md, Punkt 5.
func EvaluateDatenklasse(regelwerk DatenklasseRegelwerk, antworten Antworten) DatenklasseErgebnis {
const fallback = "intern"
var gewinner *DatenklasseStufe
var treffer []string
for i := range regelwerk.Stufen {
st := &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)
}
}

111
internal/rules/loader.go Normal file
View File

@@ -0,0 +1,111 @@
package rules
import (
"fmt"
"io/fs"
"gopkg.in/yaml.v3"
)
// 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 DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse lesen: %w", err)
}
if err := yaml.Unmarshal(data, &rw); err != nil {
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse parsen: %w", err)
}
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

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

71
internal/store/account.go Normal file
View File

@@ -0,0 +1,71 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Account ist ein Mandant (ein Unternehmen, das die Antragsprüfung
// nutzt). Jeder Antrag gehört genau einem Account.
type Account struct {
ID string
Name string
CreatedAt time.Time
}
// CreateAccount legt einen neuen Mandanten an.
func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) {
var a Account
err := s.Pool.QueryRow(ctx, `
INSERT INTO account (name) VALUES ($1)
RETURNING id, name, created_at
`, name).Scan(&a.ID, &a.Name, &a.CreatedAt)
if err != nil {
return Account{}, fmt.Errorf("store: create account: %w", err)
}
return a, nil
}
// 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)
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, ErrNotFound
}
if err != nil {
return Account{}, fmt.Errorf("store: get account: %w", err)
}
return a, nil
}
// ListAccounts liefert alle Mandanten, neueste zuerst — für den
// Admin-Bereich (Accounts-Verwaltung).
func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) {
rows, err := s.Pool.Query(ctx, `
SELECT id, name, created_at FROM account ORDER BY created_at DESC
`)
if err != nil {
return nil, fmt.Errorf("store: list accounts: %w", err)
}
defer rows.Close()
var out []Account
for rows.Next() {
var a Account
if err := rows.Scan(&a.ID, &a.Name, &a.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan account: %w", err)
}
out = append(out, a)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list accounts: %w", err)
}
return out, nil
}

View File

@@ -0,0 +1,138 @@
package store_test
import (
"context"
"testing"
)
func TestAppUserRoleAllowsBetreiber(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
u, err := s.CreateUser(ctx, accID, "admin@example.com", "hash", "betreiber")
if err != nil {
t.Fatalf("CreateUser mit role=betreiber: %v", err)
}
if u.Role != "betreiber" {
t.Fatalf("Role = %q, want betreiber", u.Role)
}
}
func TestAppUserRoleRejectsUnknownRole(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
if _, err := s.CreateUser(ctx, accID, "unbekannt@example.com", "hash", "kanzlei"); err == nil {
t.Fatal("expected the old role 'kanzlei' to be rejected after the product pivot")
}
}
func TestListAccounts(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
before, err := s.ListAccounts(ctx)
if err != nil {
t.Fatalf("ListAccounts: %v", err)
}
acc, err := s.CreateAccount(ctx, "Neuer Mandant fuer ListAccounts")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
after, err := s.ListAccounts(ctx)
if err != nil {
t.Fatalf("ListAccounts: %v", err)
}
if len(after) != len(before)+1 {
t.Fatalf("expected exactly one more account, got %d -> %d", len(before), len(after))
}
found := false
for _, a := range after {
if a.ID == acc.ID {
found = true
}
}
if !found {
t.Fatal("expected the newly created account in ListAccounts")
}
}
func TestListUsersForAccount(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
otherAccID := testAccountID(t, s)
if _, err := s.CreateUser(ctx, accID, "eins@example.com", "hash", "mitarbeiter"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
if _, err := s.CreateUser(ctx, accID, "zwei@example.com", "hash", "verantwortlicher"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
if _, err := s.CreateUser(ctx, otherAccID, "fremd@example.com", "hash", "mitarbeiter"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
list, err := s.ListUsersForAccount(ctx, accID)
if err != nil {
t.Fatalf("ListUsersForAccount: %v", err)
}
if len(list) != 2 {
t.Fatalf("expected exactly 2 users for this account, got %d: %+v", len(list), list)
}
}
func TestAuditLogCreateAndList(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
admin, err := s.CreateUser(ctx, accID, "admin-audit@example.com", "hash", "betreiber")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
entry, err := s.CreateAuditEntry(ctx, admin.ID, "werkzeug.aktualisiert", "werkzeug", accID, "manuell geprüft")
if err != nil {
t.Fatalf("CreateAuditEntry: %v", err)
}
if entry.ActorUserID != admin.ID {
t.Fatalf("ActorUserID = %q, want %q", entry.ActorUserID, admin.ID)
}
list, err := s.ListAuditLog(ctx, 10)
if err != nil {
t.Fatalf("ListAuditLog: %v", err)
}
if len(list) == 0 {
t.Fatal("expected at least one audit entry")
}
if list[0].ID != entry.ID {
t.Fatalf("expected the newest entry first, got %+v", list[0])
}
}
func TestAuditLogIsAppendOnly(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
admin, err := s.CreateUser(ctx, accID, "admin-appendonly@example.com", "hash", "betreiber")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
entry, err := s.CreateAuditEntry(ctx, admin.ID, "werkzeug.aktualisiert", "werkzeug", accID, "")
if err != nil {
t.Fatalf("CreateAuditEntry: %v", err)
}
_, err = s.Pool.Exec(ctx, `UPDATE audit_log SET action = 'geaendert' WHERE id = $1`, entry.ID)
if err == nil {
t.Fatal("expected UPDATE on audit_log to be rejected by the append-only trigger")
}
_, err = s.Pool.Exec(ctx, `DELETE FROM audit_log WHERE id = $1`, entry.ID)
if err == nil {
t.Fatal("expected DELETE on audit_log to be rejected by the append-only trigger")
}
}

162
internal/store/antrag.go Normal file
View File

@@ -0,0 +1,162 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Antrag ist ein Vorhaben aus dem Fragebogen (Abschnitt A) mit den
// vollständigen Antworten aus B/C/D als JSON — der Fragebogen ist
// adaptiv (Folgefragen hängen von vorherigen Antworten ab), ein starres
// Spaltenschema könnte das nicht abbilden. Antworten ist bewusst
// []byte (rohes JSON), nicht ein aufgelöster Go-Typ — die Struktur der
// Antworten ist Sache von internal/rules (Stufe 2), store speichert nur.
type Antrag struct {
ID string
AccountID string
ErstellerUserID string
AbteilungID *string
Titel string
Beschreibung string
Ergebnis string
Haeufigkeit string
Antworten []byte
Status string
CreatedAt time.Time
UpdatedAt time.Time
}
const antragColumns = `id, account_id, ersteller_user_id, abteilung_id, titel, beschreibung,
ergebnis, haeufigkeit, antworten, status, created_at, updated_at`
func scanAntrag(row interface {
Scan(dest ...any) error
}) (Antrag, error) {
var a Antrag
err := row.Scan(
&a.ID, &a.AccountID, &a.ErstellerUserID, &a.AbteilungID, &a.Titel, &a.Beschreibung,
&a.Ergebnis, &a.Haeufigkeit, &a.Antworten, &a.Status, &a.CreatedAt, &a.UpdatedAt,
)
return a, err
}
// CreateAntrag legt einen neuen Antrag im Status "entwurf" an.
func (s *Store) CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (Antrag, error) {
row := s.Pool.QueryRow(ctx, `
INSERT INTO antrag (account_id, ersteller_user_id, abteilung_id, titel)
VALUES ($1, $2, $3, $4)
RETURNING `+antragColumns,
accountID, erstellerUserID, abteilungID, titel,
)
a, err := scanAntrag(row)
if err != nil {
return Antrag{}, fmt.Errorf("store: create antrag: %w", err)
}
return a, nil
}
// GetAntrag liest einen Antrag anhand seiner ID — ohne Mandanten-Prüfung,
// das ist Sache des Aufrufers (siehe Antrag.AccountID).
func (s *Store) GetAntrag(ctx context.Context, id string) (Antrag, error) {
row := s.Pool.QueryRow(ctx, `SELECT `+antragColumns+` FROM antrag WHERE id = $1`, id)
a, err := scanAntrag(row)
if errors.Is(err, pgx.ErrNoRows) {
return Antrag{}, ErrNotFound
}
if err != nil {
return Antrag{}, fmt.Errorf("store: get antrag: %w", err)
}
return a, nil
}
// UpdateAntragFelder aktualisiert die Fragebogen-Felder eines Antrags —
// solange er im Entwurf ist, kann der Fragebogen adaptiv weiter
// ausgefüllt werden (Sache der Anwendungsschicht, store erzwingt den
// Status hier nicht).
func (s *Store) UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (Antrag, error) {
row := s.Pool.QueryRow(ctx, `
UPDATE antrag SET
titel = $2, beschreibung = $3, ergebnis = $4, haeufigkeit = $5, antworten = $6, updated_at = now()
WHERE id = $1
RETURNING `+antragColumns,
id, titel, beschreibung, ergebnis, haeufigkeit, antworten,
)
a, err := scanAntrag(row)
if errors.Is(err, pgx.ErrNoRows) {
return Antrag{}, ErrNotFound
}
if err != nil {
return Antrag{}, fmt.Errorf("store: update antrag felder: %w", err)
}
return a, nil
}
// SetAntragStatus setzt den Status eines Antrags (entwurf -> eingereicht
// -> entschieden). Antrag ist, anders als bewertung/entscheidung, NICHT
// append-only — der Lebenszyklus ist eine normale Zustandsänderung.
func (s *Store) SetAntragStatus(ctx context.Context, id, status string) error {
tag, err := s.Pool.Exec(ctx, `UPDATE antrag SET status = $2, updated_at = now() WHERE id = $1`, id, status)
if err != nil {
return fmt.Errorf("store: set antrag status: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// ListAntraegeForUser liefert alle Anträge, die ein bestimmter Nutzer
// gestellt hat, neueste zuerst — für Ebene 2 ("eigene Anträge
// einsehen"), im Unterschied zu ListAntraegeForAccount, das alle
// Anträge eines Mandanten liefert (Ebene 3, Posteingang).
func (s *Store) ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]Antrag, error) {
rows, err := s.Pool.Query(ctx, `
SELECT `+antragColumns+` FROM antrag WHERE ersteller_user_id = $1 ORDER BY created_at DESC
`, erstellerUserID)
if err != nil {
return nil, fmt.Errorf("store: list antraege for user: %w", err)
}
defer rows.Close()
var out []Antrag
for rows.Next() {
a, err := scanAntrag(rows)
if err != nil {
return nil, fmt.Errorf("store: scan antrag: %w", err)
}
out = append(out, a)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list antraege for user: %w", err)
}
return out, nil
}
// ListAntraegeForAccount liefert alle Anträge eines Mandanten, neueste
// zuerst.
func (s *Store) ListAntraegeForAccount(ctx context.Context, accountID string) ([]Antrag, error) {
rows, err := s.Pool.Query(ctx, `
SELECT `+antragColumns+` FROM antrag WHERE account_id = $1 ORDER BY created_at DESC
`, accountID)
if err != nil {
return nil, fmt.Errorf("store: list antraege for account: %w", err)
}
defer rows.Close()
var out []Antrag
for rows.Next() {
a, err := scanAntrag(rows)
if err != nil {
return nil, fmt.Errorf("store: scan antrag: %w", err)
}
out = append(out, a)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list antraege for account: %w", err)
}
return out, nil
}

View File

@@ -0,0 +1,133 @@
package store_test
import (
"context"
"errors"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
func testUserID(t *testing.T, s *store.Store, accountID string) string {
t.Helper()
u, err := s.CreateUser(context.Background(), accountID, "mitarbeiter-"+accountID+"@example.com", "hash", "mitarbeiter")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
return u.ID
}
func TestAntragCRUD(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
userID := testUserID(t, s, accID)
abt, err := s.CreateAbteilung(ctx, accID, "Vertrieb")
if err != nil {
t.Fatalf("CreateAbteilung: %v", err)
}
a, err := s.CreateAntrag(ctx, accID, userID, &abt.ID, "Angebotstexte generieren")
if err != nil {
t.Fatalf("CreateAntrag: %v", err)
}
if a.Status != "entwurf" {
t.Fatalf("Status = %q, want entwurf", a.Status)
}
if a.AbteilungID == nil || *a.AbteilungID != abt.ID {
t.Fatalf("AbteilungID = %v, want %q", a.AbteilungID, abt.ID)
}
got, err := s.GetAntrag(ctx, a.ID)
if err != nil {
t.Fatalf("GetAntrag: %v", err)
}
if got.Titel != "Angebotstexte generieren" {
t.Fatalf("Titel = %q, unerwartet", got.Titel)
}
updated, err := s.UpdateAntragFelder(ctx, a.ID, "Angebotstexte generieren", "KI schreibt Angebotstexte", "Fertiger Text zur Freigabe", "gelegentlich", []byte(`{"b1":"nein"}`))
if err != nil {
t.Fatalf("UpdateAntragFelder: %v", err)
}
if updated.Beschreibung != "KI schreibt Angebotstexte" || updated.Haeufigkeit != "gelegentlich" {
t.Fatalf("UpdateAntragFelder = %+v, unerwartete Werte", updated)
}
if err := s.SetAntragStatus(ctx, a.ID, "eingereicht"); err != nil {
t.Fatalf("SetAntragStatus: %v", err)
}
got, err = s.GetAntrag(ctx, a.ID)
if err != nil {
t.Fatalf("GetAntrag nach Statuswechsel: %v", err)
}
if got.Status != "eingereicht" {
t.Fatalf("Status nach SetAntragStatus = %q, want eingereicht", got.Status)
}
}
func TestGetAntragNotFound(t *testing.T) {
s := openTestStore(t)
_, err := s.GetAntrag(context.Background(), "00000000-0000-0000-0000-000000000000")
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound", err)
}
}
func TestSetAntragStatusNotFound(t *testing.T) {
s := openTestStore(t)
err := s.SetAntragStatus(context.Background(), "00000000-0000-0000-0000-000000000000", "eingereicht")
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound", err)
}
}
func TestListAntraegeForUserOnlyReturnsOwnAntraege(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
userA := testUserID(t, s, accID)
userB, err := s.CreateUser(ctx, accID, "zweiter-mitarbeiter-"+accID+"@example.com", "hash", "mitarbeiter")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
if _, err := s.CreateAntrag(ctx, accID, userA, nil, "Antrag von A"); err != nil {
t.Fatalf("CreateAntrag (A): %v", err)
}
if _, err := s.CreateAntrag(ctx, accID, userB.ID, nil, "Antrag von B"); err != nil {
t.Fatalf("CreateAntrag (B): %v", err)
}
listA, err := s.ListAntraegeForUser(ctx, userA)
if err != nil {
t.Fatalf("ListAntraegeForUser: %v", err)
}
if len(listA) != 1 || listA[0].Titel != "Antrag von A" {
t.Fatalf("ListAntraegeForUser(A) = %+v, want exactly Antrag von A", listA)
}
}
func TestListAntraegeForAccountIsolatesTenants(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accA := testAccountID(t, s)
accB := testAccountID(t, s)
userA := testUserID(t, s, accA)
userB := testUserID(t, s, accB)
if _, err := s.CreateAntrag(ctx, accA, userA, nil, "Antrag A"); err != nil {
t.Fatalf("CreateAntrag (A): %v", err)
}
if _, err := s.CreateAntrag(ctx, accB, userB, nil, "Antrag B"); err != nil {
t.Fatalf("CreateAntrag (B): %v", err)
}
listA, err := s.ListAntraegeForAccount(ctx, accA)
if err != nil {
t.Fatalf("ListAntraegeForAccount (A): %v", err)
}
if len(listA) != 1 || listA[0].Titel != "Antrag A" {
t.Fatalf("ListAntraegeForAccount (A) = %+v, want exactly Antrag A", listA)
}
}

View File

@@ -0,0 +1,61 @@
package store
import (
"context"
"fmt"
"time"
)
// AuditEntry ist ein Protokolleintrag einer Admin-Aktion. Append-only:
// siehe Migration 0004 — ein Protokoll, das man ändern kann, ist kein
// Nachweis mehr, aus demselben Grund wie bei finding/extraction.
type AuditEntry struct {
ID string
ActorUserID string
Action string
TargetType string
TargetID string
Details string
CreatedAt time.Time
}
// CreateAuditEntry protokolliert eine Admin-Aktion.
func (s *Store) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (AuditEntry, error) {
var e AuditEntry
err := s.Pool.QueryRow(ctx, `
INSERT INTO audit_log (actor_user_id, action, target_type, target_id, details)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, actor_user_id, action, target_type, target_id, details, created_at
`, actorUserID, action, targetType, targetID, details).Scan(
&e.ID, &e.ActorUserID, &e.Action, &e.TargetType, &e.TargetID, &e.Details, &e.CreatedAt,
)
if err != nil {
return AuditEntry{}, fmt.Errorf("store: create audit entry: %w", err)
}
return e, nil
}
// ListAuditLog liefert die letzten Protokolleinträge, neueste zuerst.
func (s *Store) ListAuditLog(ctx context.Context, limit int) ([]AuditEntry, error) {
rows, err := s.Pool.Query(ctx, `
SELECT id, actor_user_id, action, target_type, target_id, details, created_at
FROM audit_log ORDER BY created_at DESC LIMIT $1
`, limit)
if err != nil {
return nil, fmt.Errorf("store: list audit log: %w", err)
}
defer rows.Close()
var out []AuditEntry
for rows.Next() {
var e AuditEntry
if err := rows.Scan(&e.ID, &e.ActorUserID, &e.Action, &e.TargetType, &e.TargetID, &e.Details, &e.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan audit entry: %w", err)
}
out = append(out, e)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list audit log: %w", err)
}
return out, nil
}

116
internal/store/auth_test.go Normal file
View File

@@ -0,0 +1,116 @@
package store_test
import (
"context"
"errors"
"testing"
"time"
"github.com/netcell-it/deklarix/internal/store"
)
func TestAccountCRUD(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.GetAccount(ctx, acc.ID)
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if got.Name != "Beispiel Agentur GmbH" {
t.Fatalf("Name = %q, want Beispiel Agentur GmbH", got.Name)
}
}
func TestUserCRUD(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
user, err := s.CreateUser(ctx, accID, "team@example.com", "bcrypt-hash", "mitarbeiter")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
byEmail, err := s.GetUserByEmail(ctx, "team@example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
if byEmail.ID != user.ID {
t.Fatalf("GetUserByEmail returned a different user than CreateUser")
}
byID, err := s.GetUser(ctx, user.ID)
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if byID.Email != "team@example.com" || byID.AccountID != accID {
t.Fatalf("GetUser = %+v, unerwartete Werte", byID)
}
}
func TestGetUserByEmailNotFound(t *testing.T) {
s := openTestStore(t)
_, err := s.GetUserByEmail(context.Background(), "nichtvorhanden@example.com")
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound", err)
}
}
func TestUserEmailIsUnique(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
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", "mitarbeiter"); err == nil {
t.Fatal("expected error for a duplicate email, got nil")
}
}
func TestSessionCRUD(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
user, err := s.CreateUser(ctx, accID, "session@example.com", "hash", "verantwortlicher")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
expiresAt := time.Now().Add(time.Hour).Truncate(time.Millisecond)
sess, err := s.CreateSession(ctx, "test-token-123", user.ID, expiresAt)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
got, err := s.GetSession(ctx, sess.Token)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
if got.UserID != user.ID {
t.Fatalf("UserID = %q, want %q", got.UserID, user.ID)
}
if err := s.DeleteSession(ctx, sess.Token); err != nil {
t.Fatalf("DeleteSession: %v", err)
}
if _, err := s.GetSession(ctx, sess.Token); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err after delete = %v, want store.ErrNotFound", err)
}
}
func TestGetSessionNotFound(t *testing.T) {
s := openTestStore(t)
_, err := s.GetSession(context.Background(), "unbekanntes-token")
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound", 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

@@ -0,0 +1,111 @@
package store
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Entscheidung ist die Entscheidung eines/einer Verantwortlichen über
// einen Antrag, auf Basis einer Bewertung (Vorschlag). Append-only —
// siehe Migration. WerkzeugSnapshot ist der eingefrorene, vollständige
// Werkzeug-Datensatz zum Entscheidungszeitpunkt (nil, wenn kein
// Werkzeug gewählt wurde, z. B. bei "abgelehnt") — ein späterer
// Katalog-Wandel darf nicht rückwirkend verändern, worauf die
// Entscheidung beruhte.
type Entscheidung struct {
ID string
AntragID string
BewertungID string
EntscheiderUserID string
Entscheidung string
WerkzeugID *string
WerkzeugSnapshot *Werkzeug
Begruendung string
GueltigBis *time.Time
CreatedAt time.Time
}
const entscheidungColumns = `id, antrag_id, bewertung_id, entscheider_user_id, entscheidung,
werkzeug_id, werkzeug_snapshot, begruendung, gueltig_bis, created_at`
func scanEntscheidung(row interface {
Scan(dest ...any) error
}) (Entscheidung, error) {
var e Entscheidung
var snapshotRaw []byte
err := row.Scan(
&e.ID, &e.AntragID, &e.BewertungID, &e.EntscheiderUserID, &e.Entscheidung,
&e.WerkzeugID, &snapshotRaw, &e.Begruendung, &e.GueltigBis, &e.CreatedAt,
)
if err != nil {
return Entscheidung{}, err
}
if snapshotRaw != nil {
var w Werkzeug
if err := json.Unmarshal(snapshotRaw, &w); err != nil {
return Entscheidung{}, fmt.Errorf("store: werkzeug_snapshot unmarshal: %w", err)
}
e.WerkzeugSnapshot = &w
}
return e, nil
}
// EntscheidungInput bündelt die Felder einer neuen Entscheidung.
type EntscheidungInput struct {
AntragID string
BewertungID string
EntscheiderUserID string
Entscheidung string
WerkzeugID *string
WerkzeugSnapshot *Werkzeug
Begruendung string
GueltigBis *time.Time
}
// CreateEntscheidung speichert eine Entscheidung über einen Antrag.
func (s *Store) CreateEntscheidung(ctx context.Context, in EntscheidungInput) (Entscheidung, error) {
var snapshotJSON []byte
if in.WerkzeugSnapshot != nil {
var err error
snapshotJSON, err = json.Marshal(in.WerkzeugSnapshot)
if err != nil {
return Entscheidung{}, fmt.Errorf("store: werkzeug_snapshot marshal: %w", err)
}
}
row := s.Pool.QueryRow(ctx, `
INSERT INTO entscheidung (
antrag_id, bewertung_id, entscheider_user_id, entscheidung, werkzeug_id, werkzeug_snapshot, begruendung, gueltig_bis
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING `+entscheidungColumns,
in.AntragID, in.BewertungID, in.EntscheiderUserID, in.Entscheidung, in.WerkzeugID, snapshotJSON, in.Begruendung, in.GueltigBis,
)
e, err := scanEntscheidung(row)
if err != nil {
return Entscheidung{}, fmt.Errorf("store: create entscheidung: %w", err)
}
return e, nil
}
// GetLatestEntscheidungForAntrag liefert die zuletzt getroffene
// Entscheidung eines Antrags. Liefert ErrNotFound, wenn noch keine
// Entscheidung existiert.
func (s *Store) GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (Entscheidung, error) {
row := s.Pool.QueryRow(ctx, `
SELECT `+entscheidungColumns+`
FROM entscheidung WHERE antrag_id = $1 ORDER BY created_at DESC LIMIT 1
`, antragID)
e, err := scanEntscheidung(row)
if errors.Is(err, pgx.ErrNoRows) {
return Entscheidung{}, ErrNotFound
}
if err != nil {
return Entscheidung{}, fmt.Errorf("store: get latest entscheidung: %w", err)
}
return e, nil
}

View File

@@ -0,0 +1,115 @@
package store_test
import (
"context"
"errors"
"testing"
"time"
"github.com/netcell-it/deklarix/internal/store"
)
func testAntragMitBewertung(t *testing.T, s *store.Store, accountID, userID string) (store.Antrag, store.Bewertung) {
t.Helper()
ctx := context.Background()
antrag, err := s.CreateAntrag(ctx, accountID, userID, nil, "Testantrag")
if err != nil {
t.Fatalf("CreateAntrag: %v", err)
}
b, err := s.CreateBewertung(ctx, testBewertungInput(antrag.ID))
if err != nil {
t.Fatalf("CreateBewertung: %v", err)
}
return antrag, b
}
func TestEntscheidungCreateAndGetLatest(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
userID := testUserID(t, s, accID)
antrag, b := testAntragMitBewertung(t, s, accID, userID)
w, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "ChatGPT Enterprise"))
if err != nil {
t.Fatalf("CreateWerkzeug: %v", err)
}
gueltigBis := time.Now().Add(365 * 24 * time.Hour).Truncate(time.Millisecond)
e, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
AntragID: antrag.ID, BewertungID: b.ID, EntscheiderUserID: userID,
Entscheidung: "genehmigt", WerkzeugID: &w.ID, WerkzeugSnapshot: &w,
Begruendung: "", GueltigBis: &gueltigBis,
})
if err != nil {
t.Fatalf("CreateEntscheidung: %v", err)
}
if e.Entscheidung != "genehmigt" || e.WerkzeugSnapshot == nil || e.WerkzeugSnapshot.Name != "ChatGPT Enterprise" {
t.Fatalf("CreateEntscheidung = %+v, unerwartete Werte", e)
}
got, err := s.GetLatestEntscheidungForAntrag(ctx, antrag.ID)
if err != nil {
t.Fatalf("GetLatestEntscheidungForAntrag: %v", err)
}
if got.ID != e.ID {
t.Fatalf("GetLatestEntscheidungForAntrag returned a different row than CreateEntscheidung")
}
}
func TestEntscheidungOhneWerkzeugHatKeinenSnapshot(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
userID := testUserID(t, s, accID)
antrag, b := testAntragMitBewertung(t, s, accID, userID)
e, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
AntragID: antrag.ID, BewertungID: b.ID, EntscheiderUserID: userID,
Entscheidung: "abgelehnt", Begruendung: "Kein geeignetes Werkzeug im Katalog",
})
if err != nil {
t.Fatalf("CreateEntscheidung: %v", err)
}
if e.WerkzeugID != nil || e.WerkzeugSnapshot != nil {
t.Fatalf("expected no werkzeug/snapshot for an abgelehnt Entscheidung, got %+v", e)
}
}
func TestGetLatestEntscheidungForAntragNotFound(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
userID := testUserID(t, s, accID)
antrag, err := s.CreateAntrag(ctx, accID, userID, nil, "Ohne Entscheidung")
if err != nil {
t.Fatalf("CreateAntrag: %v", err)
}
_, err = s.GetLatestEntscheidungForAntrag(ctx, antrag.ID)
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound", err)
}
}
func TestEntscheidungIsAppendOnly(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
userID := testUserID(t, s, accID)
antrag, b := testAntragMitBewertung(t, s, accID, userID)
e, err := s.CreateEntscheidung(ctx, store.EntscheidungInput{
AntragID: antrag.ID, BewertungID: b.ID, EntscheiderUserID: userID, Entscheidung: "rueckfrage",
})
if err != nil {
t.Fatalf("CreateEntscheidung: %v", err)
}
_, err = s.Pool.Exec(ctx, `UPDATE entscheidung SET entscheidung = 'genehmigt' WHERE id = $1`, e.ID)
if err == nil {
t.Fatal("expected UPDATE on entscheidung to be rejected by the append-only trigger")
}
_, err = s.Pool.Exec(ctx, `DELETE FROM entscheidung WHERE id = $1`, e.ID)
if err == nil {
t.Fatal("expected DELETE on entscheidung to be rejected by the append-only trigger")
}
}

48
internal/store/migrate.go Normal file
View File

@@ -0,0 +1,48 @@
package store
import (
"database/sql"
"embed"
"errors"
"fmt"
"github.com/golang-migrate/migrate/v4"
pgxmigrate "github.com/golang-migrate/migrate/v4/database/pgx/v5"
"github.com/golang-migrate/migrate/v4/source/iofs"
_ "github.com/jackc/pgx/v5/stdlib"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
// Migrate wendet alle ausstehenden Migrationen aus migrations/ an.
// Die Migrationen sind im Binary eingebettet (go:embed), damit Deklarix
// weiterhin als einzelnes Binary lauffähig bleibt.
func Migrate(databaseURL string) error {
db, err := sql.Open("pgx", databaseURL)
if err != nil {
return fmt.Errorf("store: open db for migration: %w", err)
}
defer db.Close()
driver, err := pgxmigrate.WithInstance(db, &pgxmigrate.Config{})
if err != nil {
return fmt.Errorf("store: migration driver: %w", err)
}
source, err := iofs.New(migrationsFS, "migrations")
if err != nil {
return fmt.Errorf("store: migration source: %w", err)
}
m, err := migrate.NewWithInstance("iofs", source, "pgx5", driver)
if err != nil {
return fmt.Errorf("store: migrate init: %w", err)
}
defer m.Close()
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("store: migrate up: %w", err)
}
return nil
}

View File

@@ -0,0 +1,11 @@
DROP TRIGGER IF EXISTS evidence_package_append_only ON evidence_package;
DROP TRIGGER IF EXISTS finding_append_only ON finding;
DROP TRIGGER IF EXISTS extraction_append_only ON extraction;
DROP FUNCTION IF EXISTS forbid_update_delete();
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 asset;
DROP TABLE IF EXISTS submission;

View File

@@ -0,0 +1,89 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
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()
);
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()
);
-- Stufe 1: striktes JSON aus der Claude-Extraktion. Append-only, siehe Trigger unten.
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()
);
-- Stufe 2: Ergebnis pro Regel. Append-only, Korrektur = neue Zeile.
-- supersedes zeigt auf die alte Zeile, die diese Zeile ersetzt (gesetzt
-- beim INSERT der Korrektur, nie per UPDATE — der Trigger würde das
-- verbieten). "Aktuell gültig" = Zeilen, auf die kein supersedes zeigt,
-- siehe Index unten für den Anti-Join.
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')),
message TEXT NOT NULL,
supersedes UUID REFERENCES finding (id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX finding_supersedes_idx ON finding (supersedes) WHERE supersedes IS NOT NULL;
-- Dossier + Beweiskette (Hash, RFC-3161-Token). Append-only.
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 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()
);
-- Ein Beweisarchiv, in dem man Zeilen ändern kann, ist kein Beweisarchiv.
CREATE FUNCTION forbid_update_delete() RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'append-only table: % on % is not allowed', TG_OP, TG_TABLE_NAME;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER extraction_append_only
BEFORE UPDATE OR DELETE ON extraction
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
CREATE TRIGGER finding_append_only
BEFORE UPDATE OR DELETE ON finding
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
CREATE TRIGGER evidence_package_append_only
BEFORE UPDATE OR DELETE ON evidence_package
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();

View File

@@ -0,0 +1,5 @@
ALTER TABLE finding DROP COLUMN title;
ALTER TABLE finding DROP COLUMN fix;
ALTER TABLE finding DROP COLUMN sources;
ALTER TABLE finding ADD COLUMN message TEXT NOT NULL DEFAULT '';
ALTER TABLE finding ALTER COLUMN message DROP DEFAULT;

View File

@@ -0,0 +1,11 @@
-- finding.message war ein einzelnes Freitextfeld. Fürs Dossier brauchen
-- wir Titel, Korrekturvorschlag und Fundstellen aber strukturiert und
-- getrennt (siehe internal/dossier), sonst müsste die Anzeige Text
-- wieder auseinanderparsen, den wir selbst zusammengefügt haben.
ALTER TABLE finding DROP COLUMN message;
ALTER TABLE finding ADD COLUMN title TEXT NOT NULL DEFAULT '';
ALTER TABLE finding ADD COLUMN fix TEXT NOT NULL DEFAULT '';
ALTER TABLE finding ADD COLUMN sources TEXT[] NOT NULL DEFAULT '{}';
ALTER TABLE finding ALTER COLUMN title DROP DEFAULT;
ALTER TABLE finding ALTER COLUMN fix DROP DEFAULT;
ALTER TABLE finding ALTER COLUMN sources DROP DEFAULT;

View File

@@ -0,0 +1,4 @@
ALTER TABLE submission DROP COLUMN account_id;
DROP TABLE session;
DROP TABLE app_user;
DROP TABLE account;

View File

@@ -0,0 +1,33 @@
-- Mandantentrennung: ein account ist der Mandant (Creator, Agentur,
-- Marke oder Kanzlei als eigene Organisation), app_user ist ein Login
-- innerhalb eines accounts. session ist bewusst eine eigene Tabelle
-- statt signierter, zustandsloser Tokens — ein Logout muss eine
-- Sitzung wirklich beenden können, nicht nur clientseitig "vergessen"
-- werden.
CREATE TABLE account (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- "app_user" statt "user", da user ein reserviertes Wort in SQL ist.
CREATE TABLE app_user (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES account (id),
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE session (
token TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES app_user (id),
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Jeder Beitrag gehört ab jetzt zu genau einem Mandanten. NOT NULL ohne
-- Default ist hier sicher, weil noch keine Submission-Zeilen aus der
-- Zeit vor Auth existieren (kein Kunde live).
ALTER TABLE submission ADD COLUMN account_id UUID NOT NULL REFERENCES account (id);

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

62
internal/store/session.go Normal file
View File

@@ -0,0 +1,62 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Session ist eine angemeldete Sitzung. token ist der Primärschlüssel
// (das Cookie-Geheimnis selbst) — es gibt bewusst keine separate ID,
// eine Session wird immer über ihren Token nachgeschlagen.
type Session struct {
Token string
UserID string
ExpiresAt time.Time
CreatedAt time.Time
}
// CreateSession speichert eine neue Sitzung. token muss bereits ein
// kryptographisch zufälliges Geheimnis sein (siehe internal/auth).
func (s *Store) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (Session, error) {
var sess Session
err := s.Pool.QueryRow(ctx, `
INSERT INTO session (token, user_id, expires_at)
VALUES ($1, $2, $3)
RETURNING token, user_id, expires_at, created_at
`, token, userID, expiresAt).Scan(&sess.Token, &sess.UserID, &sess.ExpiresAt, &sess.CreatedAt)
if err != nil {
return Session{}, fmt.Errorf("store: create session: %w", err)
}
return sess, nil
}
// GetSession liest eine Sitzung anhand ihres Tokens. Liefert
// ErrNotFound, wenn der Token unbekannt ist — abgelaufene Sitzungen
// werden NICHT automatisch als "nicht gefunden" behandelt, das prüft
// der Aufrufer über ExpiresAt (siehe internal/auth), damit die
// Unterscheidung "gab es nie" vs. "ist abgelaufen" nicht verloren geht.
func (s *Store) GetSession(ctx context.Context, token string) (Session, error) {
var sess Session
err := s.Pool.QueryRow(ctx, `
SELECT token, user_id, expires_at, created_at FROM session WHERE token = $1
`, token).Scan(&sess.Token, &sess.UserID, &sess.ExpiresAt, &sess.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return Session{}, ErrNotFound
}
if err != nil {
return Session{}, fmt.Errorf("store: get session: %w", err)
}
return sess, nil
}
// DeleteSession beendet eine Sitzung (Logout).
func (s *Store) DeleteSession(ctx context.Context, token string) error {
if _, err := s.Pool.Exec(ctx, `DELETE FROM session WHERE token = $1`, token); err != nil {
return fmt.Errorf("store: delete session: %w", err)
}
return nil
}

31
internal/store/store.go Normal file
View File

@@ -0,0 +1,31 @@
package store
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store hält den Verbindungspool zur Postgres-Datenbank.
type Store struct {
Pool *pgxpool.Pool
}
// Open baut den Verbindungspool auf und prüft ihn mit einem Ping.
func Open(ctx context.Context, databaseURL string) (*Store, error) {
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
return nil, fmt.Errorf("store: open pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("store: ping: %w", err)
}
return &Store{Pool: pool}, nil
}
// Close gibt den Verbindungspool frei.
func (s *Store) Close() {
s.Pool.Close()
}

View File

@@ -0,0 +1,70 @@
package store_test
import (
"context"
"os"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
// Diese Tests brauchen eine laufende Postgres-Instanz und werden ohne
// DATABASE_URL übersprungen, statt eine Verbindung vorzutäuschen.
func testDatabaseURL(t *testing.T) string {
t.Helper()
url := os.Getenv("DATABASE_URL")
if url == "" {
t.Skip("DATABASE_URL nicht gesetzt, überspringe Store-Integrationstest")
}
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)
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()
var tableCount int
err = s.Pool.QueryRow(context.Background(), `
SELECT count(*) FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = ANY($1)
`, []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 != 8 {
t.Fatalf("expected 8 tables, got %d", tableCount)
}
}

103
internal/store/user.go Normal file
View File

@@ -0,0 +1,103 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// ErrNotFound signalisiert, dass eine Abfrage keine Zeile ergeben hat —
// abgrenzbar von echten Fehlern (z. B. Verbindungsabbruch), damit
// Aufrufer "nicht gefunden" gezielt anders behandeln können (z. B. bei
// Login: falsche E-Mail vs. Datenbankfehler).
var ErrNotFound = errors.New("store: nicht gefunden")
// User ist ein Login innerhalb eines Account (Mandanten).
type User struct {
ID string
AccountID string
Email string
PasswordHash string
Role string
CreatedAt time.Time
}
// 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, `
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,
)
if err != nil {
return User{}, fmt.Errorf("store: create user: %w", err)
}
return u, nil
}
// 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)
if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrNotFound
}
if err != nil {
return User{}, fmt.Errorf("store: get user by email: %w", err)
}
return u, nil
}
// 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)
if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrNotFound
}
if err != nil {
return User{}, fmt.Errorf("store: get user: %w", err)
}
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 id, account_id, email, password_hash, role, created_at
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() {
var u User
if err := rows.Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt); 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
}

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

@@ -0,0 +1,145 @@
package web
import (
"net/http"
"time"
"github.com/netcell-it/deklarix/internal/auth"
)
type authPageData struct {
Title string
Error string
}
func (s *Server) renderAuthPage(w http.ResponseWriter, name string, data authPageData) {
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
func (s *Server) handleRegisterForm(w http.ResponseWriter, r *http.Request) {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren"})
}
func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden"})
}
// handleRegister ist die "Firma"-Registrierung (Ebene 1 der Frontend-
// Spezifikation): legt einen neuen Mandanten (Account) und den ersten
// Nutzer als admin an. Testzugang ist sofort aktiv, der Bezahlbetrieb
// wird separat vom Betreiber freigeschaltet (nicht Teil dieses Flows).
// Es gibt bewusst KEINE offene Selbstregistrierung für einzelne
// Mitarbeiter — die kommen über Einladungslink/CSV-Import/Mandantenlink
// (spätere Ausbaustufe), nicht über dieses Formular.
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "ungültiges Formular"})
return
}
accountName := r.FormValue("account_name")
email := r.FormValue("email")
password := r.FormValue("password")
if accountName == "" || email == "" || password == "" {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Alle Felder sind Pflicht"})
return
}
passwordHash, err := auth.HashPassword(password)
if err != nil {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: err.Error()})
return
}
ctx := r.Context()
acc, err := s.store.CreateAccount(ctx, accountName)
if err != nil {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Konto konnte nicht angelegt werden"})
return
}
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
}
if err := s.startSession(w, r, user.ID); err != nil {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Sitzung konnte nicht gestartet werden"})
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "ungültiges Formular"})
return
}
email := r.FormValue("email")
password := r.FormValue("password")
ctx := r.Context()
user, err := s.store.GetUserByEmail(ctx, email)
if err != nil {
// Absichtlich dieselbe Meldung wie bei falschem Passwort — sonst
// verrät die Fehlermeldung, ob eine E-Mail-Adresse existiert.
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "E-Mail oder Passwort falsch"})
return
}
if err := auth.VerifyPassword(user.PasswordHash, password); err != nil {
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "E-Mail oder Passwort falsch"})
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)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(sessionCookieName); err == nil {
_ = s.store.DeleteSession(r.Context(), cookie.Value)
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// startSession erzeugt eine neue Sitzung und setzt das Session-Cookie.
// Secure ist an genau dann, wenn die Anfrage selbst über TLS kam — ein
// fest verdrahtetes Secure=true würde lokale Entwicklung ohne TLS
// (http://localhost) brechen, da Browser ein Secure-Cookie über Klartext-
// HTTP schlicht nicht speichern.
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID string) error {
token, err := auth.NewSessionToken()
if err != nil {
return err
}
expiresAt := time.Now().Add(auth.SessionDuration)
if _, err := s.store.CreateSession(r.Context(), token, userID, expiresAt); err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: r.TLS != nil,
SameSite: http.SameSiteStrictMode,
Expires: expiresAt,
})
return nil
}

View File

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

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

26
internal/web/handlers.go Normal file
View File

@@ -0,0 +1,26 @@
package web
import (
"fmt"
"net/http"
)
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"ok":true}`)
}
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) {
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)
}
}

142
internal/web/middleware.go Normal file
View File

@@ -0,0 +1,142 @@
package web
import (
"context"
"net/http"
"time"
"github.com/netcell-it/deklarix/internal/store"
)
const sessionCookieName = "deklarix_session"
type contextKey int
const userContextKey contextKey = iota
// authenticate liest das Session-Cookie, prüft die Sitzung (existiert,
// nicht abgelaufen) und lädt den zugehörigen Nutzer. Liefert (User{}, false),
// wenn irgendein Schritt fehlschlägt — die Gründe (kein Cookie, unbekanntes
// Token, abgelaufen, Nutzer weg) werden bewusst nicht unterschieden, damit
// requirePage/requireAPI immer denselben, einzigen Fehlerpfad haben.
func (s *Server) authenticate(r *http.Request) (store.User, bool) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil || cookie.Value == "" {
return store.User{}, false
}
sess, err := s.store.GetSession(r.Context(), cookie.Value)
if err != nil {
return store.User{}, false
}
if time.Now().After(sess.ExpiresAt) {
return store.User{}, false
}
user, err := s.store.GetUser(r.Context(), sess.UserID)
if err != nil {
return store.User{}, false
}
return user, true
}
// requirePage schützt volle Seitenaufrufe — ohne gültige Sitzung geht
// es zurück zu /login (eine echte Navigation, kein htmx-Fragment).
func (s *Server) requirePage(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
}
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
}
}
// requireAPI schützt htmx-Endpunkte (Formular-Posts, Downloads) — diese
// werden nur aus einer bereits authentifizierten Seite heraus
// aufgerufen, ein Fehlschlag hier ist der Ausnahmefall (z. B. Sitzung
// mitten in der Nutzung abgelaufen), daher schlicht 401 statt Redirect.
func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := s.authenticate(r)
if !ok {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
}
}
// requireBetreiber schützt den Plattform-Bereich (Ebene 5 — "nur
// Betreiber", technisch getrennt vom Mandantenbereich, siehe CLAUDE.md).
// Ohne Sitzung geht es wie bei requirePage zu /login; mit einer
// Sitzung, aber ohne Betreiber-Rolle, gibt es 404 statt 403 — sonst
// würde eine 403 einem angemeldeten, aber unprivilegierten Nutzer
// verraten, dass unter dieser URL überhaupt etwas existiert.
func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := s.authenticate(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if user.Role != "betreiber" {
http.Error(w, "nicht gefunden", http.StatusNotFound)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
}
}
// requireFachebene schützt die Fachebene (Ebene 3 — Posteingang und
// Entscheiden, Rollen "verantwortlicher" und "pruefer", siehe
// CLAUDE.md). Beide Rollen sehen dieselbe Sicht; ob innerhalb der
// Seite tatsächlich entschieden werden darf, prüft der einzelne
// Handler (nur "verantwortlicher" hat Entscheidungsrecht). Wie bei
// requireBetreiber: 404 statt 403 bei falscher Rolle, damit ein
// angemeldeter, aber unprivilegierter Nutzer (z. B. "mitarbeiter")
// nicht erfährt, dass es die Seite überhaupt gibt.
func (s *Server) requireFachebene(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := s.authenticate(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if user.Role != "verantwortlicher" && user.Role != "pruefer" {
http.Error(w, "nicht gefunden", http.StatusNotFound)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
}
}
// 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
}
// navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage.
// Nur für Seiten hinter requirePage/requireBetreiber aufrufbar (braucht
// currentUser).
func navFor(r *http.Request) navData {
role := currentUser(r).Role
return navData{IsBetreiber: role == "betreiber", IsFachebene: role == "verantwortlicher" || role == "pruefer"}
}
// 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,
// kein Laufzeitfall, den man abfangen sollte.
func currentUser(r *http.Request) store.User {
user, ok := r.Context().Value(userContextKey).(store.User)
if !ok {
panic("web: currentUser aufgerufen ohne requirePage/requireAPI")
}
return user
}

122
internal/web/server.go Normal file
View File

@@ -0,0 +1,122 @@
// Package web ist die HTTP-Schicht: Routing, Templates, Handler.
// Bewusst html/template + htmx, kein Frontend-Build (siehe CLAUDE.md,
// 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
import (
"context"
"embed"
"fmt"
"html/template"
"net/http"
"time"
"github.com/netcell-it/deklarix/internal/rules"
"github.com/netcell-it/deklarix/internal/store"
)
//go:embed templates/*.html
var templatesFS embed.FS
//go:embed static/*
var staticFS embed.FS
// 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 {
CreateAccount(ctx context.Context, name string) (store.Account, error)
GetAccount(ctx context.Context, id string) (store.Account, error)
ListAccounts(ctx context.Context) ([]store.Account, error)
CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error)
GetUserByEmail(ctx context.Context, email string) (store.User, error)
GetUser(ctx context.Context, id string) (store.User, error)
ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error)
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)
CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (store.Antrag, error)
GetAntrag(ctx context.Context, id string) (store.Antrag, error)
UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (store.Antrag, error)
SetAntragStatus(ctx context.Context, id, status string) error
ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]store.Antrag, error)
ListAntraegeForAccount(ctx context.Context, accountID string) ([]store.Antrag, error)
ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]store.Werkzeug, error)
GetWerkzeug(ctx context.Context, id string) (store.Werkzeug, error)
CurrentKatalogVersion(ctx context.Context) (string, error)
CreateBewertung(ctx context.Context, in store.BewertungInput) (store.Bewertung, error)
GetLatestBewertungForAntrag(ctx context.Context, antragID string) (store.Bewertung, error)
CreateEntscheidung(ctx context.Context, in store.EntscheidungInput) (store.Entscheidung, error)
GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (store.Entscheidung, error)
}
// Regelwerk bündelt die drei geladenen Regelwerk-Dateien, die die
// Bewertung eines Antrags braucht (siehe internal/rules). Ein eigener
// Typ statt drei einzelne Server-Felder, damit main.go sie an einer
// Stelle zusammenstellt.
type Regelwerk struct {
Datenklasse rules.DatenklasseRegelwerk
Einstufung rules.EinstufungRegelwerk
Anforderungen rules.AnforderungsRegelwerk
}
// Server bündelt Routing und Abhängigkeiten der Web-Schicht.
type Server struct {
mux *http.ServeMux
store Store
regelwerk Regelwerk
templates *template.Template
}
// 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{
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 /login", s.handleLoginForm)
mux.HandleFunc("POST /login", s.handleLogin)
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /{$}", s.requirePage(s.handleIndex))
mux.HandleFunc("GET /antraege", s.requirePage(s.handleAntragList))
mux.HandleFunc("GET /antraege/neu", s.requirePage(s.handleAntragNewForm))
mux.HandleFunc("POST /antraege", s.requirePage(s.handleAntragCreate))
mux.HandleFunc("GET /antraege/{id}", s.requirePage(s.handleAntragDetail))
mux.HandleFunc("GET /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 /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.Handle("GET /static/", http.FileServerFS(staticFS))
s.mux = mux
return s, nil
}
// ServeHTTP macht Server zu einem http.Handler.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.mux.ServeHTTP(w, r)
}

757
internal/web/server_test.go Normal file
View File

@@ -0,0 +1,757 @@
package web_test
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/netcell-it/deklarix/internal/auth"
"github.com/netcell-it/deklarix/internal/rules"
"github.com/netcell-it/deklarix/internal/store"
"github.com/netcell-it/deklarix/internal/web"
)
const testSessionCookie = "deklarix_session"
// fakeStore ist eine In-Memory-Implementierung von web.Store, damit die
// Handler-Tests keine echte Postgres-Instanz brauchen.
type fakeStore struct {
mu sync.Mutex
nextID int
accounts map[string]store.Account
users map[string]store.User
usersByEmail map[string]string // email -> user id
sessions map[string]store.Session
auditLog []store.AuditEntry
abteilungen map[string][]store.Abteilung // accountID -> Abteilungen
antraege map[string]store.Antrag
werkzeuge map[string]store.Werkzeug
bewertungen map[string][]store.Bewertung // antragID -> Bewertungen, älteste zuerst
entscheidungen map[string][]store.Entscheidung // antragID -> Entscheidungen, älteste zuerst
}
func newFakeStore() *fakeStore {
return &fakeStore{
accounts: map[string]store.Account{},
users: map[string]store.User{},
usersByEmail: map[string]string{},
sessions: map[string]store.Session{},
abteilungen: map[string][]store.Abteilung{},
antraege: map[string]store.Antrag{},
werkzeuge: map[string]store.Werkzeug{},
bewertungen: map[string][]store.Bewertung{},
entscheidungen: map[string][]store.Entscheidung{},
}
}
func (f *fakeStore) newID() string {
f.nextID++
return fmt.Sprintf("id-%d", f.nextID)
}
func (f *fakeStore) CreateAccount(ctx context.Context, name string) (store.Account, error) {
f.mu.Lock()
defer f.mu.Unlock()
acc := store.Account{ID: f.newID(), Name: name, CreatedAt: time.Now()}
f.accounts[acc.ID] = acc
return acc, nil
}
func (f *fakeStore) GetAccount(ctx context.Context, id string) (store.Account, error) {
f.mu.Lock()
defer f.mu.Unlock()
acc, ok := f.accounts[id]
if !ok {
return store.Account{}, store.ErrNotFound
}
return acc, nil
}
func (f *fakeStore) ListAccounts(ctx context.Context) ([]store.Account, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.Account
for _, acc := range f.accounts {
out = append(out, acc)
}
return out, nil
}
func (f *fakeStore) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error) {
f.mu.Lock()
defer f.mu.Unlock()
if _, exists := f.usersByEmail[email]; exists {
return store.User{}, fmt.Errorf("fakeStore: email %s bereits vergeben", email)
}
u := store.User{
ID: f.newID(), AccountID: accountID, Email: email, PasswordHash: passwordHash,
Role: role, CreatedAt: time.Now(),
}
f.users[u.ID] = u
f.usersByEmail[email] = u.ID
return u, nil
}
func (f *fakeStore) GetUserByEmail(ctx context.Context, email string) (store.User, error) {
f.mu.Lock()
defer f.mu.Unlock()
id, ok := f.usersByEmail[email]
if !ok {
return store.User{}, store.ErrNotFound
}
return f.users[id], nil
}
func (f *fakeStore) GetUser(ctx context.Context, id string) (store.User, error) {
f.mu.Lock()
defer f.mu.Unlock()
u, ok := f.users[id]
if !ok {
return store.User{}, store.ErrNotFound
}
return u, nil
}
func (f *fakeStore) ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.User
for _, u := range f.users {
if u.AccountID == accountID {
out = append(out, u)
}
}
return out, nil
}
func (f *fakeStore) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error) {
f.mu.Lock()
defer f.mu.Unlock()
sess := store.Session{Token: token, UserID: userID, ExpiresAt: expiresAt, CreatedAt: time.Now()}
f.sessions[token] = sess
return sess, nil
}
func (f *fakeStore) GetSession(ctx context.Context, token string) (store.Session, error) {
f.mu.Lock()
defer f.mu.Unlock()
sess, ok := f.sessions[token]
if !ok {
return store.Session{}, store.ErrNotFound
}
return sess, nil
}
func (f *fakeStore) DeleteSession(ctx context.Context, token string) error {
f.mu.Lock()
defer f.mu.Unlock()
delete(f.sessions, token)
return nil
}
func (f *fakeStore) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error) {
f.mu.Lock()
defer f.mu.Unlock()
e := store.AuditEntry{
ID: f.newID(), ActorUserID: actorUserID, Action: action, TargetType: targetType,
TargetID: targetID, Details: details, CreatedAt: time.Now(),
}
f.auditLog = append(f.auditLog, e)
return e, nil
}
func (f *fakeStore) ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error) {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]store.AuditEntry, len(f.auditLog))
for i, e := range f.auditLog {
out[len(f.auditLog)-1-i] = e
}
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
func (f *fakeStore) ListAbteilungenForAccount(ctx context.Context, accountID string) ([]store.Abteilung, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.abteilungen[accountID], nil
}
func (f *fakeStore) CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (store.Antrag, error) {
f.mu.Lock()
defer f.mu.Unlock()
a := store.Antrag{
ID: f.newID(), AccountID: accountID, ErstellerUserID: erstellerUserID, AbteilungID: abteilungID,
Titel: titel, Status: "entwurf", Antworten: []byte(`{}`), CreatedAt: time.Now(), UpdatedAt: time.Now(),
}
f.antraege[a.ID] = a
return a, nil
}
func (f *fakeStore) GetAntrag(ctx context.Context, id string) (store.Antrag, error) {
f.mu.Lock()
defer f.mu.Unlock()
a, ok := f.antraege[id]
if !ok {
return store.Antrag{}, store.ErrNotFound
}
return a, nil
}
func (f *fakeStore) UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (store.Antrag, error) {
f.mu.Lock()
defer f.mu.Unlock()
a, ok := f.antraege[id]
if !ok {
return store.Antrag{}, store.ErrNotFound
}
a.Titel, a.Beschreibung, a.Ergebnis, a.Haeufigkeit, a.Antworten = titel, beschreibung, ergebnis, haeufigkeit, antworten
a.UpdatedAt = time.Now()
f.antraege[id] = a
return a, nil
}
func (f *fakeStore) SetAntragStatus(ctx context.Context, id, status string) error {
f.mu.Lock()
defer f.mu.Unlock()
a, ok := f.antraege[id]
if !ok {
return store.ErrNotFound
}
a.Status = status
f.antraege[id] = a
return nil
}
func (f *fakeStore) ListAntraegeForUser(ctx context.Context, erstellerUserID string) ([]store.Antrag, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.Antrag
for _, a := range f.antraege {
if a.ErstellerUserID == erstellerUserID {
out = append(out, a)
}
}
return out, nil
}
func (f *fakeStore) ListAntraegeForAccount(ctx context.Context, accountID string) ([]store.Antrag, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.Antrag
for _, a := range f.antraege {
if a.AccountID == accountID {
out = append(out, a)
}
}
return out, nil
}
func (f *fakeStore) ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]store.Werkzeug, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.Werkzeug
for _, w := range f.werkzeuge {
if w.AccountID == nil || *w.AccountID == accountID {
out = append(out, w)
}
}
return out, nil
}
func (f *fakeStore) GetWerkzeug(ctx context.Context, id string) (store.Werkzeug, error) {
f.mu.Lock()
defer f.mu.Unlock()
w, ok := f.werkzeuge[id]
if !ok {
return store.Werkzeug{}, store.ErrNotFound
}
return w, nil
}
func (f *fakeStore) CurrentKatalogVersion(ctx context.Context) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
return fmt.Sprintf("fake-%d", len(f.werkzeuge)), nil
}
func (f *fakeStore) CreateBewertung(ctx context.Context, in store.BewertungInput) (store.Bewertung, error) {
f.mu.Lock()
defer f.mu.Unlock()
b := store.Bewertung{
ID: f.newID(), AntragID: in.AntragID,
Datenklasse: in.Datenklasse, DatenklasseHerleitung: in.DatenklasseHerleitung,
Einstufung: in.Einstufung, EinstufungHerleitung: in.EinstufungHerleitung,
Verboten: in.Verboten, Anforderungen: in.Anforderungen,
ZulaessigeWerkzeuge: in.ZulaessigeWerkzeuge, AusgeschlosseneWerkzeuge: in.AusgeschlosseneWerkzeuge,
RegelwerkVersion: in.RegelwerkVersion, KatalogVersion: in.KatalogVersion,
CreatedAt: time.Now(),
}
f.bewertungen[in.AntragID] = append(f.bewertungen[in.AntragID], b)
return b, nil
}
func (f *fakeStore) GetLatestBewertungForAntrag(ctx context.Context, antragID string) (store.Bewertung, error) {
f.mu.Lock()
defer f.mu.Unlock()
bs := f.bewertungen[antragID]
if len(bs) == 0 {
return store.Bewertung{}, store.ErrNotFound
}
return bs[len(bs)-1], nil
}
func (f *fakeStore) CreateEntscheidung(ctx context.Context, in store.EntscheidungInput) (store.Entscheidung, error) {
f.mu.Lock()
defer f.mu.Unlock()
e := store.Entscheidung{
ID: f.newID(), AntragID: in.AntragID, BewertungID: in.BewertungID, EntscheiderUserID: in.EntscheiderUserID,
Entscheidung: in.Entscheidung, WerkzeugID: in.WerkzeugID, WerkzeugSnapshot: in.WerkzeugSnapshot,
Begruendung: in.Begruendung, GueltigBis: in.GueltigBis, CreatedAt: time.Now(),
}
f.entscheidungen[in.AntragID] = append(f.entscheidungen[in.AntragID], e)
return e, nil
}
func (f *fakeStore) GetLatestEntscheidungForAntrag(ctx context.Context, antragID string) (store.Entscheidung, error) {
f.mu.Lock()
defer f.mu.Unlock()
es := f.entscheidungen[antragID]
if len(es) == 0 {
return store.Entscheidung{}, store.ErrNotFound
}
return es[len(es)-1], nil
}
// ─── Test-Setup ───────────────────────────────────────────────────────
// loadTestRegelwerk lädt die echten rules/*.yaml-Dateien — dieselben,
// die auch main.go beim Start lädt. Handler-Tests laufen so gegen das
// tatsächliche Regelwerk statt gegen ein Test-Fixture, das getrennt
// von rules/ gepflegt werden müsste.
func loadTestRegelwerk(t *testing.T) web.Regelwerk {
t.Helper()
fsys := os.DirFS("../../rules")
dk, err := rules.LoadDatenklasse(fsys, "datenklasse.yaml")
if err != nil {
t.Fatalf("LoadDatenklasse: %v", err)
}
ei, err := rules.LoadEinstufung(fsys, "kivo_einstufung.yaml")
if err != nil {
t.Fatalf("LoadEinstufung: %v", err)
}
an, err := rules.LoadAnforderungen(fsys, "anforderungen.yaml")
if err != nil {
t.Fatalf("LoadAnforderungen: %v", err)
}
return web.Regelwerk{Datenklasse: dk, Einstufung: ei, Anforderungen: an}
}
func newServer(t *testing.T, fs *fakeStore) *web.Server {
t.Helper()
s, err := web.NewServer(fs, loadTestRegelwerk(t))
if err != nil {
t.Fatalf("NewServer: %v", err)
}
return s
}
// seedAccountWithRole legt direkt im fakeStore (ohne HTTP) einen
// Account, einen Nutzer und eine gültige Sitzung an und liefert das
// Session-Cookie.
func seedAccountWithRole(t *testing.T, fs *fakeStore, accountName, email, role string) *http.Cookie {
t.Helper()
ctx := context.Background()
acc, err := fs.CreateAccount(ctx, accountName)
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
hash, err := auth.HashPassword("test-passwort-123")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
user, err := fs.CreateUser(ctx, acc.ID, email, hash, role)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
token, err := auth.NewSessionToken()
if err != nil {
t.Fatalf("NewSessionToken: %v", err)
}
if _, err := fs.CreateSession(ctx, token, user.ID, time.Now().Add(time.Hour)); err != nil {
t.Fatalf("CreateSession: %v", err)
}
return &http.Cookie{Name: testSessionCookie, Value: token}
}
// seedUserInAccount legt einen weiteren Nutzer in einem bereits
// existierenden Account an — für Tests, die mehrere Rollen im selben
// Mandanten brauchen (z. B. mitarbeiter stellt einen Antrag,
// verantwortlicher entscheidet darüber).
func seedUserInAccount(t *testing.T, fs *fakeStore, accountID, email, role string) *http.Cookie {
t.Helper()
ctx := context.Background()
hash, err := auth.HashPassword("test-passwort-123")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
user, err := fs.CreateUser(ctx, accountID, email, hash, role)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
token, err := auth.NewSessionToken()
if err != nil {
t.Fatalf("NewSessionToken: %v", err)
}
if _, err := fs.CreateSession(ctx, token, user.ID, time.Now().Add(time.Hour)); err != nil {
t.Fatalf("CreateSession: %v", err)
}
return &http.Cookie{Name: testSessionCookie, Value: token}
}
func seedAccount(t *testing.T, fs *fakeStore, accountName, email string) *http.Cookie {
t.Helper()
return seedAccountWithRole(t, fs, accountName, email, "mitarbeiter")
}
func newAuthedTestServer(t *testing.T) (*web.Server, *fakeStore, *http.Cookie) {
t.Helper()
fs := newFakeStore()
s := newServer(t, fs)
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
return s, fs, cookie
}
func postForm(t *testing.T, s *web.Server, cookie *http.Cookie, path string, form url.Values) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if cookie != nil {
req.AddCookie(cookie)
}
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
return w
}
func getWithCookie(t *testing.T, s *web.Server, cookie *http.Cookie, path string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
if cookie != nil {
req.AddCookie(cookie)
}
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
return w
}
// ─── Tests: Health, statische Assets (kein Auth nötig) ────────────────
func TestHandleHealth(t *testing.T) {
s, _, _ := newAuthedTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/health", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if w.Body.String() != `{"ok":true}` {
t.Fatalf("body = %q, want {\"ok\":true}", w.Body.String())
}
}
func TestHandleStaticServesHTMX(t *testing.T) {
s, _, _ := newAuthedTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/static/htmx.min.js", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if w.Body.Len() < 1000 {
t.Fatalf("htmx.min.js suspiciously small: %d bytes", w.Body.Len())
}
}
// ─── Tests: Auth (Registrierung, Login, Logout, Zugriffsschutz) ───────
func TestRegisterThenLoginThenAccessProtectedPage(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
regResp := postForm(t, s, nil, "/register", url.Values{
"account_name": {"Meine Firma"}, "email": {"neu@example.com"},
"password": {"ein-sicheres-passwort"},
})
if regResp.Code != http.StatusSeeOther {
t.Fatalf("register status = %d, want 303, body: %s", regResp.Code, regResp.Body.String())
}
cookies := regResp.Result().Cookies()
if len(cookies) == 0 {
t.Fatal("expected a session cookie to be set after registration")
}
indexResp := getWithCookie(t, s, cookies[0], "/")
if indexResp.Code != http.StatusOK {
t.Fatalf("index status with fresh session = %d, want 200", indexResp.Code)
}
var created store.User
for _, u := range fs.users {
created = u
}
if created.Role != "admin" {
t.Fatalf("expected the first user of a new company to have role admin, got %q", created.Role)
}
}
func TestRegisterRejectsDuplicateEmail(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
form := url.Values{
"account_name": {"A"}, "email": {"doppelt@example.com"},
"password": {"ein-sicheres-passwort"},
}
if resp := postForm(t, s, nil, "/register", form); resp.Code != http.StatusSeeOther {
t.Fatalf("first register status = %d, want 303", resp.Code)
}
resp := postForm(t, s, nil, "/register", form)
if resp.Code != http.StatusOK {
t.Fatalf("second register status = %d, want 200 (re-rendered form with error)", resp.Code)
}
if !strings.Contains(resp.Body.String(), "schon vergeben") {
t.Errorf("expected a duplicate-email error, got: %s", resp.Body.String())
}
}
func TestLoginWithCorrectPassword(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
hash, err := auth.HashPassword("richtiges-passwort")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
acc, err := fs.CreateAccount(context.Background(), "Bestehender Mandant")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if _, err := fs.CreateUser(context.Background(), acc.ID, "bestehend@example.com", hash, "mitarbeiter"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
resp := postForm(t, s, nil, "/login", url.Values{"email": {"bestehend@example.com"}, "password": {"richtiges-passwort"}})
if resp.Code != http.StatusSeeOther {
t.Fatalf("login status = %d, want 303, body: %s", resp.Code, resp.Body.String())
}
if len(resp.Result().Cookies()) == 0 {
t.Fatal("expected a session cookie after successful login")
}
}
func TestLoginRedirectsBetreiberToPlatformDashboard(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
hash, err := auth.HashPassword("admin-passwort")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
acc, err := fs.CreateAccount(context.Background(), "Deklarix Betreiber")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if _, err := fs.CreateUser(context.Background(), acc.ID, "betreiber@example.com", hash, "betreiber"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
resp := postForm(t, s, nil, "/login", url.Values{"email": {"betreiber@example.com"}, "password": {"admin-passwort"}})
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
}
if loc := resp.Header().Get("Location"); loc != "/betreiber" {
t.Fatalf("Location = %q, want /betreiber", loc)
}
}
func TestLoginRejectsWrongPassword(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
hash, err := auth.HashPassword("richtiges-passwort")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
acc, _ := fs.CreateAccount(context.Background(), "X")
if _, err := fs.CreateUser(context.Background(), acc.ID, "x@example.com", hash, "mitarbeiter"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
resp := postForm(t, s, nil, "/login", url.Values{"email": {"x@example.com"}, "password": {"falsch"}})
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (re-rendered login form)", resp.Code)
}
if len(resp.Result().Cookies()) != 0 {
t.Fatal("expected no session cookie for a failed login")
}
}
func TestLoginRejectsUnknownEmailWithSameMessageAsWrongPassword(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
resp := postForm(t, s, nil, "/login", url.Values{"email": {"gibtsnicht@example.com"}, "password": {"irgendwas"}})
if !strings.Contains(resp.Body.String(), "E-Mail oder Passwort falsch") {
t.Errorf("expected the generic invalid-credentials message, got: %s", resp.Body.String())
}
}
func TestIndexRedirectsToLoginWithoutSession(t *testing.T) {
s, _, _ := newAuthedTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303 redirect to /login", w.Code)
}
if loc := w.Header().Get("Location"); loc != "/login" {
t.Fatalf("Location = %q, want /login", loc)
}
}
func TestLogoutClearsSession(t *testing.T) {
s, fs, cookie := newAuthedTestServer(t)
if resp := getWithCookie(t, s, cookie, "/"); resp.Code != http.StatusOK {
t.Fatalf("index before logout = %d, want 200", resp.Code)
}
logoutResp := postForm(t, s, cookie, "/logout", url.Values{})
if logoutResp.Code != http.StatusSeeOther {
t.Fatalf("logout status = %d, want 303", logoutResp.Code)
}
if _, err := fs.GetSession(context.Background(), cookie.Value); !errors.Is(err, store.ErrNotFound) {
t.Fatal("expected the session to be deleted from the store after logout")
}
if resp := getWithCookie(t, s, cookie, "/"); resp.Code != http.StatusSeeOther {
t.Fatalf("index after logout = %d, want 303 redirect (session no longer valid)", resp.Code)
}
}
func TestHandleIndexRendersForMitarbeiter(t *testing.T) {
s, _, cookie := newAuthedTestServer(t)
resp := getWithCookie(t, s, cookie, "/")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.Code)
}
}
// ─── Tests: Plattform-Bereich (nur Betreiber) ─────────────────────────
func TestBetreiberRoutesRejectNonBetreiberWith404(t *testing.T) {
s, _, cookie := newAuthedTestServer(t)
for _, path := range []string{"/betreiber", "/betreiber/accounts", "/betreiber/audit-log"} {
resp := getWithCookie(t, s, cookie, path)
if resp.Code != http.StatusNotFound {
t.Errorf("GET %s status = %d, want 404 for a non-betreiber user", path, resp.Code)
}
}
}
func TestBetreiberRoutesRedirectToLoginWithoutSession(t *testing.T) {
s, _, _ := newAuthedTestServer(t)
resp := getWithCookie(t, s, nil, "/betreiber")
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303 redirect to /login", resp.Code)
}
}
func TestBetreiberDashboardAccessibleForBetreiber(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
resp := getWithCookie(t, s, betreiberCookie, "/betreiber")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", resp.Code, resp.Body.String())
}
}
func TestBetreiberAccountListShowsAllAccountsAcrossTenants(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
seedAccount(t, fs, "Mandant A", "a@example.com")
seedAccount(t, fs, "Mandant B", "b@example.com")
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/accounts")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
body := resp.Body.String()
for _, want := range []string{"Mandant A", "Mandant B", "Deklarix Betreiber"} {
if !strings.Contains(body, want) {
t.Errorf("expected %q in the betreiber account list, got: %s", want, body)
}
}
}
func TestBetreiberAccountDetailShowsUsers(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
seedAccount(t, fs, "Mandant A", "mitarbeiter-a@example.com")
var accID string
for id, acc := range fs.accounts {
if acc.Name == "Mandant A" {
accID = id
}
}
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/accounts/"+accID)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "mitarbeiter-a@example.com") {
t.Errorf("expected the user's email on the account detail page, got: %s", resp.Body.String())
}
}
func TestBetreiberAuditLogShowsEntries(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
betreiber, err := fs.GetUserByEmail(context.Background(), "betreiber@example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
if _, err := fs.CreateAuditEntry(context.Background(), betreiber.ID, "werkzeug.aktualisiert", "werkzeug", "w1", "manuell geprüft"); err != nil {
t.Fatalf("CreateAuditEntry: %v", err)
}
resp := getWithCookie(t, s, betreiberCookie, "/betreiber/audit-log")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), "werkzeug.aktualisiert") {
t.Errorf("expected the audit entry on the audit log page, got: %s", resp.Body.String())
}
}

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

@@ -0,0 +1,437 @@
/* 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. */
@import url('/static/inter.css');
:root {
--branding-primary: #1677ff;
--radius: 6px;
--radius-md: 8px;
--radius-lg: 10px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--color-bg: #f8fafc;
--color-text: #334155;
--color-heading: #0f172a;
--color-border: #e2e8f0;
--color-muted: #64748b;
--color-hoch: #b91c1c;
--color-hoch-bg: #fef2f2;
--color-mittel: #b45309;
--color-mittel-bg: #fffbeb;
--color-niedrig: #166534;
--color-niedrig-bg: #f0fdf4;
}
* {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--color-bg);
color: var(--color-text);
font-size: 16px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 {
color: var(--color-heading);
font-weight: 600;
letter-spacing: -0.02em;
margin: 0 0 0.5em;
}
h1 {
font-size: 1.5rem;
}
p {
margin: 0 0 1em;
}
a {
color: var(--branding-primary);
}
/* Container: volle Breite + Innenabstand auf dem Handy, zentriert mit
fester Breite ab Tablet-Größe aufwärts. */
.page {
padding: 16px;
max-width: 640px;
margin: 0 auto;
}
nav {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16px;
padding: 12px 16px;
}
nav a {
color: var(--color-muted);
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
}
nav a:hover {
color: var(--color-text);
}
/* Formulare: großzügige Touch-Ziele (min. 44px Höhe), volle Breite auf
dem Handy. */
form {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 24px;
}
label {
font-weight: 500;
font-size: 0.875rem;
color: var(--color-heading);
margin-top: 12px;
}
input, select, textarea, button {
font: inherit;
font-size: 16px; /* verhindert Auto-Zoom beim Fokussieren auf iOS */
}
input, select, textarea {
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: #fff;
color: var(--color-text);
min-height: 44px;
width: 100%;
}
textarea {
min-height: 120px;
resize: vertical;
}
input:focus, select:focus, textarea:focus {
outline: 2px solid var(--branding-primary);
outline-offset: 1px;
border-color: var(--branding-primary);
}
button {
margin-top: 16px;
padding: 12px 20px;
min-height: 44px;
border: none;
border-radius: var(--radius);
background: var(--branding-primary);
color: #fff;
font-weight: 500;
cursor: pointer;
box-shadow: var(--shadow-sm);
}
button:hover {
filter: brightness(0.94);
}
button:active {
filter: brightness(0.88);
}
nav button {
margin-top: 0;
background: transparent;
color: var(--color-muted);
border: 1px solid var(--color-border);
box-shadow: none;
min-height: 36px;
padding: 6px 14px;
font-size: 0.875rem;
}
.hinweis {
font-size: 0.8125rem;
color: var(--color-muted);
margin-top: 4px;
}
.disclaimer {
font-size: 0.8125rem;
color: var(--color-muted);
border-top: 1px solid var(--color-border);
padding-top: 12px;
margin-top: 24px;
}
.fehler {
background: var(--color-hoch-bg);
color: var(--color-hoch);
border-radius: var(--radius);
padding: 12px;
font-size: 0.9375rem;
}
.rueckfrage {
background: var(--color-mittel-bg);
color: var(--color-mittel);
border-radius: var(--radius);
padding: 12px;
}
.keine-findings {
background: var(--color-niedrig-bg);
color: var(--color-niedrig);
border-radius: var(--radius);
padding: 12px;
}
.archiviert {
background: var(--color-niedrig-bg);
color: var(--color-niedrig);
border-radius: var(--radius);
padding: 12px;
}
.findings {
list-style: none;
margin: 0 0 16px;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.finding {
border-radius: var(--radius-md);
padding: 12px 14px;
box-shadow: var(--shadow);
}
.finding p {
margin: 6px 0 0;
font-size: 0.9375rem;
}
.finding-hoch {
background: var(--color-hoch-bg);
border-left: 4px solid var(--color-hoch);
}
.finding-mittel {
background: var(--color-mittel-bg);
border-left: 4px solid var(--color-mittel);
}
.finding-niedrig {
background: var(--color-niedrig-bg);
border-left: 4px solid var(--color-niedrig);
}
.status {
display: inline-block;
font-size: 0.75rem;
font-weight: 500;
padding: 2px 8px;
border-radius: var(--radius);
background: var(--color-border);
color: var(--color-muted);
}
.status-published {
background: var(--color-niedrig-bg);
color: var(--color-niedrig);
}
.status-checked,
.status-mittel {
background: var(--color-mittel-bg);
color: var(--color-mittel);
}
.beitraege-liste {
list-style: none;
margin: 0 0 16px;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.beitraege-liste li a {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 12px 14px;
border-radius: var(--radius-md);
box-shadow: var(--shadow);
background: #fff;
color: var(--color-text);
text-decoration: none;
}
.beteiligte {
list-style: none;
margin: 0 0 16px;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.beteiligter {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: var(--radius-md);
box-shadow: var(--shadow);
background: #fff;
}
.beteiligter-kopf {
flex: 1 1 100%;
}
.beteiligter .rolle {
color: var(--color-muted);
font-weight: 400;
}
.beteiligter-form {
flex-direction: row;
align-items: center;
gap: 12px;
margin: 0;
flex: 1 1 auto;
}
.beteiligter-form label {
display: flex;
align-items: center;
gap: 4px;
font-weight: 400;
margin: 0;
}
.beteiligter-form input[type="checkbox"] {
width: auto;
min-height: 0;
}
.beteiligter form:last-child {
margin: 0;
}
button.entfernen {
margin: 0;
background: transparent;
color: var(--color-hoch);
border: 1px solid var(--color-hoch-bg);
box-shadow: none;
min-height: 36px;
padding: 6px 14px;
font-size: 0.875rem;
}
.admin-kacheln {
list-style: none;
margin: 0 0 16px;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.admin-kacheln a {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 14px 16px;
border-radius: var(--radius-md);
box-shadow: var(--shadow);
background: #fff;
color: var(--color-text);
text-decoration: none;
font-weight: 500;
}
.kanzlei-eintrag {
display: block;
padding: 12px 14px;
border-radius: var(--radius-md);
box-shadow: var(--shadow);
background: #fff;
}
.fragebogen fieldset {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 12px 14px;
margin: 12px 0;
}
.fragebogen legend {
font-weight: 500;
font-size: 0.9375rem;
padding: 0 4px;
}
.fragebogen fieldset label {
display: inline-flex;
align-items: center;
gap: 4px;
font-weight: 400;
margin: 4px 12px 4px 0;
}
.fragebogen fieldset input[type="radio"] {
width: auto;
min-height: 0;
}
/* Adaptive Folgefragen: rein clientseitig per :has() ein-/ausgeblendet,
kein JavaScript nötig. Siehe templates/antrag_neu.html — die "Ja"-
Antwort trägt eine eigene Klasse (c2-ja/c3-ja), damit sich die
jeweilige Folgefrage gezielt zuordnen lässt. */
.mit-folgefrage .folgefrage {
display: none;
margin-top: 8px;
}
.mit-folgefrage:has(.c2-ja input:checked) .folgefrage,
.mit-folgefrage:has(.c3-ja input:checked) .folgefrage {
display: block;
}
/* Ab hier mehr Platz (Tablet/Desktop) — der Container bekommt spürbaren
Rand statt volle Breite, sonst bleibt alles identisch. */
@media (min-width: 640px) {
.page {
padding: 32px 24px;
}
h1 {
font-size: 1.75rem;
}
}

Binary file not shown.

1
internal/web/static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
{{define "betreiber-dashboard"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<h1>Plattform</h1>
<ul class="admin-kacheln">
<li><a href="/betreiber/accounts">Accounts <span class="status">{{.AccountCount}}</span></a></li>
<li><a href="/betreiber/audit-log">Audit-Log <span class="status">{{.RecentAuditCount}} neueste</span></a></li>
</ul>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,24 @@
{{define "faelle-liste"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<h1>Posteingang</h1>
{{if not .Faelle}}
<p class="keine-findings">Keine offenen Anträge — alles bearbeitet.</p>
{{else}}
<ul class="beitraege-liste">
{{range .Faelle}}
<li>
<a href="/faelle/{{.ID}}">
<strong>{{.Titel}}</strong> · {{.Haeufigkeit}} · eingereicht am {{.CreatedAt}}
</a>
</li>
{{end}}
</ul>
{{end}}
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,60 @@
{{define "fall-detail"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<p><a href="/faelle">&larr; Posteingang</a></p>
<h1>{{.Titel}}</h1>
<p class="hinweis">Eingereicht am {{.CreatedAt}} — Status: <span class="status status-{{.Status}}">{{.Status}}</span></p>
<h2>Vorhaben</h2>
<p><strong>Was soll die KI tun?</strong><br>{{.Beschreibung}}</p>
<p><strong>Was soll herauskommen?</strong><br>{{.Ergebnis}}</p>
<p><strong>Häufigkeit:</strong> {{.Haeufigkeit}}</p>
{{if .Bewertung}}
{{template "bewertung-block" .Bewertung}}
{{else}}
<p class="hinweis">Für diesen Antrag liegt noch keine Bewertung vor.</p>
{{end}}
{{if .Entscheidung}}
<h2>Entscheidung</h2>
<p><strong>{{.Entscheidung.Entscheidung}}</strong>{{if .Entscheidung.WerkzeugName}} — {{.Entscheidung.WerkzeugName}}{{end}}<br>
<span class="hinweis">am {{.Entscheidung.CreatedAt}}{{if .Entscheidung.GueltigBis}}, gültig bis {{.Entscheidung.GueltigBis}}{{end}}</span></p>
{{if .Entscheidung.Begruendung}}<p>{{.Entscheidung.Begruendung}}</p>{{end}}
{{else if .CanEntscheiden}}
<h2>Entscheiden</h2>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<form method="post" action="/faelle/{{.ID}}/entscheiden">
<fieldset>
<legend>Entscheidung</legend>
<label><input type="radio" name="entscheidung" value="genehmigt" required> Genehmigt</label>
<label><input type="radio" name="entscheidung" value="genehmigt_mit_auflagen"> Genehmigt mit Auflagen</label>
<label><input type="radio" name="entscheidung" value="abgelehnt"> Abgelehnt</label>
<label><input type="radio" name="entscheidung" value="rueckfrage"> Rückfrage</label>
</fieldset>
{{if .WerkzeugOptionen}}
<label>Werkzeug (bei Genehmigung erforderlich)
<select name="werkzeug_id">
<option value="">— auswählen —</option>
{{range .WerkzeugOptionen}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
</label>
{{end}}
<label>Begründung (Pflicht bei Abweichung vom Vorschlag)
<textarea name="begruendung" rows="3"></textarea>
</label>
<button type="submit">Entscheidung speichern</button>
</form>
{{else}}
<p class="hinweis">Noch keine Entscheidung getroffen. Nur die Rolle "Verantwortlicher" kann entscheiden.</p>
{{end}}
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,15 @@
{{define "index"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<h1>Willkommen bei Deklarix</h1>
<ul class="admin-kacheln">
<li><a href="/antraege/neu">Antrag stellen</a></li>
<li><a href="/antraege">Meine Anträge</a></li>
</ul>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,81 @@
{{define "head"}}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} — Deklarix</title>
<link rel="stylesheet" href="/static/app.css">
<script src="/static/htmx.min.js"></script>
{{end}}
{{define "nav"}}
<nav>
<a href="/">Start</a>
{{if not .IsBetreiber}}<a href="/antraege">Meine Anträge</a>{{end}}
{{if .IsFachebene}}<a href="/faelle">Posteingang</a>{{end}}
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
<form method="post" action="/logout" style="display:inline">
<button type="submit">Abmelden</button>
</form>
</nav>
{{end}}
{{/* bewertung-block rendert eine Bewertung (Datenklasse, KI-VO-
Einstufung, Anforderungen, Werkzeuge, Pflicht-Disclaimer) — gemeinsam
genutzt von antrag_detail.html (Ebene 2, nur Ansicht) und
fall_detail.html (Ebene 3, mit Entscheiden-Formular). Erwartet eine
*bewertungView als . */}}
{{define "bewertung-block"}}
<h2>Bewertung</h2>
{{if .Verboten}}
<p class="fehler"><strong>Verboten.</strong> Diese Nutzung fällt laut Einstufung unter ein verbotenes KI-Praktiken-Muster der KI-VO ({{.Einstufung}}). {{.EinstufungHerleitung}}</p>
{{end}}
<p><strong>Datenklasse:</strong> {{.Datenklasse}}<br>
<span class="hinweis">{{.DatenklasseHerleitung}}</span></p>
<p><strong>KI-VO-Einstufung:</strong> {{.Einstufung}}<br>
<span class="hinweis">{{.EinstufungHerleitung}}</span></p>
{{if .Anforderungen}}
<h3>Anforderungen</h3>
<ul class="findings">
{{range .Anforderungen}}
<li class="finding finding-mittel">
<strong>{{.Beschreibung}}</strong>
<p>{{.Herleitung}}</p>
</li>
{{end}}
</ul>
{{end}}
{{if not .Verboten}}
<h3>Zulässige Werkzeuge</h3>
{{if .ZulaessigeWerkzeuge}}
<ul>
{{range .ZulaessigeWerkzeuge}}<li>{{.}}</li>{{end}}
</ul>
{{else}}
<p class="rueckfrage">Kein Werkzeug im Katalog erfüllt aktuell alle Anforderungen dieses Antrags.</p>
{{end}}
{{if .AusgeschlosseneWerkzeuge}}
<h3>Ausgeschlossene Werkzeuge</h3>
<ul class="findings">
{{range .AusgeschlosseneWerkzeuge}}
<li class="finding finding-niedrig">
<strong>{{.Name}}</strong>
<p>Nicht erfüllt: {{range $i, $a := .NichtErfuellt}}{{if $i}}, {{end}}{{$a}}{{end}}</p>
</li>
{{end}}
</ul>
{{end}}
{{end}}
<p class="disclaimer">
Das System entscheidet nicht. Es bereitet vor. Diese Einstufung ist ein
Vorschlag zur Herleitung von Datenklasse, KI-VO-Einstufung und
Anforderungen (Regelwerk {{.RegelwerkVersion}}, Katalog
{{.KatalogVersion}}, berechnet am {{.CreatedAt}}) —
die Entscheidung trifft ein Mensch.
</p>
{{end}}

View File

@@ -0,0 +1,21 @@
{{define "login"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
<div class="page">
<h1>Anmelden</h1>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<form method="post" action="/login">
<label for="email">E-Mail</label>
<input type="email" id="email" name="email" required>
<label for="password">Passwort</label>
<input type="password" id="password" name="password" required>
<button type="submit">Anmelden</button>
</form>
<p><a href="/register">Noch kein Konto? Registrieren</a></p>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,28 @@
{{define "register"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
<div class="page">
<h1>Firma registrieren</h1>
<p class="hinweis">
Testzugang ist sofort aktiv. Der Bezahlbetrieb wird nach Prüfung durch
den Betreiber freigeschaltet.
</p>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<form method="post" action="/register">
<label for="account_name">Firmenname</label>
<input type="text" id="account_name" name="account_name" required>
<label for="email">E-Mail (erster Nutzer, wird Admin)</label>
<input type="email" id="email" name="email" required>
<label for="password">Passwort (mind. 8 Zeichen)</label>
<input type="password" id="password" name="password" minlength="8" required>
<button type="submit">Konto anlegen</button>
</form>
<p><a href="/login">Schon ein Konto? Anmelden</a></p>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1 @@
/etc/deklarix/deklarix.env.example

View File

@@ -2,6 +2,10 @@ Package: deklarix
Version: VERSION
Architecture: ARCH
Maintainer: NetCell IT <team@netcell-it.de>
Description: Deklarix — deklarix.de / deklarix.com
Deklarix Software Package.
Depends:
Homepage: https://deklarix.de
Section: web
Priority: optional
Depends: ca-certificates
Description: Deklarix — Prüfung und Nachweis für Werbe-Content
Pre-Publish-Kennzeichnungsprüfung und revisionssichere Archivierung
veröffentlichter Beiträge für Creator, Agenturen, Marken und Kanzleien.

79
packaging/DEBIAN/postinst Executable file
View File

@@ -0,0 +1,79 @@
#!/bin/bash
# Deklarix — postinst
set -e
SERVICE_USER="deklarix"
CONFIG_DIR="/etc/deklarix"
DATA_DIR="/var/lib/deklarix"
LOG_DIR="/var/log/deklarix"
case "$1" in
configure)
# ─── apt-Quelle einrichten/aktualisieren ────────────────────
# Gleiches Muster wie enconf: Gitea-Debian-Registry, Suite =
# OS-Codename, Komponente = Kanal (aktuell nur "testing"). Ein
# bereits gewählter Kanal bleibt über Upgrades hinweg erhalten.
mkdir -p /etc/apt/keyrings
curl -fsSL "https://git.netcell-it.de/api/packages/projekte/debian/repository.key" \
-o /etc/apt/keyrings/deklarix-gitea.asc 2>/dev/null || true
if [ -f /etc/apt/keyrings/deklarix-gitea.asc ]; then
DX_CODENAME="$(. /etc/os-release 2>/dev/null; echo "${VERSION_CODENAME:-}")"
case "$DX_CODENAME" in bookworm|trixie) ;; *) DX_CODENAME="trixie" ;; esac
DX_CHANNEL="$(grep -oE 'projekte/debian[[:space:]]+[a-z]+[[:space:]]+(stable|testing)' \
/etc/apt/sources.list.d/deklarix.list 2>/dev/null | awk '{print $NF}' | head -1)"
case "$DX_CHANNEL" in stable|testing) ;; *) DX_CHANNEL="testing" ;; esac
echo "deb [signed-by=/etc/apt/keyrings/deklarix-gitea.asc] https://git.netcell-it.de/api/packages/projekte/debian $DX_CODENAME $DX_CHANNEL" \
> /etc/apt/sources.list.d/deklarix.list
fi
# ─── System-User + Verzeichnisse ────────────────────────────
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
useradd -r -s /usr/sbin/nologin -d "$DATA_DIR" "$SERVICE_USER"
fi
mkdir -p "$DATA_DIR" "$LOG_DIR" "$CONFIG_DIR"
chown "$SERVICE_USER:$SERVICE_USER" "$DATA_DIR" "$LOG_DIR"
# ─── Konfiguration ───────────────────────────────────────────
# Erstinstall: Vorlage kopieren, nicht scharf schalten (Secrets
# fehlen noch). Upgrade: bestehende deklarix.env bleibt unberührt.
if [ ! -f "$CONFIG_DIR/deklarix.env" ]; then
cp "$CONFIG_DIR/deklarix.env.example" "$CONFIG_DIR/deklarix.env"
chmod 640 "$CONFIG_DIR/deklarix.env"
chown "root:$SERVICE_USER" "$CONFIG_DIR/deklarix.env"
fi
# ─── Healing: fehlende Variablen aus älteren Installationen ───
# deklarix.env wird bei Upgrades nie überschrieben (Secrets/
# Anpassungen bleiben erhalten) — Variablen, die erst nach einer
# Erstinstallation dazugekommen sind, fehlen dort sonst schlicht
# und lassen den Dienst mit einem für den Admin überraschenden
# Fehler abbrechen (z. B. RULES_DIR fehlt -> main.go sucht Regeln
# im falschen Verzeichnis, obwohl DATABASE_URL längst gesetzt
# war). Bekannte Variablen mit unkritischem Default werden hier
# ergänzt, falls sie fehlen — nie überschrieben, nur angehängt.
if [ -f "$CONFIG_DIR/deklarix.env" ]; then
grep -q '^RULES_DIR=' "$CONFIG_DIR/deklarix.env" || \
echo "RULES_DIR=/usr/share/deklarix/rules" >> "$CONFIG_DIR/deklarix.env"
fi
systemctl daemon-reload 2>/dev/null || true
systemctl enable deklarix.service >/dev/null 2>&1 || true
# Nicht blind starten — ohne gesetzte DATABASE_URL würde der Dienst
# nur in eine Restart-Schleife laufen (main.go bricht sonst bewusst
# mit log.Fatal ab, siehe CLAUDE.md "keine stillen Fallbacks"). Die
# Vorlage liefert sie auskommentiert aus — ein Treffer hier bedeutet
# also wirklich "vom Admin gesetzt", nicht den Platzhalter.
if grep -qE '^DATABASE_URL=.+' "$CONFIG_DIR/deklarix.env" 2>/dev/null; then
systemctl restart deklarix.service
else
echo ""
echo " → Deklarix installiert, aber noch nicht gestartet."
echo " DATABASE_URL in $CONFIG_DIR/deklarix.env setzen, dann:"
echo " systemctl start deklarix"
echo ""
fi
;;
esac
exit 0

11
packaging/DEBIAN/prerm Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/bash
# Deklarix — prerm
set -e
case "$1" in
remove|deconfigure)
systemctl stop deklarix.service 2>/dev/null || true
;;
esac
exit 0

View File

@@ -0,0 +1,17 @@
# Deklarix — Konfiguration
# Wird bei der Erstinstallation nach /etc/deklarix/deklarix.env kopiert.
# Diese Datei selbst wird von apt-Updates verwaltet — Anpassungen gehören
# in deklarix.env, nicht hierhin.
PORT=8080
# Wo internal/rules das Bewertungsregelwerk (Datenklasse, KI-VO-
# Einstufung, Anforderungsprofil) findet — vom .deb-Paket nach
# /usr/share/deklarix/rules installiert (siehe scripts/build.sh).
RULES_DIR=/usr/share/deklarix/rules
# Pflicht — der Dienst startet nicht ohne gültige DATABASE_URL.
# Auskommentiert lassen, bis ein echter Wert eingetragen ist: postinst
# prüft genau diese Zeile, um den Dienst nicht blind in eine Restart-
# Schleife gegen einen Platzhalter laufen zu lassen.
#DATABASE_URL=postgres://user:password@host:5432/deklarix?sslmode=require

View File

@@ -0,0 +1,23 @@
[Unit]
Description=Deklarix
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=deklarix
Group=deklarix
EnvironmentFile=/etc/deklarix/deklarix.env
ExecStart=/usr/bin/deklarix
WorkingDirectory=/var/lib/deklarix
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/deklarix /var/log/deklarix
[Install]
WantedBy=multi-user.target

85
rules/OPEN.md Normal file
View File

@@ -0,0 +1,85 @@
# Offene fachliche Fragen zum Regelwerk
Diese Punkte wurden beim Anlegen der Regelwerk-YAML-Dateien (Phase 1)
bewusst nicht geraten, sondern hier notiert — vor Phase 3 (Ableitungen
und harte Filter) mit dem fachlich Verantwortlichen (z. B. der/dem
betrieblichen Datenschutzbeauftragten) klären.
## 1. Rangfolge der Datenklassen
Die Spezifikation sagt "höchste zutreffende Stufe gewinnt", gibt aber
keine expliziten Rangzahlen vor. `rules/datenklasse.yaml` nimmt aktuell
diese Reihenfolge an (niedrigster zu höchstem Rang):
1. `oeffentlich`
2. `intern`
3. `auftragsdaten`
4. `personenbezogen`
5. `berufsgeheimnis`
6. `besondere_kategorie`
Begründung der Annahme: DSGVO Art. 9 (besondere Kategorien) gilt
allgemein als striktester Datenschutz-Tatbestand, § 203 StGB
(Berufsgeheimnis) hat eigene strafrechtliche Relevanz — beide vor
"normalen" personenbezogenen Daten eingeordnet. `auftragsdaten` unter
`personenbezogen` einsortiert, weil vertragliche Geheimhaltung in der
Regel schwächer sanktioniert ist als DSGVO-Bußgelder. **Nicht
bestätigt.**
## 2. Anforderung `kein_training_auf_eingabe` und `auftragsdaten`
Die Spezifikation formuliert "aus intern, personenbezogen und höher" —
das lässt offen, ob `auftragsdaten` (zwischen `intern` und
`personenbezogen` einsortiert, siehe Punkt 1) eingeschlossen sein soll.
`rules/anforderungen.yaml` schließt `auftragsdaten` aktuell explizit
ein (Kundendaten sollten aus denselben Gründen wie Geschäftsgeheimnisse
nicht zum Training verwendet werden) — **Annahme, nicht bestätigt.**
## 3. Genaue Fragebogen-Fakten für "verboten" (KI-VO Art. 5) — ERLEDIGT
~~Fragebogen-Abschnitt C stellt nur eine einzelne Frage...~~ Mit Phase 2
(Fragebogen, `internal/web/templates/antrag_neu.html`) gelöst: C3 hat
jetzt eine Folgefrage `c3_art` mit den Werten
`emotionserkennung_arbeitsplatz`, `social_scoring`,
`biometrische_kategorisierung` und `keine` — exakt die Werte, die
`rules/kivo_einstufung.yaml` bereits erwartete. Feldnamen sind damit
final, nicht mehr Platzhalter. **Weiterhin offen:** ob diese drei
Varianten fachlich vollständig sind (deckt das wirklich alle "verboten"-
Fälle aus Art. 5 KI-VO ab?) — das war nie Teil dieser Klärung.
## 4. `loeschfrist_max_tage` — konkrete Fristen je Datenklasse
Die Spezifikation nennt `loeschfrist_max_tage` als Anforderung "aus
Datenklasse", ohne die konkreten Tageswerte pro Stufe zu nennen.
`rules/anforderungen.yaml` listet die Anforderung nur als grundsätzlich
anwendbar (welche Datenklassen sie überhaupt auslösen), ohne
Zahlenwerte — die eigentlichen Fristen (z. B. "personenbezogen: 90
Tage") müssen vor Phase 3 fachlich festgelegt werden. **Auswirkung auf
Schritt 3 (`internal/rules.ErfuelltAnforderung`):** ohne Zahlenwerte
wird diese Anforderung aktuell NICHT hart gegen Werkzeuge gefiltert
(jedes Werkzeug gilt hier als "erfüllt") — sobald Fristen feststehen,
muss die Filterfunktion entsprechend erweitert werden.
## 5. Fallback, wenn keine Datenklasse zutrifft
Wenn ein Antrag bei allen B-Fragen "nein" beantwortet — auch bei B7
("nur allgemein zugängliche/erfundene Inhalte") — trifft laut Tabelle
keine Stufe zu (ein eigentlich widersprüchlicher Zustand: irgendeine
Kategorie sollte immer zutreffen). `internal/rules.EvaluateDatenklasse`
nimmt in diesem Fall konservativ `intern` an, nicht `oeffentlich` — im
Zweifel mehr Schutzanforderungen, nicht weniger. **Nicht fachlich
bestätigt**, nur eine sichere Standardannahme.
## 6. Welche Anforderungen werden hart gegen ein Werkzeug gefiltert?
`internal/rules.ErfuelltAnforderung` filtert aktuell hart nur
`avv_erforderlich`, `eu_verarbeitung` und `kein_training_auf_eingabe`
das sind technische Eigenschaften eines Werkzeugs
(`werkzeug.avv_verfuegbar`, `verarbeitungsort`, `training_standard`).
`menschliche_aufsicht`, `kennzeichnungspflicht` und `dsfa_erforderlich`
betreffen dagegen einen Prozess rund um die Nutzung, nicht das Werkzeug
selbst — ein Werkzeug wird deswegen nicht aussortiert, das wird
stattdessen als Auflage vermerkt (geplant für Schritt 4/5). **Diese
Aufteilung ist eine Annahme von Schritt 3, nicht ausdrücklich in der
Spezifikation so festgelegt** — vor Schritt 4 gegenprüfen, ob das der
gewünschten Produktlogik entspricht.

32
rules/anforderungen.yaml Normal file
View File

@@ -0,0 +1,32 @@
# Anforderungsprofil: aus Datenklasse und/oder KI-VO-Einstufung
# abgeleitete Menge von Anforderungen, die ein Werkzeug erfüllen muss.
#
# ACHTUNG: "kein_training_auf_eingabe aus intern, personenbezogen und
# höher" in der Produktspezifikation lässt offen, ob auftragsdaten
# (rang 3, zwischen intern und personenbezogen) eingeschlossen sein
# soll — hier bewusst explizit aufgeführt (siehe rules/OPEN.md), damit
# nichts über eine implizite Rangfolge erraten wird.
version: 1
anforderungen:
- id: avv_erforderlich
aus_datenklassen: [personenbezogen, besondere_kategorie, auftragsdaten]
beschreibung: "Auftragsverarbeitungsvertrag mit dem Werkzeug-Anbieter erforderlich"
- id: eu_verarbeitung
aus_datenklassen: [berufsgeheimnis, besondere_kategorie]
beschreibung: "Verarbeitung muss innerhalb der EU stattfinden"
- id: kein_training_auf_eingabe
aus_datenklassen: [intern, auftragsdaten, personenbezogen, berufsgeheimnis, besondere_kategorie]
beschreibung: "Eingaben dürfen nicht zum Training des Werkzeugs verwendet werden"
- id: loeschfrist_max_tage
aus_datenklassen: [oeffentlich, intern, auftragsdaten, personenbezogen, berufsgeheimnis, besondere_kategorie]
beschreibung: "maximale Aufbewahrungsdauer richtet sich nach der Datenklasse"
- id: menschliche_aufsicht
aus_einstufungen: [hochrisiko]
beschreibung: "ein Mensch muss das Ergebnis vor Wirksamkeit prüfen"
- id: kennzeichnungspflicht
aus_einstufungen: [transparenzpflicht]
beschreibung: "das Ergebnis muss als KI-generiert gekennzeichnet werden"
- id: dsfa_erforderlich
aus_datenklassen: [besondere_kategorie]
aus_einstufungen: [hochrisiko]
beschreibung: "Datenschutz-Folgenabschätzung erforderlich"

35
rules/datenklasse.yaml Normal file
View File

@@ -0,0 +1,35 @@
# Datenklasse-Ableitung aus Fragebogen-Abschnitt B. "Höchste zutreffende
# Stufe gewinnt" — rang entscheidet, welche Stufe gewinnt, wenn mehrere
# Auslöser gleichzeitig zutreffen. "Unsicher" zählt wie "Ja" (siehe
# Fragebogen-Spezifikation) — das wird in der Auswertung (Phase 3)
# umgesetzt, nicht hier.
#
# ACHTUNG: Die Rangfolge unten ist eine Annahme (nicht explizit in der
# Produktspezifikation vorgegeben), siehe rules/OPEN.md — vor Phase 3
# mit dem fachlich Verantwortlichen bestätigen.
version: 1
stufen:
- id: oeffentlich
rang: 1
ausloeser: [b7]
folge: "keine besonderen Anforderungen"
- id: intern
rang: 2
ausloeser: [b6]
folge: "Geschäftsgeheimnis-Schutz"
- id: auftragsdaten
rang: 3
ausloeser: [b5]
folge: "Weitergabe nur wenn Haupt-AVV es zulässt"
- id: personenbezogen
rang: 4
ausloeser: [b1, b4]
folge: "AVV, EU-Verarbeitung, Löschfrist"
- id: berufsgeheimnis
rang: 5
ausloeser: [b3]
folge: "§ 203 StGB — externe Verarbeitung nur mit Verpflichtung"
- id: besondere_kategorie
rang: 6
ausloeser: [b2]
folge: "zusätzlich DSFA prüfen, Art. 9 DSGVO"

View File

@@ -0,0 +1,30 @@
# KI-VO-Einstufung aus Fragebogen-Abschnitt C. Reihenfolge ist
# Prüfreihenfolge (erste zutreffende Stufe gewinnt) — "verboten" muss
# zuerst geprüft werden (K.-o.-Prüfung, siehe Bewertungslogik). Jede
# Variante ist eine UND-Verknüpfung von Fakten; mehrere Varianten pro
# Stufe sind ODER-verknüpft.
#
# ACHTUNG: c3_art (social_scoring / biometrische_kategorisierung /
# emotionserkennung_arbeitsplatz) und c2_folge sind Platzhalter-
# Feldnamen, bis Phase 2 (Fragebogen) die genauen Fakten-Schlüssel
# festlegt — siehe rules/OPEN.md.
version: 1
stufen:
- id: verboten
quelle: "Art. 5 KI-VO"
varianten:
- {c3: true, c3_art: emotionserkennung_arbeitsplatz}
- {c3: true, c3_art: social_scoring}
- {c3: true, c3_art: biometrische_kategorisierung}
- id: hochrisiko
varianten:
- {c2: true, c2_folge: beschaeftigung}
- {c2: true, c2_folge: kreditwuerdigkeit}
- {c2: true, c2_folge: leistungsbewilligung}
- {c2: true, c2_folge: bildung}
- {c2: true, c2_folge: gesundheit}
- id: transparenzpflicht
varianten:
- {c1: true, c5: false}
- id: minimal
varianten: []

View File

@@ -1,23 +1,19 @@
#!/usr/bin/env bash
# Deklarix — Build-Skript (amd64 + arm64)
# Deklarix — .deb-Pakete bauen (amd64 + arm64)
#
# Verwendung:
# ./scripts/build.sh [version] # Beide Architekturen
# ./scripts/build.sh [version] amd64 # Nur amd64
# ./scripts/build.sh [version] arm64 # Nur arm64
#
# Ausgabe: dist/deklarix_<version>_<arch>
#
# Release-Prozess:
# 1. VERSION erhöhen (Semantic Versioning: MAJOR.MINOR.PATCH)
# 2. ./scripts/build.sh <version>
# 3. ./scripts/test.sh
# 4. git tag v<version> && git push origin v<version>
# Ausgabe: dist/deklarix_<version>_<arch>.deb
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
PACKAGING_DIR="$REPO_DIR/packaging"
BUILD_DIR="$REPO_DIR/dist/build"
VERSION="${1:-0.0.1}"
ARCH_FILTER="${2:-both}"
@@ -26,9 +22,8 @@ log() { echo -e "${GRN}[build]${NC} $*"; }
export PATH=$PATH:/usr/local/go/bin
command -v go >/dev/null || { echo "Go nicht gefunden — PATH: $PATH"; exit 1; }
mkdir -p "$REPO_DIR/dist"
command -v go >/dev/null || { echo "Go nicht gefunden — PATH: $PATH"; exit 1; }
command -v dpkg-deb >/dev/null || { echo "dpkg-deb nicht gefunden"; exit 1; }
log "Version: $VERSION | Go: $(go version)"
@@ -39,14 +34,42 @@ case "$ARCH_FILTER" in
*) ARCHS=("amd64" "arm64") ;;
esac
rm -rf "$BUILD_DIR"
mkdir -p "$REPO_DIR/dist"
for ARCH in "${ARCHS[@]}"; do
OUT="$REPO_DIR/dist/deklarix_${VERSION}_${ARCH}"
log "Baue $ARCH$OUT"
GOARCH=$ARCH GOOS=linux go build \
-ldflags="-X main.Version=${VERSION} -s -w" \
-o "$OUT" \
./cmd/server/
log "$ARCH fertig: $(du -sh "$OUT" | cut -f1)"
DEB_NAME="deklarix_${VERSION}_${ARCH}"
DEB_DIR="$BUILD_DIR/$DEB_NAME"
log "Baue Binary ($ARCH)..."
mkdir -p "$DEB_DIR/DEBIAN" \
"$DEB_DIR/usr/bin" \
"$DEB_DIR/usr/share/deklarix/rules" \
"$DEB_DIR/etc/systemd/system" \
"$DEB_DIR/etc/deklarix"
CGO_ENABLED=0 GOARCH=$ARCH GOOS=linux go build \
-trimpath -ldflags="-X main.Version=${VERSION} -s -w" \
-o "$DEB_DIR/usr/bin/deklarix" \
./cmd/deklarix/
chmod 755 "$DEB_DIR/usr/bin/deklarix"
log "Stelle Paket zusammen ($ARCH)..."
sed "s/^Version: VERSION/Version: $VERSION/; s/^Architecture: ARCH/Architecture: $ARCH/" \
"$PACKAGING_DIR/DEBIAN/control.tmpl" > "$DEB_DIR/DEBIAN/control"
cp "$PACKAGING_DIR/DEBIAN/postinst" "$DEB_DIR/DEBIAN/postinst"
cp "$PACKAGING_DIR/DEBIAN/prerm" "$DEB_DIR/DEBIAN/prerm"
cp "$PACKAGING_DIR/DEBIAN/conffiles" "$DEB_DIR/DEBIAN/conffiles"
chmod 755 "$DEB_DIR/DEBIAN/postinst" "$DEB_DIR/DEBIAN/prerm"
cp "$PACKAGING_DIR/etc/systemd/system/deklarix.service" "$DEB_DIR/etc/systemd/system/"
cp "$PACKAGING_DIR/etc/deklarix/deklarix.env.example" "$DEB_DIR/etc/deklarix/"
cp "$REPO_DIR"/rules/*.yaml "$DEB_DIR/usr/share/deklarix/rules/"
dpkg-deb --build --root-owner-group "$DEB_DIR" "$REPO_DIR/dist/$DEB_NAME.deb"
log "$ARCH fertig: dist/$DEB_NAME.deb ($(du -sh "$REPO_DIR/dist/$DEB_NAME.deb" | cut -f1))"
done
rm -rf "$BUILD_DIR"
log "Build abgeschlossen ✓"

View File

@@ -6,13 +6,14 @@
#
# Was passiert:
# 1. Tests laufen
# 2. Build für amd64 + arm64
# 3. git tag v<version> wird gesetzt
# 4. git push origin main + git push origin v<version>
# 2. .deb-Pakete bauen (amd64 + arm64)
# 3. Pakete in Giteas Debian-Paketregistrierung hochladen (Kanal "testing")
# 4. git tag v<version> wird gesetzt
# 5. git push origin main + git push origin v<version>
#
# Voraussetzungen:
# - Sauberer Git-Status (keine uncommitted changes)
# - Alle Tests grün
# - Alle Tests grün (inkl. DATABASE_URL für die Store-Integrationstests)
set -euo pipefail
@@ -24,8 +25,9 @@ if [[ -z "$VERSION" ]]; then
exit 1
fi
GRN='\033[0;32m'; BLD='\033[1m'; NC='\033[0m'
log() { echo -e "${GRN}[release]${NC} $*"; }
GRN='\033[0;32m'; YLW='\033[0;33m'; BLD='\033[1m'; NC='\033[0m'
log() { echo -e "${GRN}[release]${NC} $*"; }
warn() { echo -e "${YLW}[release]${NC} $*"; }
cd "$REPO_DIR"
@@ -43,9 +45,46 @@ log "Tests laufen..."
bash "$REPO_DIR/scripts/test.sh"
# Build
log "Build für alle Architekturen..."
log "Pakete bauen..."
bash "$REPO_DIR/scripts/build.sh" "$VERSION"
# ─── apt-Kanal-Modell ────────────────────────────────────────────────────────
# Suite = OS-Codename, Komponente = Kanal. Deklarix hat aktuell nur einen
# Kanal (testing) — es gibt noch keine Kunden, für die "stable" vom
# aktuellen Entwicklungsstand getrennt werden müsste. Sobald das relevant
# wird: gleiches Promote-Modell wie enconf-webpanel (release.sh stable).
GITEA_URL="https://git.netcell-it.de"
GITEA_ORG="projekte"
GITEA_CHANNEL="testing"
APT_SUITES=(bookworm trixie)
GITEA_TOKEN="$(git -C "$REPO_DIR" remote get-url origin | grep -oP '://[^:]+:\K[^@]+' 2>/dev/null || echo '')"
if [ -z "$GITEA_TOKEN" ]; then
echo "FEHLER: Gitea-Token nicht im Git-Remote gefunden."
exit 1
fi
upload_deb() {
curl -sk -w "%{http_code}" -o /dev/null -X PUT \
"$GITEA_URL/api/packages/$GITEA_ORG/debian/pool/$2/$3/upload" \
-H "Authorization: token $GITEA_TOKEN" --upload-file "$1" 2>/dev/null
}
log "Pakete → Gitea-Debian-Registry (${APT_SUITES[*]} / $GITEA_CHANNEL)..."
for deb in "$REPO_DIR"/dist/deklarix_"${VERSION}"_*.deb; do
[ -f "$deb" ] || { echo "FEHLER: kein .deb für Version $VERSION in dist/ gefunden"; exit 1; }
b="$(basename "$deb")"
for suite in "${APT_SUITES[@]}"; do
code=$(upload_deb "$deb" "$suite" "$GITEA_CHANNEL")
if [ "$code" = "201" ] || [ "$code" = "409" ]; then
log "$b$suite/$GITEA_CHANNEL"
else
echo "FEHLER: Upload $b$suite/$GITEA_CHANNEL fehlgeschlagen (HTTP $code)"
exit 1
fi
done
done
# Git Tag
log "Tag v${VERSION} setzen..."
git tag -a "v${VERSION}" -m "Release v${VERSION}"
@@ -57,4 +96,5 @@ git push origin "v${VERSION}"
log ""
log "Release v${VERSION} fertig ✓"
log "Binaries: dist/deklarix_${VERSION}_amd64 + _arm64"
log "Pakete: dist/deklarix_${VERSION}_amd64.deb + _arm64.deb"
log "Installation/Update auf Zielservern: apt install/upgrade deklarix"

View File

@@ -15,6 +15,17 @@ fail() { echo -e "${RED}[FAIL]${NC} $*"; exit 1; }
cd "$REPO_DIR"
# internal/store hat Integrationstests gegen echtes Postgres (u. a. die
# Append-only-Garantie auf finding/extraction/evidence_package). Ohne
# DATABASE_URL überspringt Go diese Tests still — das darf im
# Release-Pfad (release.sh ruft dieses Skript auf) nicht passieren.
# Postgres wird hier bewusst NICHT selbst hochgezogen — das ist Aufgabe
# des Testsystems, nicht des lokalen Dev-Rechners. DATABASE_URL muss dort
# vorkonfiguriert sein.
if [[ -z "${DATABASE_URL:-}" ]]; then
fail "DATABASE_URL ist nicht gesetzt — Store-Integrationstests (Append-only-Garantie) würden sonst still übersprungen. Auf dem Testsystem konfigurieren; für schnelle lokale Iteration ohne Postgres stattdessen gezielt 'go test ./internal/...' o. Ä. nutzen."
fi
log "go vet ..."
go vet ./... || fail "go vet fehlgeschlagen"
@@ -22,6 +33,6 @@ log "go test ./... "
go test -race -count=1 ./... || fail "Tests fehlgeschlagen"
log "Build-Check (amd64) ..."
GOARCH=amd64 GOOS=linux go build -o /dev/null ./cmd/server/ || fail "Build fehlgeschlagen"
GOARCH=amd64 GOOS=linux go build -o /dev/null ./cmd/deklarix/ || fail "Build fehlgeschlagen"
log "Alle Tests bestanden ✓"