Compare commits

..

53 Commits
v0.2.0 ... main

Author SHA1 Message Date
noroot
cbe68b16c9 fix: fieldset/legend durch echte Überschriften ersetzt, globales CSS vervollständigt
<legend> positioniert sich per Spezifikation immer auf der oberen
Rahmenlinie seines <fieldset> - das lässt sich nicht per CSS aufheben,
nur kaschieren. Die vorherige "Nachbesserung" (mehr Padding/Breite auf
der legend) war ein Pflaster, kein Fix - kam prompt wieder zum
Vorschein, sobald eine neue .form-section-Seite entstand. .form-section
ist jetzt ein <div>, die Überschrift ein normales <h3
class="form-section-title"> mit voller CSS-Kontrolle. Betrifft alle
neun Vorlagen mit diesem Muster.

Zusätzlich alle restlichen unstyled Formulare (Login, Passwort-Reset,
Einladung annehmen, Nutzer anlegen, CSV-Import, Abteilung anlegen,
Firma umbenennen, Fall-Entscheiden) auf dasselbe form-card/form-section/
form-grid-Muster gehoben, für durchgängig konsistentes globales CSS
statt gemischter roher und gestylter Formulare. antrag_neu.html (der
Fragebogen) bleibt bewusst unverändert - dessen fieldset/legend haben
keinen Rahmen-Konflikt und sind an bereits getestete :has()-Adaptivlogik
gekoppelt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 12:18:35 +02:00
noroot
7db4707bc5 feat: vollständige Firmendaten (Adresse, Abrechnung) bei Firmenanlage
Bei Firmenanlage (Registrierung + Betreiber-Firmenanlage) müssen jetzt
Adresse (Straße, PLZ, Ort, Land) und Abrechnungsdaten (Rechnungsemail,
optional USt-IdNr.) erfasst werden, nicht nur der Firmenname (Migration
0023). USt-IdNr. bewusst optional - Kleinunternehmer nach §19 UStG
haben keine. Neue Seite /verwaltung/firma (admin-only) zum Einsehen/
Nachtragen für bestehende Firmen. store.CreateAccount nimmt jetzt ein
AccountInput statt nur einen Namen entgegen (Signaturänderung betrifft
~20 Testaufrufe, mechanisch umgestellt). register.html/
betreiber_account_neu.html auf form-card/form-grid umgestellt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 11:36:05 +02:00
noroot
fa5e68c892 refactor: E-Mail-Vorlagen als Liste + eigene Bearbeiten-Seite statt Formular-Stapel
Auf Nutzerfeedback: die erste Version zeigte alle bekannten Vorlagen als
gestapelte Formulare auf einer Seite. Jetzt wie Werkzeugkatalog/
Nutzerverwaltung: eine Liste (GET .../email-vorlagen) mit Klick auf eine
eigene Bearbeiten-Seite je Typ (GET/POST .../email-vorlagen/{typ}) -
skaliert sauber, wenn mit der Zeit weitere Benachrichtigungstypen
dazukommen, statt eine immer länger werdende Formular-Stapel-Seite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 11:10:46 +02:00
noroot
d3b171f721 feat: pro Mandant editierbare E-Mail-Vorlagen
Zweistufig wie der Werkzeugkatalog: Betreiber pflegt den plattformweiten
Standard (/betreiber/email-vorlagen), jeder Mandant kann ihn für sich
übersteuern (/verwaltung/email-vorlagen) - ResolveEmailVorlage nutzt die
eigene Vorlage falls vorhanden, sonst fällt sie auf den Plattform-
Standard zurück. Passwort-Zurücksetzen ist die einzige aktuell
existierende E-Mail und nutzt jetzt diese Vorlage statt Hardcoding.

Bug beim Live-Verifizieren gefunden: UNIQUE(account_id, typ) verhindert
bei NULLABLE account_id keine Duplikate (NULL != NULL in SQL) - jedes
Speichern des Plattform-Standards erzeugte eine neue Zeile statt sie zu
aktualisieren. Fix: zwei partielle Unique-Indizes statt eines
gemeinsamen Constraints, mit Regressionstest abgesichert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 10:06:09 +02:00
noroot
b0d6b00045 fix: Migration 0021 crashte in Produktion — falsche Rollen-Annahme
v0.38.0 verursachte einen ~3-minütigen Ausfall: Migration 0021 nahm an,
die Anwendung verbinde überall als Postgres-Superuser "postgres" (nur
lokal via sudo geprüft, nicht die tatsächliche Produktions-DATABASE_URL)
und versuchte dort eine neue Rolle anzulegen - production verbindet
aber über die nicht-privilegierte, tabellenbesitzende Rolle "deklarix"
ohne CREATEROLE, das INSERT/CREATE ROLE schlug fehl und der Dienst
crash-loopte im "dirty migration"-Zustand. Kein Datenverlust (Postgres
hat die DDL-Transaktion selbst zurückgerollt), Dienst wurde auf v0.37.0
zurückgestuft und stabilisiert.

Fix: Rollen-Anlage ist jetzt an eine CREATEROLE-Prüfung gekoppelt und
wird bei fehlender Berechtigung übersprungen statt zu scheitern.
Zusätzlich FORCE ROW LEVEL SECURITY auf jeder Tabelle - das bindet auch
den Tabellenbesitzer (wie Productions "deklarix"), ganz ohne die
zusätzliche Rolle. Produktivbetrieb braucht dadurch jetzt gar keinen
manuellen Schritt mehr. Erneut end-to-end verifiziert, diesmal
zusätzlich produktionsgetreu simuliert (temporäre nicht-privilegierte,
tabellenbesitzende Rolle lokal).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 09:38:51 +02:00
noroot
fe28278615 feat: Row-Level-Security für Mandantenisolation auf DB-Ebene
Postgres-RLS-Policies auf allen Tabellen mit echten Mandanten-
Geschäftsdaten (antrag und alles darüber verkettete, abteilung,
werkzeug/werkzeug_sperre, genehmiger_rolle/freigabe_regel,
loeschfrist_einstellung). Kritischer Fund vor der Umsetzung: die
Anwendung verbindet als postgres-Superuser, der RLS immer umgeht -
Migration 0021 legt deshalb zusätzlich eine eingeschränkte Rolle
"deklarix_app" an, nur für die greifen die Policies tatsächlich.

internal/store/tenant_scope.go: WithTenantScope öffnet eine Transaktion
und setzt Sitzungsvariablen (app.account_id/app.is_betreiber) per
set_config mit Parameterbindung; alle Store-Methoden laufen jetzt über
s.db(ctx) statt direkt s.Pool. Jede require*-Middleware umschließt die
komplette Handler-Ausführung damit - jeder Request läuft dadurch auch
atomar in einer Transaktion (positiver Nebeneffekt).

Live end-to-end verifiziert (echter HTTP-Server + DATABASE_URL_APP auf
die eingeschränkte Rolle gesetzt): zwei Firmen registriert, Isolation
über Abteilung/Antrag/Bewertung bestätigt, zentraler NULL-Katalog für
beide sichtbar. Produktivbetrieb braucht noch einen manuellen Schritt
(Passwort für deklarix_app setzen + DATABASE_URL_APP konfigurieren,
siehe CLAUDE.md) - die Migration allein aktiviert noch nichts, solange
die App weiter als Superuser verbindet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 09:22:20 +02:00
noroot
0fe29f8c80 fix: .page-wide auf alle Listen-/Dashboard-/Detailansichten ausgeweitet
Erste Version deckte nur die fünf Tabellen-Seiten ab. Auf Nutzerwunsch
jetzt auch Dashboard, Posteingang, Register, Nutzerverwaltung, Fall-/
Antrag-Detail, Wiedervorlage, Betreiber-Accounts/-Audit-Log/-Dashboard
und die neuen Genehmiger-Rollen/Freigaben-Seiten. Reine Formular-/Auth-
Seiten (Login, Registrieren, Einladung, Nutzer/Firma anlegen) bleiben
bewusst bei 1100px - ein einzelnes Formular auf voller Breite wäre
schlechter lesbar. .page-wide selbst wurde von "kein Limit" auf 1600px
korrigiert, da Detailseiten mit Fließtext (Fall-/Antrag-Detail) sonst
auf Ultrawide-Monitoren unlesbar lange Zeilen bekommen hätten.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 08:58:18 +02:00
noroot
eccf02050b fix: .page-wide hebt die 1100px-Breitendeckelung für Tabellen-Seiten auf
.page war für Formulare/Fließtext sinnvoll begrenzt, verschwendete auf
breiten Bildschirmen aber sichtbar Platz bei Tabellen mit vielen Spalten
(Werkzeugkatalog, Register, Freigabe-Regeln, Nutzer-Import-Ergebnis) -
die Tabelle musste horizontal scrollen, obwohl rechts noch Leerraum war.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 08:54:53 +02:00
noroot
8a8295dacd feat: Löschfristen je Datenklasse, Passwort-Zurücksetzen, DPF-Recherche
- Löschfristen (Migration 0020): pro Mandant einstellbar statt fest im
  Regelwerk, da die DSGVO selbst keine festen Fristen nennt (Art. 5
  Abs. 1 lit. e). Jede Firma wird mit risikogestaffelten Vorschlagswerten
  vorbelegt, loeschfrist_max_tage wird jetzt tatsächlich hart gegen
  Werkzeuge gefiltert (schließt rules/OPEN.md Punkt 4).
- Neues internal/mail-Paket (SMTP-Versand + Test-Doppel) und darauf
  aufbauend Passwort-Zurücksetzen (Migration 0019) - bisher nur als
  Absicht in der Rollentabelle genannt, nie gebaut.
- dpf_zertifiziert für alle 20 Katalogeinträge gegen das offizielle
  DPF-Register recherchiert und in der Produktions-DB aktualisiert
  (12 zertifiziert, 5 recherchiert-nicht-gefunden, 3 nicht anwendbar).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 07:38:12 +02:00
noroot
690660b655 feat: Standard-Genehmiger-Rollen automatisch bei Firmenanlage anlegen
Jede neue Firma (Registrierung + Betreiber-Firmenanlage) bekommt jetzt
automatisch vier leere Genehmiger-Rollen mit erklärender Beschreibung
(Datenschutzbeauftragter, Geschäftsführer, KI-Manager, CISO) - Admin
muss nur noch Personen zuordnen statt bei null anzufangen. Welche
Bedingung welche Rolle tatsächlich auslöst, bleibt weiterhin komplett
konfigurierbar pro Firma (Migration 0018 fügt genehmiger_rolle.beschreibung
als reines Freitext-Orientierungsfeld hinzu, keine feste fachliche
Bindung).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 22:18:49 +02:00
noroot
f729e5ae48 feat: konfigurierbaren Freigabe-Workflow mit Genehmiger-Rollen einführen
Mandanten können jetzt selbst festlegen, wer bei welcher abgeleiteten
Bedingung (Anforderung/Einstufung/Datenklasse) zusätzlich zur
Fachebene-Entscheidung zustimmen muss (z. B. Datenschutzbeauftragter,
Geschäftsführung) - Genehmiger-Rollen sind orthogonal zu den fünf
bestehenden Zugriffsrollen. Alle ausgelösten Rollen müssen zustimmen,
eine Ablehnung kippt kaskadierend den gesamten Antrag. Mandanten ohne
Freigabe-Regeln (aktuell alle) sind unverändert vom alten Direktpfad
betroffen, siehe TestOhneFreigabeRegelnVerhaeltSichWieVorher.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 21:42:28 +02:00
noroot
9e2c7f92ff feat: PageHeader/Filter/Paginierung auf Betreiber-Accounts, Audit-Log, Meine Anträge, Wiedervorlage ausgerollt
Vervollständigt den Rollout aus dem vorigen Schritt: Betreiber-Accounts
und Audit-Log (Betreiber-Bereich) sowie "Meine Anträge" bekommen
.page-header, Freitext-Filter (matchesQuery) und Paginierung.
Wiedervorlage nur .page-header, bewusst ohne Paginierung (natürlich
begrenzte Liste).

Audit-Log holt weiterhin nur die neuesten 1000 Einträge aus der DB
und paginiert im Go-Code darüber (auditLogFetchLimit) - keine echte
DB-Offset-Paginierung, als bekannte Einschränkung dokumentiert.
2026-08-31 19:33:27 +02:00
noroot
4c60603055 feat: PageHeader/Filter/Paginierung auf Register, Nutzerverwaltung, Abteilungen, Posteingang, Betreiber-Dashboard ausgerollt
Register und Fälle-Posteingang bekommen .page-header, Freitext-Filter
und Paginierung; Register zusätzlich .table-responsive statt
.table-scroll. Nutzerverwaltung bekommt Filter+Paginierung (bleibt
Karten-Liste). Abteilungen nur .page-header (bewusst ohne Paginierung,
typischerweise kleine Listen). Betreiber-Dashboard von .admin-kacheln
auf .stat-cards umgestellt.

Neuer gemeinsamer Helfer matchesQuery() in pagination.go für die
Freitext-Filter aller Listen-Seiten.

Dabei einen bereits live gewesenen CSS-Bug gefunden und behoben:
.filter-bar direkt auf einem <form> verlor gegen "form {
flex-direction: column }" (gleiche Spezifität, nur form setzte die
Property), wodurch die Filterleiste senkrecht statt waagerecht
stapelte — betraf die im letzten Schritt ausgelieferte
Mandanten-Werkzeugkatalog-Seite. Jetzt robust via explizitem
flex-direction: row in .filter-bar selbst.
2026-08-31 16:12:41 +02:00
noroot
bc99858c94 feat: enconf-Layout-Patterns übertragen — responsive Card-Tabellen, PageHeader, StatCards
Über die Architect-RAG-Suche (ac_search_code_global) drei UX-Muster
aus dem enconf-Schwesterprojekt (React/Ant Design) identifiziert und
als reines CSS/HTML nachgebaut, da Deklarix bewusst kein SPA ist:

- .table-responsive: Tabellen werden unter 640px zu einer Card-Liste
  statt horizontal zu scrollen (enconfs ProTable-Verhalten), via
  data-label-Attribut je <td>. Angewendet auf beide
  Werkzeugkatalog-Tabellen.
- .page-header: Titel+Unterzeile links, primäre Aktion rechts, statt
  eines nackten <h1> mit Link irgendwo unten.
- .stat-cards: Kennzahl-Kacheln mit farbigem Akzentrand (nur farbig
  bei Wert > 0) statt einfacher Text-Links, auf der Startseite.
2026-08-31 15:55:34 +02:00
noroot
3aecbb446c feat: Filter und Paginierung für den Werkzeugkatalog, Claude Code als Katalogeintrag
Beide Katalog-Ansichten (Betreiber + Mandant) haben jetzt eine
Filterleiste (Freitext über Name/Anbieter/Verarbeitungsländer,
Zweck-Dropdown) und serverseitige Paginierung (20/Seite) über ein
GET-Formular, kein JavaScript. pagination.go ist generisch gehalten
(Go-Generics) für Wiederverwendung in weiteren Listen.

Claude Code als eigener Katalogeintrag ergänzt (nicht identisch mit
Claude for Work: läuft lokal, hat Dateizugriff, lokale Sitzungs-
protokolle mit eigenem Aufbewahrungsrisiko).
2026-08-31 15:09:23 +02:00
noroot
08c7721165 docs: DPF-Recherche als offenen Punkt festhalten 2026-08-31 13:55:58 +02:00
noroot
59e1bcc8f7 feat: Werkzeugkatalog nennt tatsächliche Verarbeitungsländer statt EU/USA/gemischt-Eimer, plus DPF-Feld
Die USA sind DSGVO-rechtlich bereits ein Drittland wie jedes andere -
der alte verarbeitungsort-Wertebereich (EU/USA/gemischt/on-prem)
verschleierte das. Ersetzt durch verarbeitungslaender TEXT[] mit
echter Weltländerliste im Formular (Mehrfachauswahl, kein JS nötig).
Der harte Filter eu_verarbeitung verlangt jetzt, dass ALLE genannten
Länder EU/EWR sind. Neues Feld dpf_zertifiziert macht die EU-US Data
Privacy Framework-Zertifizierung strukturiert statt nur Fließtext.
Öffnet nebenbei den Weg für Katalogeinträge außerhalb EU/USA (z. B.
DeepSeek), die vorher am alten Wertebereich scheiterten.

Zusätzlich Typografie-Nachbesserung nach Nutzer-Feedback: Formular-
Abschnittsüberschriften (<legend>) saßen zu dicht am Kartenrand, weil
<legend> das Padding des umschließenden <fieldset> ignoriert - jetzt
mit eigenem Padding und größerer Schrift, h2/h3 einheitlich gesetzt.
2026-08-31 13:52:00 +02:00
noroot
2e459f1dd1 docs: Werkzeugkatalog-Stand aktualisieren (19 Einträge, alle 12 Zweck-Kategorien), Schema-Lücke für Drittland-Hosting dokumentieren
Katalog um 9 weitere recherchierte Werkzeuge erweitert (Notion AI,
Grammarly Business, Midjourney, Julius AI, Synthesia, ElevenLabs,
Zendesk AI, Canva Magic Studio, Zapier) — deckt jetzt alle zwölf
Zweck-Kategorien ab. Midjourney bewusst als Negativbeispiel (kein AVV,
keine EU-Residenz, unbegrenzte Speicherung) aufgenommen statt
weggelassen. DeepSeek NICHT angelegt: verarbeitungsort erlaubt nur
EU/USA/gemischt/on-prem, keiner davon bildet China-Hosting korrekt ab
— Wertebereich müsste zuerst erweitert werden.
2026-08-31 13:17:13 +02:00
noroot
d77b19f61f feat: Zweck-Vokabular auf 12 Kategorien erweitert, Werkzeug-Formular im Enterprise-Look überarbeitet
Sechs neue Zweck-Kategorien (Datenanalyse, Video, Audio/Sprache,
Kundenservice/Chatbot, Präsentation/Design, Automatisierung/Agenten)
ergänzen die ursprünglichen sechs — deckt jetzt die tatsächliche
Bandbreite an Einsatzgebieten ab statt nur klassischer Wissensarbeit.

Werkzeug-Formular (Betreiber + Mandant, gleiche Vorlage) neu strukturiert:
Karten-Layout mit thematischen Abschnitten, 2-Spalten-Grid für kurze
Felder, Chip-Gruppen statt langer Checkbox-Listen, abgesetzte
Gefahrenzone für den Löschen-Button. Dabei einen vorbestehenden Bug
gefunden und behoben: das Lösch-Formular war fest auf die
Betreiber-Route verdrahtet, obwohl dieselbe Vorlage auch vom
Mandanten-Formular genutzt wird.
2026-08-31 13:12:07 +02:00
noroot
dbeaa45644 feat: Ampel-Kennzeichnung für Prüfalter im Werkzeugkatalog, Scroll-Fade für breite Tabellen
Katalogtabellen zeigten bisher nur "nicht aktuell geprüft" als
Binärflag; jetzt eine dreistufige Ampel (aktuell/bald fällig/
abgelaufen) mit denselben Farbtoken wie die KI-VO-Risikoampel.
Zusätzlich: .table-scroll bekommt einen rechten Fade-Rand, damit eine
am Fensterrand abgeschnittene breite Tabelle als "hier geht's mit
Scrollen weiter" erkennbar ist statt wie ein Rendering-Fehler
auszusehen.
2026-08-31 12:28:57 +02:00
noroot
4358dbaa0d feat: Werkzeugkatalog als Tabelle, Aufbewahrung nullable, kontrolliertes Zweck-Vokabular, Subprozessoren
Katalog (zentral + mandantenseitig) zeigt jetzt eine mehrspaltige
Tabelle statt einer Liste. aufbewahrung_tage ist nullable (NULL =
vom Anbieter nicht beziffert, unterscheidbar von echter 0-Tage-
Zusicherung). geeignete_zwecke ist Checkbox-Auswahl aus den sechs
Fragebogen-Zweck-Kategorien statt Freitext. Neues Feld
subprozessoren macht Unterauftragsverarbeiter durchsuchbar statt nur
Freitext in Einschränkungen. 180-Tage-Frische-Markierung in beiden
Katalogansichten ergänzt.
2026-08-31 12:08:05 +02:00
noroot
7397f70068 feat: Firmen-CRUD für den Betreiber (anlegen, umbenennen)
Schließt eine echte Lücke: der Betreiber-Bereich konnte Accounts
bisher nur lesend anzeigen, eine neue Firma entstand ausschließlich
über die öffentliche Selbstregistrierung. GET/POST
/betreiber/accounts/neu legt jetzt eine Firma samt erstem admin-Login
direkt vom Betreiber aus an (z. B. für vertriebsunterstütztes
Onboarding oder Testkonten) — erzeugt einen audit_log-Eintrag. POST
/betreiber/accounts/{id}/umbenennen korrigiert den Firmennamen
(store.UpdateAccount). Bewusst kein Löschen: ein Hard-Delete würde
gegen die Fremdschlüssel aus antrag/app_user/audit_log laufen und
Historie zerstören — dasselbe Prinzip wie bei Nutzern (deaktivieren
statt löschen), ein Sperren/Deaktivieren für Accounts fehlt aber noch
und hängt an der noch nicht getroffenen Abrechnungsarchitektur.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 13:39:01 +02:00
noroot
2736e2c0db feat: Support-Login — Betreiber kann sich als Kunden-Nutzer anmelden
Ebene 5 (Betreiber) kann sich jetzt auf der Account-Detailseite über
einen Button je Nutzer als dieser Kunden-Login anmelden, ohne dessen
Passwort zu kennen — für Support-Fälle, in denen der Betreiber
nachvollziehen muss, was ein Kunde sieht. Nicht für deaktivierte
Nutzer möglich. Neue Spalte session.impersonated_by_user_id (Migration
0014, store.CreateImpersonatedSession) hält fest, wer die Sitzung
ausgelöst hat — die Nav zeigt während der gesamten Sitzung einen
auffälligen Banner ("Support-Zugriff durch ..."), damit nie unklar
ist, im Kontext eines fremden Kontos zu handeln. Jede Nutzung erzeugt
einen audit_log-Eintrag. Die neue Sitzung ersetzt die eigene
Betreiber-Sitzung (kein Sitzungs-Stack) — nach der Nutzung meldet sich
der Betreiber mit den eigenen Zugangsdaten neu an.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 00:38:40 +02:00
noroot
9d2cb79424 fix: Startseite zeigt echte Übersicht statt Phase-1-Platzhalter
handleIndex war seit dem Produktwechsel unverändert ein Platzhalter aus
Schritt 1 ("Antrag stellen"/"Meine Anträge" — beides bereits in der
Seitenleiste vorhanden, die Startseite selbst zeigte nichts Eigenes).
Zeigt jetzt die Zahl eigener offener Anträge, und für die Fachebene
zusätzlich die Zahl offener Fälle im Posteingang; Betreiber sehen
einen Link zur Plattform statt leerer Antrags-Kacheln, die für sie
ohnehin nicht gelten. Bewusst ohne Wiedervorlage-Zahl — deren
Berechnung vergleicht jeden Genehmigungs-Snapshot gegen den aktuellen
Katalogstand und wäre für eine bei jedem Login geladene Startseite zu
teuer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 23:10:42 +02:00
noroot
4dd9bd88cb docs: fachliche Annahmen im Regelwerk bestätigt
Rangfolge der Datenklassen, auftragsdaten bei kein_training_auf_eingabe,
Vollständigkeit der drei "verboten"-Varianten und der intern-Fallback
wurden vom Produktverantwortlichen bestätigt (rules/OPEN.md). Weiterhin
offen: konkrete Löschfristen in Tagen, harte Filterung von
menschliche_aufsicht/kennzeichnungspflicht/dsfa_erforderlich.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 22:47:07 +02:00
noroot
96ac5d6b59 refactor: unbenutzte View-Felder entfernen (ID auf drei Templates nie referenziert)
Ein systematischer Audit nach demselben Muster wie beim Fragebogen-
Antworten-Bug fand drei Felder, die vom Handler befüllt, aber im
zugehörigen Template nie referenziert werden: antragDetailData.ID
(kein Formular auf der Seite braucht sie), anforderungView.ID (die
Beschreibung reicht als lesbare Anforderung, die interne Regel-ID ist
kein Nutzerinhalt), betreiberAccountDetailData.AccountID (die
Account-Detailseite ist rein lesend, kein Formular zeigt darauf).
Anders als der Fragebogen-Antworten-Bug ist hier keine für Nutzer
relevante Information verloren gegangen — die Felder waren schlicht
totes Gewicht, deshalb entfernt statt eine Anzeige dafür zu erfinden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 21:07:00 +02:00
noroot
29961f5657 fix: Fragebogen-Antworten auf der Antrag-/Fall-Detailseite anzeigen
Die tatsächlich gegebenen Fragebogen-Antworten (B-Fragen zur Datenklasse,
C-Fragen zur KI-VO-Einstufung, D-Werkzeugangaben) wurden bisher nirgends
angezeigt — weder dem antragstellenden Mitarbeiter noch der Fachebene
beim Entscheiden, obwohl der Code-Kommentar an handleAntragDetail schon
"zeigt einen Antrag mit allen Antworten" versprach. Nur die daraus
abgeleitete Bewertung war sichtbar, nicht die Grundlage, auf der sie
beruht — im Audit nicht nachvollziehbar (siehe CLAUDE.md, Grundregel)
und für die Fachebene beim Entscheiden nicht praktikabel.

antwortenAnzeige() baut aus den rohen JSON-Antworten eine lesbare Liste
mit denselben Fragen-Labels wie im Fragebogen selbst — jetzt sowohl auf
GET /antraege/{id} (Ebene 2) als auch GET /faelle/{id} (Ebene 3)
sichtbar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 18:11:25 +02:00
noroot
3ec57f2786 fix: admin bekommt Fachebene-Rechte (Posteingang, Entscheiden, Register, Wiedervorlage)
Löst einen Blocker im Kernablauf: eine frisch registrierte Firma hat
nur einen admin-Login und konnte damit bislang keinen einzigen
eingereichten Antrag sehen oder entscheiden, weil requireFachebene nur
verantwortlicher/pruefer durchließ. Die Spezifikation will admin+
KI-Verantwortlicher ohnehin auf derselben Person (siehe CLAUDE.md,
Offene Punkte) — statt eines Datenmodell-Umbaus (roles-Array oder
zwei app_user-Zeilen pro Person) bekommt admin jetzt pragmatisch
dieselben Fachebene-Rechte wie verantwortlicher (hatEntscheidungsrecht
in fachebene_handlers.go). pruefer bleibt unverändert nur lesend.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Volle Testsuite inkl. echter Postgres-Tests grün; End-to-End manuell
gegen einen laufenden Server verifiziert (Admin-Login, Verify-Toggle,
Erscheinen im öffentlichen Verzeichnis, Audit-Log-Eintrag, 404 für
Nicht-Admin-Zugriff).
2026-08-27 18:17:45 +02:00
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
197 changed files with 18356 additions and 3726 deletions

1536
CLAUDE.md

File diff suppressed because it is too large Load Diff

View File

@@ -6,8 +6,7 @@ import (
"net/http"
"os"
"github.com/netcell-it/deklarix/internal/evidence"
"github.com/netcell-it/deklarix/internal/extract"
"github.com/netcell-it/deklarix/internal/mail"
"github.com/netcell-it/deklarix/internal/rules"
"github.com/netcell-it/deklarix/internal/store"
"github.com/netcell-it/deklarix/internal/web"
@@ -23,32 +22,59 @@ func main() {
log.Fatalf("migrate: %v", err)
}
db, err := store.Open(context.Background(), databaseURL)
// Migrationen laufen immer über DATABASE_URL (braucht DDL-Rechte,
// z. B. CREATE TABLE/ALTER TABLE). Die laufende Anwendung verbindet
// sich dagegen möglichst über die eingeschränkte Row-Level-Security-
// Rolle "deklarix_app" (siehe Migration 0021) — DATABASE_URL_APP,
// falls gesetzt. Ohne DATABASE_URL_APP fällt sie auf DATABASE_URL
// zurück (bisheriges Verhalten, Postgres-Superuser umgeht RLS-
// Policies dann vollständig — kein Fehler, aber auch keine
// zusätzliche Isolation auf DB-Ebene, siehe CLAUDE.md).
appDatabaseURL := os.Getenv("DATABASE_URL_APP")
if appDatabaseURL == "" {
appDatabaseURL = databaseURL
}
db, err := store.Open(context.Background(), appDatabaseURL)
if err != nil {
log.Fatalf("open store: %v", err)
}
defer db.Close()
extractor := extract.NewEngine()
rulesDir := os.Getenv("RULES_DIR")
if rulesDir == "" {
rulesDir = "rules"
}
ruleSet, err := rules.Load(os.DirFS(rulesDir))
rulesFS := os.DirFS(rulesDir)
// Beim Start laden und validieren, auch wenn die Auswertungslogik
// (Phase 3 der Baureihenfolge) sie noch nicht konsumiert — ein
// kaputtes Regelwerk soll den Dienst nicht erst beim ersten Antrag
// zum Absturz bringen, sondern gar nicht erst starten lassen.
datenklasse, err := rules.LoadDatenklasse(rulesFS, "datenklasse.yaml")
if err != nil {
log.Fatalf("load rules from %s: %v", rulesDir, err)
log.Fatalf("load datenklasse-regelwerk from %s: %v", rulesDir, err)
}
log.Printf("%d Regeln aus %s geladen", len(ruleSet), rulesDir)
timestamper := evidence.NewHTTPTimestamper(os.Getenv("TSA_URL"))
dossierDir := os.Getenv("DOSSIER_DIR")
if dossierDir == "" {
dossierDir = "dossiers"
einstufung, err := rules.LoadEinstufung(rulesFS, "kivo_einstufung.yaml")
if err != nil {
log.Fatalf("load ki-vo-einstufung-regelwerk from %s: %v", rulesDir, err)
}
anforderungen, err := rules.LoadAnforderungen(rulesFS, "anforderungen.yaml")
if err != nil {
log.Fatalf("load anforderungsprofil-regelwerk from %s: %v", rulesDir, err)
}
log.Printf("Regelwerk geladen: %d Datenklassen, %d KI-VO-Stufen, %d Anforderungen (aus %s)",
len(datenklasse.Stufen), len(einstufung.Stufen), len(anforderungen.Anforderungen), rulesDir)
server, err := web.NewServer(extractor, ruleSet, db, timestamper, dossierDir)
mailer := mail.NewSMTPMailer(mail.Config{
Host: os.Getenv("SMTP_HOST"),
Port: os.Getenv("SMTP_PORT"),
User: os.Getenv("SMTP_USER"),
Password: os.Getenv("SMTP_PASSWORD"),
From: os.Getenv("SMTP_FROM"),
})
server, err := web.NewServer(db, web.Regelwerk{
Datenklasse: datenklasse, Einstufung: einstufung, Anforderungen: anforderungen,
}, mailer)
if err != nil {
log.Fatalf("web server: %v", err)
}

View File

@@ -1,251 +0,0 @@
!00 U+0000 .notdef
!01 U+0001 .notdef
!02 U+0002 .notdef
!03 U+0003 .notdef
!04 U+0004 .notdef
!05 U+0005 .notdef
!06 U+0006 .notdef
!07 U+0007 .notdef
!08 U+0008 .notdef
!09 U+0009 .notdef
!0A U+000A .notdef
!0B U+000B .notdef
!0C U+000C .notdef
!0D U+000D .notdef
!0E U+000E .notdef
!0F U+000F .notdef
!10 U+0010 .notdef
!11 U+0011 .notdef
!12 U+0012 .notdef
!13 U+0013 .notdef
!14 U+0014 .notdef
!15 U+0015 .notdef
!16 U+0016 .notdef
!17 U+0017 .notdef
!18 U+0018 .notdef
!19 U+0019 .notdef
!1A U+001A .notdef
!1B U+001B .notdef
!1C U+001C .notdef
!1D U+001D .notdef
!1E U+001E .notdef
!1F U+001F .notdef
!20 U+0020 space
!21 U+0021 exclam
!22 U+0022 quotedbl
!23 U+0023 numbersign
!24 U+0024 dollar
!25 U+0025 percent
!26 U+0026 ampersand
!27 U+0027 quotesingle
!28 U+0028 parenleft
!29 U+0029 parenright
!2A U+002A asterisk
!2B U+002B plus
!2C U+002C comma
!2D U+002D hyphen
!2E U+002E period
!2F U+002F slash
!30 U+0030 zero
!31 U+0031 one
!32 U+0032 two
!33 U+0033 three
!34 U+0034 four
!35 U+0035 five
!36 U+0036 six
!37 U+0037 seven
!38 U+0038 eight
!39 U+0039 nine
!3A U+003A colon
!3B U+003B semicolon
!3C U+003C less
!3D U+003D equal
!3E U+003E greater
!3F U+003F question
!40 U+0040 at
!41 U+0041 A
!42 U+0042 B
!43 U+0043 C
!44 U+0044 D
!45 U+0045 E
!46 U+0046 F
!47 U+0047 G
!48 U+0048 H
!49 U+0049 I
!4A U+004A J
!4B U+004B K
!4C U+004C L
!4D U+004D M
!4E U+004E N
!4F U+004F O
!50 U+0050 P
!51 U+0051 Q
!52 U+0052 R
!53 U+0053 S
!54 U+0054 T
!55 U+0055 U
!56 U+0056 V
!57 U+0057 W
!58 U+0058 X
!59 U+0059 Y
!5A U+005A Z
!5B U+005B bracketleft
!5C U+005C backslash
!5D U+005D bracketright
!5E U+005E asciicircum
!5F U+005F underscore
!60 U+0060 grave
!61 U+0061 a
!62 U+0062 b
!63 U+0063 c
!64 U+0064 d
!65 U+0065 e
!66 U+0066 f
!67 U+0067 g
!68 U+0068 h
!69 U+0069 i
!6A U+006A j
!6B U+006B k
!6C U+006C l
!6D U+006D m
!6E U+006E n
!6F U+006F o
!70 U+0070 p
!71 U+0071 q
!72 U+0072 r
!73 U+0073 s
!74 U+0074 t
!75 U+0075 u
!76 U+0076 v
!77 U+0077 w
!78 U+0078 x
!79 U+0079 y
!7A U+007A z
!7B U+007B braceleft
!7C U+007C bar
!7D U+007D braceright
!7E U+007E asciitilde
!7F U+007F .notdef
!80 U+20AC Euro
!82 U+201A quotesinglbase
!83 U+0192 florin
!84 U+201E quotedblbase
!85 U+2026 ellipsis
!86 U+2020 dagger
!87 U+2021 daggerdbl
!88 U+02C6 circumflex
!89 U+2030 perthousand
!8A U+0160 Scaron
!8B U+2039 guilsinglleft
!8C U+0152 OE
!8E U+017D Zcaron
!91 U+2018 quoteleft
!92 U+2019 quoteright
!93 U+201C quotedblleft
!94 U+201D quotedblright
!95 U+2022 bullet
!96 U+2013 endash
!97 U+2014 emdash
!98 U+02DC tilde
!99 U+2122 trademark
!9A U+0161 scaron
!9B U+203A guilsinglright
!9C U+0153 oe
!9E U+017E zcaron
!9F U+0178 Ydieresis
!A0 U+00A0 space
!A1 U+00A1 exclamdown
!A2 U+00A2 cent
!A3 U+00A3 sterling
!A4 U+00A4 currency
!A5 U+00A5 yen
!A6 U+00A6 brokenbar
!A7 U+00A7 section
!A8 U+00A8 dieresis
!A9 U+00A9 copyright
!AA U+00AA ordfeminine
!AB U+00AB guillemotleft
!AC U+00AC logicalnot
!AD U+00AD hyphen
!AE U+00AE registered
!AF U+00AF macron
!B0 U+00B0 degree
!B1 U+00B1 plusminus
!B2 U+00B2 twosuperior
!B3 U+00B3 threesuperior
!B4 U+00B4 acute
!B5 U+00B5 mu
!B6 U+00B6 paragraph
!B7 U+00B7 periodcentered
!B8 U+00B8 cedilla
!B9 U+00B9 onesuperior
!BA U+00BA ordmasculine
!BB U+00BB guillemotright
!BC U+00BC onequarter
!BD U+00BD onehalf
!BE U+00BE threequarters
!BF U+00BF questiondown
!C0 U+00C0 Agrave
!C1 U+00C1 Aacute
!C2 U+00C2 Acircumflex
!C3 U+00C3 Atilde
!C4 U+00C4 Adieresis
!C5 U+00C5 Aring
!C6 U+00C6 AE
!C7 U+00C7 Ccedilla
!C8 U+00C8 Egrave
!C9 U+00C9 Eacute
!CA U+00CA Ecircumflex
!CB U+00CB Edieresis
!CC U+00CC Igrave
!CD U+00CD Iacute
!CE U+00CE Icircumflex
!CF U+00CF Idieresis
!D0 U+00D0 Eth
!D1 U+00D1 Ntilde
!D2 U+00D2 Ograve
!D3 U+00D3 Oacute
!D4 U+00D4 Ocircumflex
!D5 U+00D5 Otilde
!D6 U+00D6 Odieresis
!D7 U+00D7 multiply
!D8 U+00D8 Oslash
!D9 U+00D9 Ugrave
!DA U+00DA Uacute
!DB U+00DB Ucircumflex
!DC U+00DC Udieresis
!DD U+00DD Yacute
!DE U+00DE Thorn
!DF U+00DF germandbls
!E0 U+00E0 agrave
!E1 U+00E1 aacute
!E2 U+00E2 acircumflex
!E3 U+00E3 atilde
!E4 U+00E4 adieresis
!E5 U+00E5 aring
!E6 U+00E6 ae
!E7 U+00E7 ccedilla
!E8 U+00E8 egrave
!E9 U+00E9 eacute
!EA U+00EA ecircumflex
!EB U+00EB edieresis
!EC U+00EC igrave
!ED U+00ED iacute
!EE U+00EE icircumflex
!EF U+00EF idieresis
!F0 U+00F0 eth
!F1 U+00F1 ntilde
!F2 U+00F2 ograve
!F3 U+00F3 oacute
!F4 U+00F4 ocircumflex
!F5 U+00F5 otilde
!F6 U+00F6 odieresis
!F7 U+00F7 divide
!F8 U+00F8 oslash
!F9 U+00F9 ugrave
!FA U+00FA uacute
!FB U+00FB ucircumflex
!FC U+00FC udieresis
!FD U+00FD yacute
!FE U+00FE thorn
!FF U+00FF ydieresis

View File

@@ -1,157 +0,0 @@
// Package dossier erzeugt das Nachweis-Dossier (PDF) aus Submission,
// Findings, Verantwortungsmatrix und Beweiskette. Die Aufbereitung des
// Inhalts (BuildContent) ist von der eigentlichen PDF-Zeichnung
// (Render) getrennt, damit die fachliche Logik — was steht wo im
// Dossier, in welcher Form — ohne PDF-Parsing testbar ist.
package dossier
import (
"encoding/hex"
"fmt"
"time"
"github.com/netcell-it/deklarix/internal/evidence"
"github.com/netcell-it/deklarix/internal/rules"
)
// Disclaimer steht auf jedem erzeugten Dossier. Siehe CLAUDE.md,
// Leitplanken: Deklarix ist ein Werkzeug, keine Rechtsberatung.
const Disclaimer = "Dieses Dossier dokumentiert die durchgeführte Prüfung. " +
"Es ist keine Rechtsberatung und ersetzt keine anwaltliche Prüfung im Einzelfall."
// Submission sind die Basisdaten des geprüften Beitrags.
type Submission struct {
Platform string
PostType string
Caption string
CreatedAt time.Time
}
// Participant ist ein Beteiligter aus der Verantwortungsmatrix.
type Participant struct {
Role string
Name string
Vorgegeben bool
Freigegeben bool
}
// Data ist die Eingabe für Generate/BuildContent — alles, was ein
// Dossier für einen Beitrag braucht.
type Data struct {
Submission Submission
Facts rules.Facts
Findings []rules.Finding
Participants []Participant
AssetHash []byte
MetadataHash []byte
TimestampToken []byte
GeneratedAt time.Time
}
// FindingRow ist die dossier-taugliche Aufbereitung eines rules.Finding.
type FindingRow struct {
RuleID string
Version int
Severity string
Title string
Fix string
Sources []string
}
// ParticipantRow ist die dossier-taugliche Aufbereitung eines Participant.
type ParticipantRow struct {
Role string
Name string
Vorgegeben bool
Freigegeben bool
}
// Content ist die fertig aufbereitete, PDF-unabhängige Darstellung
// eines Dossiers.
type Content struct {
Title string
Platform string
PostType string
Caption string
SubmittedAt time.Time
GeneratedAt time.Time
DisclosurePresent bool
DisclosureWording string
DisclosureBeforeCut bool
Findings []FindingRow
Participants []ParticipantRow
AssetHashHex string
MetadataHashHex string
TimestampedAt time.Time
Disclaimer string
}
// BuildContent bereitet Data zu Content auf. Fehlt eine Pflichtangabe
// (Plattform, Zeitstempel-Token), wird ein Fehler geliefert statt ein
// Dossier mit stillschweigend leeren Beweisfeldern zu erzeugen.
func BuildContent(data Data) (Content, error) {
if data.Submission.Platform == "" {
return Content{}, fmt.Errorf("dossier: submission.platform fehlt")
}
if len(data.TimestampToken) == 0 {
return Content{}, fmt.Errorf("dossier: timestamp token fehlt")
}
// AssetHash ist optional: nicht jede Prüfung hat ein hochgeladenes
// Bild/Video (aktuell reine Caption-Prüfungen). Ein erfundener
// Platzhalter-Hash für ein nicht existierendes Asset wäre selbst ein
// Integritätsproblem — also zeigt das Dossier stattdessen "kein
// Asset hinterlegt" an, siehe Render.
if len(data.MetadataHash) == 0 {
return Content{}, fmt.Errorf("dossier: metadata hash fehlt")
}
timestampedAt, err := evidence.TimestampTime(data.TimestampToken)
if err != nil {
return Content{}, fmt.Errorf("dossier: timestamp token: %w", err)
}
findings := make([]FindingRow, len(data.Findings))
for i, f := range data.Findings {
findings[i] = FindingRow{
RuleID: f.RuleID,
Version: f.RuleVersion,
Severity: string(f.Severity),
Title: f.Title,
Fix: f.Fix,
Sources: f.Sources,
}
}
participants := make([]ParticipantRow, len(data.Participants))
for i, p := range data.Participants {
participants[i] = ParticipantRow{
Role: p.Role,
Name: p.Name,
Vorgegeben: p.Vorgegeben,
Freigegeben: p.Freigegeben,
}
}
return Content{
Title: "Nachweis-Dossier",
Platform: data.Submission.Platform,
PostType: data.Submission.PostType,
Caption: data.Submission.Caption,
SubmittedAt: data.Submission.CreatedAt,
GeneratedAt: data.GeneratedAt,
DisclosurePresent: data.Facts.DisclosurePresent,
DisclosureWording: data.Facts.DisclosureWording,
DisclosureBeforeCut: data.Facts.DisclosureBeforeCut,
Findings: findings,
Participants: participants,
AssetHashHex: hex.EncodeToString(data.AssetHash),
MetadataHashHex: hex.EncodeToString(data.MetadataHash),
TimestampedAt: timestampedAt,
Disclaimer: Disclaimer,
}, nil
}

View File

@@ -1,166 +0,0 @@
package dossier_test
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"math/big"
"testing"
"time"
"github.com/digitorus/timestamp"
"github.com/netcell-it/deklarix/internal/dossier"
"github.com/netcell-it/deklarix/internal/evidence"
"github.com/netcell-it/deklarix/internal/rules"
)
// fakeTimestampToken erzeugt einen strukturell gültigen, selbstsignierten
// RFC-3161-Token für hash — offline, ohne echte TSA. Damit lassen sich
// BuildContent/Render testen, ohne bei jedem Testlauf eine echte
// Time-Stamp Authority anzufragen (die Echtheit/Vertrauenswürdigkeit
// des Zertifikats spielt für diese Tests keine Rolle, nur dass der Token
// strukturell parsbar ist wie ein echter).
func fakeTimestampToken(t *testing.T, hash []byte, at time.Time) []byte {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
certTemplate := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "deklarix-test-tsa"},
NotBefore: at.Add(-time.Hour),
NotAfter: at.Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageTimeStamping},
}
certDER, err := x509.CreateCertificate(rand.Reader, certTemplate, certTemplate, &key.PublicKey, key)
if err != nil {
t.Fatalf("create certificate: %v", err)
}
cert, err := x509.ParseCertificate(certDER)
if err != nil {
t.Fatalf("parse certificate: %v", err)
}
ts := timestamp.Timestamp{
HashAlgorithm: crypto.SHA256,
HashedMessage: hash,
Time: at,
SerialNumber: big.NewInt(1),
Policy: asn1.ObjectIdentifier{1, 2, 3},
}
respDER, err := ts.CreateResponse(cert, key)
if err != nil {
t.Fatalf("create timestamp response: %v", err)
}
parsed, err := timestamp.ParseResponse(respDER)
if err != nil {
t.Fatalf("parse fake timestamp response: %v", err)
}
return parsed.RawToken
}
func validData(t *testing.T) dossier.Data {
t.Helper()
hash := evidence.HashBytes([]byte("caption+bild"))
at := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)
return dossier.Data{
Submission: dossier.Submission{
Platform: "instagram",
PostType: "reel",
Caption: "Werbung für ein Produkt äöüß",
CreatedAt: at,
},
Facts: rules.Facts{
DisclosurePresent: true,
DisclosureWording: "Werbung",
DisclosureBeforeCut: false,
},
Findings: []rules.Finding{
{RuleID: "WK-004", RuleVersion: 1, Severity: rules.SeverityHigh, Title: "t", Fix: "f", Sources: []string{"§ 5a Abs. 4 UWG"}},
},
Participants: []dossier.Participant{
{Role: "creator", Name: "Max Mustermann", Vorgegeben: false, Freigegeben: true},
},
AssetHash: hash,
MetadataHash: evidence.HashBytes([]byte("metadata")),
TimestampToken: fakeTimestampToken(t, hash, at),
GeneratedAt: at,
}
}
func TestBuildContentRejectsMissingPlatform(t *testing.T) {
data := validData(t)
data.Submission.Platform = ""
if _, err := dossier.BuildContent(data); err == nil {
t.Fatal("expected error for missing platform, got nil")
}
}
func TestBuildContentRejectsMissingTimestampToken(t *testing.T) {
data := validData(t)
data.TimestampToken = nil
if _, err := dossier.BuildContent(data); err == nil {
t.Fatal("expected error for missing timestamp token, got nil")
}
}
func TestBuildContentRejectsMissingMetadataHash(t *testing.T) {
data := validData(t)
data.MetadataHash = nil
if _, err := dossier.BuildContent(data); err == nil {
t.Fatal("expected error for missing metadata hash, got nil")
}
}
func TestBuildContentAllowsMissingAssetHash(t *testing.T) {
// Reine Caption-Pruefungen ohne hochgeladenes Bild/Video haben kein
// Asset zum Hashen — das ist kein Fehlerfall.
data := validData(t)
data.AssetHash = nil
content, err := dossier.BuildContent(data)
if err != nil {
t.Fatalf("BuildContent: %v", err)
}
if content.AssetHashHex != "" {
t.Fatalf("AssetHashHex = %q, want empty when no asset was provided", content.AssetHashHex)
}
}
func TestBuildContentSuccess(t *testing.T) {
data := validData(t)
content, err := dossier.BuildContent(data)
if err != nil {
t.Fatalf("BuildContent: %v", err)
}
if content.Platform != "instagram" {
t.Errorf("Platform = %q, want instagram", content.Platform)
}
if len(content.Findings) != 1 || content.Findings[0].RuleID != "WK-004" {
t.Errorf("Findings = %+v, want one WK-004 row", content.Findings)
}
if len(content.Participants) != 1 || content.Participants[0].Name != "Max Mustermann" {
t.Errorf("Participants = %+v, want one Max-Mustermann row", content.Participants)
}
if len(content.AssetHashHex) != 64 {
t.Errorf("AssetHashHex length = %d, want 64 (hex of 32-byte SHA-256)", len(content.AssetHashHex))
}
if !content.TimestampedAt.Equal(data.Submission.CreatedAt) {
t.Errorf("TimestampedAt = %v, want %v", content.TimestampedAt, data.Submission.CreatedAt)
}
if content.Disclaimer == "" {
t.Error("expected a non-empty disclaimer")
}
}

View File

@@ -1,129 +0,0 @@
package dossier
import (
"bytes"
"embed"
"fmt"
"io"
"strings"
"github.com/go-pdf/fpdf"
)
//go:embed assets/cp1252.map
var assetsFS embed.FS
// Generate erzeugt das PDF-Dossier für data und schreibt es nach w.
func Generate(w io.Writer, data Data) error {
content, err := BuildContent(data)
if err != nil {
return err
}
return Render(w, content)
}
// Render zeichnet ein bereits aufbereitetes Content als PDF nach w.
func Render(w io.Writer, c Content) error {
tr, err := unicodeTranslator()
if err != nil {
return fmt.Errorf("dossier: unicode translator: %w", err)
}
pdf := fpdf.New("P", "mm", "A4", "")
pdf.SetTitle(tr(c.Title), false)
pdf.AddPage()
heading := func(text string) {
pdf.SetFont("Helvetica", "B", 12)
pdf.CellFormat(0, 8, tr(text), "", 1, "L", false, 0, "")
pdf.SetFont("Helvetica", "", 10)
}
line := func(format string, args ...any) {
pdf.CellFormat(0, 6, tr(fmt.Sprintf(format, args...)), "", 1, "L", false, 0, "")
}
paragraph := func(text string) {
pdf.MultiCell(0, 5, tr(text), "", "L", false)
}
pdf.SetFont("Helvetica", "B", 16)
pdf.CellFormat(0, 10, tr(c.Title), "", 1, "L", false, 0, "")
pdf.SetFont("Helvetica", "", 10)
line("Erzeugt am %s", c.GeneratedAt.Format("02.01.2006 15:04 MST"))
pdf.Ln(4)
heading("Beitrag")
line("Plattform: %s Typ: %s", c.Platform, c.PostType)
line("Eingereicht am: %s", c.SubmittedAt.Format("02.01.2006 15:04"))
paragraph("Caption: " + c.Caption)
pdf.Ln(2)
heading("Kennzeichnung")
line("Vorhanden: %s Wortlaut: %q Vor Kürzung sichtbar: %s",
yesNo(c.DisclosurePresent), c.DisclosureWording, yesNo(c.DisclosureBeforeCut))
pdf.Ln(2)
heading("Findings")
if len(c.Findings) == 0 {
line("Keine Findings.")
}
for _, f := range c.Findings {
pdf.SetFont("Helvetica", "B", 10)
line("%s v%d — %s (%s)", f.RuleID, f.Version, f.Title, f.Severity)
pdf.SetFont("Helvetica", "", 10)
paragraph("Korrektur: " + f.Fix)
if len(f.Sources) > 0 {
paragraph("Fundstellen: " + strings.Join(f.Sources, "; "))
}
pdf.Ln(1)
}
pdf.Ln(2)
heading("Verantwortungsmatrix")
if len(c.Participants) == 0 {
line("Keine Beteiligten hinterlegt.")
}
for _, p := range c.Participants {
line("%s: %s (vorgegeben: %s, freigegeben: %s)",
p.Role, p.Name, yesNo(p.Vorgegeben), yesNo(p.Freigegeben))
}
pdf.Ln(2)
heading("Beweiskette")
if c.AssetHashHex != "" {
line("Asset-Hash (SHA-256): %s", c.AssetHashHex)
} else {
line("Asset-Hash: kein Asset hinterlegt (reine Caption-Prüfung)")
}
line("Metadaten-Hash (SHA-256): %s", c.MetadataHashHex)
line("RFC-3161-Zeitstempel: %s", c.TimestampedAt.Format("02.01.2006 15:04:05 MST"))
pdf.Ln(4)
pdf.SetFont("Helvetica", "I", 8)
paragraph(c.Disclaimer)
if err := pdf.Error(); err != nil {
return fmt.Errorf("dossier: pdf aufbauen: %w", err)
}
if err := pdf.Output(w); err != nil {
return fmt.Errorf("dossier: pdf schreiben: %w", err)
}
return nil
}
func yesNo(b bool) string {
if b {
return "ja"
}
return "nein"
}
// unicodeTranslator übersetzt UTF-8 (z. B. Umlaute) in die cp1252-
// Kodierung der Helvetica-Kernschriftart. Die Map-Datei ist eingebettet,
// damit Deklarix trotz PDF-Erzeugung ein einzelnes Binary bleibt.
func unicodeTranslator() (func(string) string, error) {
data, err := assetsFS.ReadFile("assets/cp1252.map")
if err != nil {
return nil, err
}
return fpdf.UnicodeTranslator(bytes.NewReader(data))
}

View File

@@ -1,51 +0,0 @@
package dossier_test
import (
"bytes"
"testing"
"github.com/netcell-it/deklarix/internal/dossier"
)
func TestRenderProducesValidPDF(t *testing.T) {
content, err := dossier.BuildContent(validData(t))
if err != nil {
t.Fatalf("BuildContent: %v", err)
}
var buf bytes.Buffer
if err := dossier.Render(&buf, content); err != nil {
t.Fatalf("Render: %v", err)
}
out := buf.Bytes()
if !bytes.HasPrefix(out, []byte("%PDF-")) {
t.Fatalf("output does not start with %%PDF- header: %q", out[:min(20, len(out))])
}
if !bytes.Contains(out, []byte("%%EOF")) {
t.Fatal("output does not contain the expected PDF EOF trailer")
}
if len(out) < 500 {
t.Fatalf("output suspiciously small (%d bytes) for a multi-section dossier", len(out))
}
}
func TestGenerateProducesValidPDF(t *testing.T) {
var buf bytes.Buffer
if err := dossier.Generate(&buf, validData(t)); err != nil {
t.Fatalf("Generate: %v", err)
}
if !bytes.HasPrefix(buf.Bytes(), []byte("%PDF-")) {
t.Fatal("Generate output does not start with the expected PDF header")
}
}
func TestGeneratePropagatesBuildContentErrors(t *testing.T) {
data := validData(t)
data.Submission.Platform = ""
var buf bytes.Buffer
if err := dossier.Generate(&buf, data); err == nil {
t.Fatal("expected Generate to propagate a BuildContent error, got nil")
}
}

View File

@@ -1,32 +0,0 @@
// Package evidence bildet die Beweiskette: Hashing von Assets und
// Metadaten, RFC-3161-Zeitstempel über diesen Hash. Das Append-only-
// Prinzip selbst wird von internal/store (Postgres-Trigger) erzwungen —
// dieses Paket liefert nur die Bausteine, die evidence_package braucht.
package evidence
import (
"crypto/sha256"
"encoding/json"
"fmt"
)
// HashBytes berechnet den SHA-256-Digest über beliebige Bytes, z. B.
// ein Asset wie Screenshot oder Video.
func HashBytes(data []byte) []byte {
sum := sha256.Sum256(data)
return sum[:]
}
// HashMetadata berechnet den SHA-256-Digest über die kanonisierte JSON-
// Repräsentation von v. "Kanonisiert" heißt hier: encoding/json sortiert
// Objektschlüssel bereits deterministisch, und Struct-Felder behalten
// ihre Deklarationsreihenfolge — für unsere eigenen, fest definierten
// Go-Typen ist das schon eine stabile, reproduzierbare Serialisierung,
// ohne dass es eine eigene Kanonisierungs-Bibliothek bräuchte.
func HashMetadata(v any) ([]byte, error) {
data, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("evidence: metadata marshal: %w", err)
}
return HashBytes(data), nil
}

View File

@@ -1,67 +0,0 @@
package evidence_test
import (
"bytes"
"testing"
"github.com/netcell-it/deklarix/internal/evidence"
)
func TestHashBytesIsDeterministic(t *testing.T) {
data := []byte("ein beispiel-asset")
a := evidence.HashBytes(data)
b := evidence.HashBytes(data)
if !bytes.Equal(a, b) {
t.Fatalf("HashBytes ist nicht deterministisch: %x != %x", a, b)
}
if len(a) != 32 {
t.Fatalf("expected 32-byte SHA-256 digest, got %d bytes", len(a))
}
}
func TestHashBytesDiffersForDifferentInput(t *testing.T) {
a := evidence.HashBytes([]byte("foo"))
b := evidence.HashBytes([]byte("bar"))
if bytes.Equal(a, b) {
t.Fatal("expected different hashes for different input, got the same")
}
}
func TestHashMetadataIsStableAcrossMapKeyOrder(t *testing.T) {
m1 := map[string]any{"a": 1, "b": 2, "c": 3}
m2 := map[string]any{"c": 3, "a": 1, "b": 2}
h1, err := evidence.HashMetadata(m1)
if err != nil {
t.Fatalf("HashMetadata(m1): %v", err)
}
h2, err := evidence.HashMetadata(m2)
if err != nil {
t.Fatalf("HashMetadata(m2): %v", err)
}
if !bytes.Equal(h1, h2) {
t.Fatal("HashMetadata should be stable across map key insertion order")
}
}
func TestHashMetadataDiffersForDifferentContent(t *testing.T) {
h1, err := evidence.HashMetadata(map[string]any{"a": 1})
if err != nil {
t.Fatalf("HashMetadata: %v", err)
}
h2, err := evidence.HashMetadata(map[string]any{"a": 2})
if err != nil {
t.Fatalf("HashMetadata: %v", err)
}
if bytes.Equal(h1, h2) {
t.Fatal("expected different hashes for different metadata content")
}
}
func TestHashMetadataRejectsUnmarshalableValue(t *testing.T) {
// Kanäle können nicht als JSON serialisiert werden — muss einen
// Fehler liefern statt still einen falschen Hash zurückzugeben.
if _, err := evidence.HashMetadata(make(chan int)); err == nil {
t.Fatal("expected error for unmarshalable value, got nil")
}
}

View File

@@ -1,18 +0,0 @@
package evidence
import (
"fmt"
"time"
"github.com/digitorus/timestamp"
)
// TimestampTime liefert den im RFC-3161-Token bescheinigten Zeitpunkt,
// z. B. für die Anzeige im Nachweis-Dossier.
func TimestampTime(token []byte) (time.Time, error) {
ts, err := timestamp.Parse(token)
if err != nil {
return time.Time{}, fmt.Errorf("evidence: timestamp token parse: %w", err)
}
return ts.Time, nil
}

View File

@@ -1,13 +0,0 @@
package evidence_test
import (
"testing"
"github.com/netcell-it/deklarix/internal/evidence"
)
func TestTimestampTimeRejectsGarbage(t *testing.T) {
if _, err := evidence.TimestampTime([]byte("not a timestamp token")); err == nil {
t.Fatal("expected error for garbage token bytes, got nil")
}
}

View File

@@ -1,18 +0,0 @@
package evidence
import "context"
// Timestamper fragt einen RFC-3161-Zeitstempel für einen Hash (i. d. R.
// aus HashBytes/HashMetadata) bei einer Time-Stamp Authority (TSA) an
// und liefert den rohen Zeitstempel-Token zurück, wie er unverändert in
// evidence_package.timestamp_token gespeichert wird.
//
// Es gibt hier bewusst noch keine konkrete Implementierung: welche TSA
// verwendet wird, ist in CLAUDE.md als offener Punkt vermerkt (freie
// TSA vs. eIDAS-qualifizierter Zeitstempeldienst — das betrifft direkt
// die Beweiskraft des Archivs und ist keine rein technische
// Entscheidung). Sobald das geklärt ist, implementiert ein konkreter
// Typ dieses Interface.
type Timestamper interface {
Timestamp(ctx context.Context, hash []byte) (token []byte, err error)
}

View File

@@ -1,113 +0,0 @@
package evidence
import (
"bytes"
"context"
"crypto"
"crypto/rand"
"fmt"
"io"
"math/big"
"net/http"
"github.com/digitorus/timestamp"
)
// DefaultTSAURL ist eine freie, RFC-3161-konforme Time-Stamp Authority.
//
// NICHT eIDAS-qualifiziert — reicht, um die Beweiskette technisch
// funktionsfähig zu haben, aber vor echtem Kundeneinsatz auf einen
// eIDAS-qualifizierten Zeitstempeldienst umstellen (z. B. D-Trust,
// Bundesdruckerei), der eine gesetzliche Vermutungswirkung nach
// eIDAS Art. 41 hat. Siehe CLAUDE.md, Offene Punkte.
const DefaultTSAURL = "https://freetsa.org/tsr"
// HTTPTimestamper implementiert Timestamper gegen eine RFC-3161-TSA
// über HTTP (application/timestamp-query, siehe RFC 3161 Abschnitt 3.4).
type HTTPTimestamper struct {
url string
httpClient *http.Client
}
// TimestamperOption konfiguriert einen HTTPTimestamper.
type TimestamperOption func(*HTTPTimestamper)
// WithTimestamperHTTPClient überschreibt den verwendeten *http.Client
// (für Tests).
func WithTimestamperHTTPClient(hc *http.Client) TimestamperOption {
return func(t *HTTPTimestamper) { t.httpClient = hc }
}
// NewHTTPTimestamper erstellt einen Timestamper. Ein leerer url-Wert
// verwendet DefaultTSAURL.
func NewHTTPTimestamper(url string, opts ...TimestamperOption) *HTTPTimestamper {
if url == "" {
url = DefaultTSAURL
}
t := &HTTPTimestamper{url: url, httpClient: http.DefaultClient}
for _, opt := range opts {
opt(t)
}
return t
}
// Timestamp fragt einen RFC-3161-Zeitstempel für hash (ein SHA-256-
// Digest) an und liefert den rohen TimeStampToken zurück. Die Antwort
// wird gegen den angefragten Hash und die gesendete Nonce geprüft,
// bevor der Token akzeptiert wird — eine TSA-Antwort, die nicht zum
// eigenen Request passt, ist kein gültiger Zeitstempel für diesen Hash.
func (t *HTTPTimestamper) Timestamp(ctx context.Context, hash []byte) ([]byte, error) {
if len(hash) != crypto.SHA256.Size() {
return nil, fmt.Errorf("evidence: timestamp erwartet einen SHA-256-Digest (%d Bytes), bekam %d", crypto.SHA256.Size(), len(hash))
}
nonce, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 64))
if err != nil {
return nil, fmt.Errorf("evidence: nonce generieren: %w", err)
}
req := timestamp.Request{
HashAlgorithm: crypto.SHA256,
HashedMessage: hash,
Nonce: nonce,
Certificates: true,
}
reqBytes, err := req.Marshal()
if err != nil {
return nil, fmt.Errorf("evidence: timestamp request marshal: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, t.url, bytes.NewReader(reqBytes))
if err != nil {
return nil, fmt.Errorf("evidence: request bauen: %w", err)
}
httpReq.Header.Set("Content-Type", "application/timestamp-query")
resp, err := t.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("evidence: TSA-Request fehlgeschlagen: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("evidence: TSA-Response lesen: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("evidence: TSA-Status %d: %s", resp.StatusCode, string(body))
}
ts, err := timestamp.ParseResponse(body)
if err != nil {
return nil, fmt.Errorf("evidence: TSA-Response parse: %w", err)
}
if ts.HashAlgorithm != crypto.SHA256 || !bytes.Equal(ts.HashedMessage, hash) {
return nil, fmt.Errorf("evidence: TSA-Antwort passt nicht zum angefragten Hash")
}
if ts.Nonce == nil || ts.Nonce.Cmp(nonce) != 0 {
return nil, fmt.Errorf("evidence: TSA-Antwort hat falsche oder fehlende Nonce")
}
return ts.RawToken, nil
}

View File

@@ -1,142 +0,0 @@
package evidence_test
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/digitorus/timestamp"
"github.com/netcell-it/deklarix/internal/evidence"
)
func TestTimestampRejectsWrongHashSize(t *testing.T) {
called := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
}))
defer server.Close()
ts := evidence.NewHTTPTimestamper(server.URL)
_, err := ts.Timestamp(context.Background(), []byte("zu kurz"))
if err == nil {
t.Fatal("expected error for a non-SHA-256-sized hash, got nil")
}
if called {
t.Fatal("expected no HTTP call for an invalid hash size")
}
}
// TestTimestampSendsValidRequest prüft serverseitig, dass Timestamp()
// einen tatsächlich gültigen, RFC-3161-konformen Request schickt (parsbar,
// korrekter Hash, korrekter Algorithmus, Nonce gesetzt) — ohne dafür eine
// echte signierte Antwort fälschen zu müssen.
func TestTimestampSendsValidRequest(t *testing.T) {
hash := evidence.HashBytes([]byte("test-content"))
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if ct := r.Header.Get("Content-Type"); ct != "application/timestamp-query" {
t.Errorf("Content-Type = %q, want application/timestamp-query", ct)
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read request body: %v", err)
}
req, err := timestamp.ParseRequest(body)
if err != nil {
t.Errorf("ParseRequest: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
if !bytes.Equal(req.HashedMessage, hash) {
t.Errorf("HashedMessage = %x, want %x", req.HashedMessage, hash)
}
if req.Nonce == nil {
t.Error("expected a nonce to be set on the request")
}
// Keine echte TSA hier — nur die Request-Validierung interessiert
// dieser Test. Ein absichtlich ungültiger Response-Body lässt
// Timestamp() mit einem Parse-Fehler zurückkommen, was für diesen
// Test in Ordnung ist.
w.WriteHeader(http.StatusOK)
w.Write([]byte("not a valid timestamp response"))
}))
defer server.Close()
ts := evidence.NewHTTPTimestamper(server.URL)
_, err := ts.Timestamp(context.Background(), hash)
if err == nil {
t.Fatal("expected a parse error for the fake response, got nil")
}
}
func TestTimestampRejectsNon200(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("tsa down"))
}))
defer server.Close()
ts := evidence.NewHTTPTimestamper(server.URL)
hash := evidence.HashBytes([]byte("x"))
if _, err := ts.Timestamp(context.Background(), hash); err == nil {
t.Fatal("expected error for non-200 TSA status, got nil")
}
}
func TestTimestampRejectsMalformedResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("garbage, not ASN.1 DER"))
}))
defer server.Close()
ts := evidence.NewHTTPTimestamper(server.URL)
hash := evidence.HashBytes([]byte("x"))
if _, err := ts.Timestamp(context.Background(), hash); err == nil {
t.Fatal("expected error for malformed TSA response, got nil")
}
}
// TestTimestampIntegration ruft die echte Standard-TSA (FreeTSA.org) auf.
// Läuft nur, wenn DEKLARIX_TSA_INTEGRATION gesetzt ist — kein Netzwerkzugriff
// in normalen Testläufen (siehe internal/store für dasselbe Muster mit
// DATABASE_URL).
func TestTimestampIntegration(t *testing.T) {
if os.Getenv("DEKLARIX_TSA_INTEGRATION") == "" {
t.Skip("DEKLARIX_TSA_INTEGRATION nicht gesetzt, überspringe echten TSA-Aufruf")
}
hash := evidence.HashBytes([]byte(t.Name()))
ts := evidence.NewHTTPTimestamper("")
token, err := ts.Timestamp(context.Background(), hash)
if err != nil {
t.Fatalf("Timestamp: %v", err)
}
parsed, err := timestamp.Parse(token)
if err != nil {
t.Fatalf("Parse(token): %v", err)
}
if !bytes.Equal(parsed.HashedMessage, hash) {
t.Fatalf("token hash = %x, want %x", parsed.HashedMessage, hash)
}
if parsed.Time.IsZero() {
t.Fatal("expected a non-zero timestamp time")
}
tm, err := evidence.TimestampTime(token)
if err != nil {
t.Fatalf("TimestampTime: %v", err)
}
if !tm.Equal(parsed.Time) {
t.Fatalf("TimestampTime() = %v, want %v", tm, parsed.Time)
}
}

View File

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

View File

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

78
internal/mail/mail.go Normal file
View File

@@ -0,0 +1,78 @@
// Package mail versendet E-Mails (aktuell: Passwort-Zurücksetzen-Links)
// über einen konfigurierten SMTP-Server. Reine Versandlogik, kein
// Template-Rendering — der Aufrufer (internal/web) baut Betreff/Text
// selbst zusammen, damit dieses Paket unabhängig vom Web-Layer bleibt.
package mail
import (
"fmt"
"net/smtp"
)
// Mailer ist die Schnittstelle, die internal/web nutzt — austauschbar
// gegen eine Test-Doppel (siehe FakeMailer), damit Tests keinen echten
// SMTP-Server brauchen.
type Mailer interface {
Send(to, subject, body string) error
}
// Config sind die SMTP-Zugangsdaten, aus Umgebungsvariablen gelesen
// (siehe cmd/deklarix/main.go) — kein neues Geheimnis im Code.
type Config struct {
Host string
Port string
User string
Password string
From string
}
// SMTPMailer versendet über einen echten SMTP-Server (STARTTLS, falls
// vom Server angeboten — das deckt den Standard-Submission-Port 587 ab;
// implizites TLS auf Port 465 wird von net/smtp nicht unterstützt).
type SMTPMailer struct {
cfg Config
}
func NewSMTPMailer(cfg Config) *SMTPMailer {
return &SMTPMailer{cfg: cfg}
}
// Send verschickt eine reine Text-E-Mail. Liefert einen Fehler, wenn
// kein SMTP-Host konfiguriert ist — bewusst kein stiller No-op, ein
// fehlgeschlagener Versand (z. B. Passwort-Reset) muss sichtbar
// scheitern statt eine E-Mail vorzutäuschen, die nie ankommt.
func (m *SMTPMailer) Send(to, subject, body string) error {
if m.cfg.Host == "" {
return fmt.Errorf("mail: SMTP nicht konfiguriert (SMTP_HOST fehlt)")
}
addr := m.cfg.Host + ":" + m.cfg.Port
msg := fmt.Sprintf(
"From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
m.cfg.From, to, subject, body,
)
var auth smtp.Auth
if m.cfg.User != "" {
auth = smtp.PlainAuth("", m.cfg.User, m.cfg.Password, m.cfg.Host)
}
if err := smtp.SendMail(addr, auth, m.cfg.From, []string{to}, []byte(msg)); err != nil {
return fmt.Errorf("mail: senden an %s: %w", to, err)
}
return nil
}
// SentMail protokolliert einen Versand für Tests.
type SentMail struct {
To, Subject, Body string
}
// FakeMailer ist das Test-Doppel für Mailer — sammelt gesendete Mails
// statt sie zu verschicken.
type FakeMailer struct {
Sent []SentMail
}
func (f *FakeMailer) Send(to, subject, body string) error {
f.Sent = append(f.Sent, SentMail{To: to, Subject: subject, Body: body})
return nil
}

View File

@@ -1,42 +1,317 @@
package rules
// Finding ist das Ergebnis einer einzelnen zutreffenden Regel.
type Finding struct {
RuleID string
RuleVersion int
Severity Severity
Title string
Fix string
Sources []string
import (
"encoding/json"
"fmt"
"sort"
"strings"
)
// Antworten ist die im Antrag gespeicherte Fragebogen-Antwort-Menge —
// dieselbe Struktur, die internal/web/antrag_handlers.go erzeugt
// (antwortenFromForm): ein flaches JSON-Objekt mit denselben
// Fakten-Schlüsseln wie in den Regelwerk-YAML-Dateien (b1..b7, c1..c5,
// c2_folge, c3_art).
type Antworten map[string]any
// ParseAntworten liest antrag.antworten (rohes JSON aus der Datenbank).
func ParseAntworten(raw []byte) (Antworten, error) {
if len(raw) == 0 {
return Antworten{}, nil
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("rules: antworten parsen: %w", err)
}
return Antworten(m), nil
}
// Evaluate prüft alle Regeln gegen f und liefert ein Finding für jede
// zutreffende Regel. Reihenfolge folgt der Reihenfolge von rules.
// 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
// Verarbeitungslaender nennt die tatsächlichen Länder statt eines
// groben "EU"/"USA"-Eimers (siehe CLAUDE.md, Werkzeugkatalog) — die
// eu_verarbeitung-Anforderung gilt nur als erfüllt, wenn ALLE
// genannten Länder EU/EWR-Mitgliedstaaten sind.
Verarbeitungslaender []string
TrainingOptOut bool
TrainingStandard bool
// AufbewahrungTage ist nil, wenn der Anbieter keine Aufbewahrungsdauer
// beziffert — das erfüllt eine gesetzte Löschfrist-Anforderung NICHT
// (fail closed), analog zur leeren Länderliste bei eu_verarbeitung.
AufbewahrungTage *int
}
// euEwrLaender sind die Staaten, für die eine Verarbeitung nicht als
// Drittlandtransfer gilt (Art. 44 ff. DSGVO) — die 27 EU-Mitgliedstaaten
// plus die drei über den EWR-Vertrag gleichgestellten Staaten. Die USA
// gehören bewusst NICHT dazu — sie sind ein Drittland wie jedes andere,
// unabhängig vom EU-US Data Privacy Framework (das mildert die
// Transfer-Grundlage, macht die USA aber nicht zum EU/EWR-Gebiet).
var euEwrLaender = map[string]bool{
"Belgien": true, "Bulgarien": true, "Dänemark": true, "Deutschland": true,
"Estland": true, "Finnland": true, "Frankreich": true, "Griechenland": true,
"Irland": true, "Italien": true, "Kroatien": true, "Lettland": true,
"Litauen": true, "Luxemburg": true, "Malta": true, "Niederlande": true,
"Österreich": true, "Polen": true, "Portugal": true, "Rumänien": true,
"Schweden": true, "Slowakei": true, "Slowenien": true, "Spanien": true,
"Tschechien": true, "Ungarn": true, "Zypern": true,
"Island": true, "Liechtenstein": true, "Norwegen": true,
}
// alleLaenderInEUEWR prüft, ob jedes genannte Land EU/EWR ist. Eine
// leere Liste (kein Land benannt) gilt als NICHT erfüllt — "wissen wir
// nicht" darf nie stillschweigend als "ist okay" durchgehen.
func alleLaenderInEUEWR(laender []string) bool {
if len(laender) == 0 {
return false
}
for _, l := range laender {
if !euEwrLaender[l] {
return false
}
}
return true
}
// 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.
//
// Ist f.Consideration "unklar", wird KEINE Bewertung abgegeben —
// needsClarification ist dann true und findings ist immer leer. Das ist
// Absicht (Kernprinzip): eine unsichere Extraktion erzeugt eine
// Rückfrage an den Nutzer, niemals eine stille "keine Findings"-
// Bewertung, die wie "alles in Ordnung" aussähe.
func Evaluate(rules []Rule, f Facts) (findings []Finding, needsClarification bool) {
if f.Consideration == ConsiderationUnclear {
return nil, true
}
for _, r := range rules {
if r.Jurisdiction != f.Jurisdiction {
continue
// loeschfristMaxTage ist die vom Mandanten für die aktuelle Datenklasse
// konfigurierte Frist (siehe store.LoeschfristEinstellung) — nil
// bedeutet "für diesen Mandanten/diese Datenklasse nicht konfiguriert"
// und wird NICHT hart gefiltert (Rückwärtskompatibilität, siehe
// rules/OPEN.md, Punkt 4); ist eine Frist gesetzt, erfüllt ein Werkzeug
// ohne bezifferte Aufbewahrungsdauer sie NICHT (fail closed).
func ErfuelltAnforderung(anforderungID string, w WerkzeugEigenschaften, loeschfristMaxTage *int) bool {
switch anforderungID {
case "avv_erforderlich":
return w.AVVVerfuegbar
case "eu_verarbeitung":
return alleLaenderInEUEWR(w.Verarbeitungslaender)
case "kein_training_auf_eingabe":
return w.TrainingStandard
case "loeschfrist_max_tage":
if loeschfristMaxTage == nil {
return true
}
if r.Condition.Matches(f) {
findings = append(findings, Finding{
RuleID: r.ID,
RuleVersion: r.Version,
Severity: r.Severity,
Title: r.Title,
Fix: r.Fix,
Sources: r.Sources,
})
if w.AufbewahrungTage == nil {
return false
}
return *w.AufbewahrungTage <= *loeschfristMaxTage
default:
return true
}
return findings, false
}
// 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. loeschfristMaxTage siehe
// ErfuelltAnforderung.
func FilterWerkzeuge(kandidaten []WerkzeugKandidat, anforderungIDs []string, loeschfristMaxTage *int) (zulaessig []string, ausgeschlossen []AusschlussGrund) {
for _, k := range kandidaten {
var fehlend []string
for _, reqID := range anforderungIDs {
if !ErfuelltAnforderung(reqID, k.Eigenschaften, loeschfristMaxTage) {
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,247 @@
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}, nil) {
t.Error("erwartet: nicht erfuellt ohne AVV")
}
if !rules.ErfuelltAnforderung("avv_erforderlich", rules.WerkzeugEigenschaften{AVVVerfuegbar: true}, nil) {
t.Error("erwartet: erfuellt mit AVV")
}
}
func TestErfuelltAnforderungEUVerarbeitung(t *testing.T) {
if !rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{Verarbeitungslaender: []string{"Irland"}}, nil) {
t.Error("erwartet: erfuellt bei einem einzelnen EU/EWR-Land")
}
if rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{Verarbeitungslaender: []string{"USA"}}, nil) {
t.Error("erwartet: nicht erfuellt bei USA - die USA sind ein Drittland wie jedes andere")
}
if rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{Verarbeitungslaender: []string{"Irland", "USA"}}, nil) {
t.Error("erwartet: nicht erfuellt, sobald auch nur ein Land nicht EU/EWR ist")
}
if rules.ErfuelltAnforderung("eu_verarbeitung", rules.WerkzeugEigenschaften{}, nil) {
t.Error("erwartet: nicht erfuellt ohne benanntes Land - 'wissen wir nicht' darf nicht als 'ist okay' durchgehen")
}
}
func TestErfuelltAnforderungLoeschfrist(t *testing.T) {
dreissig := 30
neunzig := 90
// Keine Frist konfiguriert -> nicht hart gefiltert (Rückwärtskompatibilität).
if !rules.ErfuelltAnforderung("loeschfrist_max_tage", rules.WerkzeugEigenschaften{}, nil) {
t.Error("erwartet: erfuellt ohne konfigurierte Frist")
}
// Frist gesetzt, Werkzeug bleibt innerhalb -> erfuellt.
if !rules.ErfuelltAnforderung("loeschfrist_max_tage", rules.WerkzeugEigenschaften{AufbewahrungTage: &dreissig}, &neunzig) {
t.Error("erwartet: erfuellt, wenn Aufbewahrung <= Frist")
}
// Frist gesetzt, Werkzeug überschreitet -> nicht erfuellt.
if rules.ErfuelltAnforderung("loeschfrist_max_tage", rules.WerkzeugEigenschaften{AufbewahrungTage: &neunzig}, &dreissig) {
t.Error("erwartet: nicht erfuellt, wenn Aufbewahrung > Frist")
}
// Frist gesetzt, Werkzeug beziffert Aufbewahrung nicht -> fail closed.
if rules.ErfuelltAnforderung("loeschfrist_max_tage", rules.WerkzeugEigenschaften{}, &dreissig) {
t.Error("erwartet: nicht erfuellt, wenn Aufbewahrungsdauer unbekannt ist ('wissen wir nicht' darf nicht als 'ist okay' durchgehen)")
}
}
func TestErfuelltAnforderungUnbekannteAnforderungIstUnkritisch(t *testing.T) {
if !rules.ErfuelltAnforderung("menschliche_aufsicht", rules.WerkzeugEigenschaften{}, nil) {
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, Verarbeitungslaender: []string{"Deutschland"}, TrainingStandard: true}},
{ID: "ohne-avv", Eigenschaften: rules.WerkzeugEigenschaften{AVVVerfuegbar: false, Verarbeitungslaender: []string{"Deutschland"}, TrainingStandard: true}},
}
zulaessig, ausgeschlossen := rules.FilterWerkzeuge(kandidaten, []string{"avv_erforderlich"}, nil)
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, nil)
if len(zulaessig) != 2 || len(ausgeschlossen) != 0 {
t.Fatalf("zulaessig=%v ausgeschlossen=%v, want beide zulaessig", zulaessig, ausgeschlossen)
}
}
func TestParseAntwortenRoundTrip(t *testing.T) {
raw := []byte(`{"b1":"ja","c2":true,"c2_folge":"bildung"}`)
a, err := rules.ParseAntworten(raw)
if err != nil {
t.Fatalf("ParseAntworten: %v", err)
}
if a["b1"] != "ja" || a["c2_folge"] != "bildung" {
t.Fatalf("ParseAntworten = %+v, unerwartete Werte", a)
}
}
func TestParseAntwortenEmpty(t *testing.T) {
a, err := rules.ParseAntworten(nil)
if err != nil {
t.Fatalf("ParseAntworten: %v", err)
}
if len(a) != 0 {
t.Fatalf("ParseAntworten(nil) = %+v, want empty", a)
}
}

View File

@@ -1,29 +0,0 @@
package rules
// Consideration ist die Gegenleistung für einen Beitrag, wie sie die
// Extraktion (Stufe 1) liefert.
type Consideration string
const (
ConsiderationPaid Consideration = "bezahlt"
ConsiderationInKind Consideration = "sachbezug"
ConsiderationNone Consideration = "keine"
ConsiderationUnclear Consideration = "unklar"
)
// Facts sind die Fakten aus der Extraktion (Stufe 1), auf denen die
// Regelauswertung (Stufe 2) urteilt. Die Extraktion liefert diese Werte,
// sie bewertet sie nicht — das Urteil fällt ausschließlich das Regelwerk.
//
// Jurisdiction ist keine vom Modell extrahierte Tatsache (aus Caption/
// Bild lässt sich keine Rechtsordnung ablesen), sondern kommt vom
// Aufrufer — analog zu Platform. Aktuell ist "DE" der einzig unterstützte
// Wert; siehe Rule.Jurisdiction.
type Facts struct {
Platform string `json:"platform"`
Jurisdiction string `json:"jurisdiction"`
Consideration Consideration `json:"consideration"`
DisclosurePresent bool `json:"disclosure_present"`
DisclosureWording string `json:"disclosure_wording"`
DisclosureBeforeCut bool `json:"disclosure_before_cut"`
}

View File

@@ -1,106 +0,0 @@
package rules_test
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"strconv"
"testing"
"github.com/netcell-it/deklarix/internal/rules"
)
// goldenCase spiegelt eine Datei aus testdata/golden/: die extrahierten
// Fakten eines Beispielbeitrags plus die Findings, die das Regelwerk
// dafür liefern muss. Das ist das eigentliche Asset des Projekts, nicht
// die UI — siehe CLAUDE.md.
type goldenCase struct {
Name string `json:"name"`
Facts rules.Facts `json:"facts"`
ExpectedFindings []goldenFinding `json:"expected_findings"`
ExpectNeedsClarification bool `json:"expect_needs_clarification"`
}
type goldenFinding struct {
RuleID string `json:"rule_id"`
RuleVersion int `json:"rule_version"`
Severity string `json:"severity"`
}
const (
rulesDir = "../../rules"
goldenDir = "../../testdata/golden"
)
func TestGolden(t *testing.T) {
ruleSet, err := rules.Load(os.DirFS(rulesDir))
if err != nil {
t.Fatalf("Load rules: %v", err)
}
entries, err := os.ReadDir(goldenDir)
if err != nil {
t.Fatalf("read golden dir: %v", err)
}
found := 0
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
continue
}
found++
entry := entry
t.Run(entry.Name(), func(t *testing.T) {
data, err := os.ReadFile(filepath.Join(goldenDir, entry.Name()))
if err != nil {
t.Fatalf("read %s: %v", entry.Name(), err)
}
var gc goldenCase
if err := json.Unmarshal(data, &gc); err != nil {
t.Fatalf("parse %s: %v", entry.Name(), err)
}
got, needsClarification := rules.Evaluate(ruleSet, gc.Facts)
if needsClarification != gc.ExpectNeedsClarification {
t.Fatalf("%s: needsClarification = %v, want %v", gc.Name, needsClarification, gc.ExpectNeedsClarification)
}
gotKeys := make([]string, 0, len(got))
for _, f := range got {
gotKeys = append(gotKeys, findingKey(f.RuleID, f.RuleVersion, string(f.Severity)))
}
wantKeys := make([]string, 0, len(gc.ExpectedFindings))
for _, ef := range gc.ExpectedFindings {
wantKeys = append(wantKeys, findingKey(ef.RuleID, ef.RuleVersion, ef.Severity))
}
sort.Strings(gotKeys)
sort.Strings(wantKeys)
if !equalStrings(gotKeys, wantKeys) {
t.Fatalf("%s: got findings %v, want %v", gc.Name, gotKeys, wantKeys)
}
})
}
if found == 0 {
t.Fatal("no golden cases found in " + goldenDir)
}
}
func findingKey(ruleID string, version int, severity string) string {
return ruleID + "/" + strconv.Itoa(version) + "/" + severity
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

View File

@@ -3,50 +3,109 @@ package rules
import (
"fmt"
"io/fs"
"path/filepath"
"gopkg.in/yaml.v3"
)
// Load liest alle *.yaml-Dateien aus fsys (nicht rekursiv) und parst sie
// als Regeln. Ein Fehler in einer Datei (Parse-Fehler, fehlende ID/
// Version) bricht das Laden komplett ab, statt die Datei stillschweigend
// zu überspringen — ein halb geladenes Regelwerk ist gefährlicher als
// ein Start, der mit einem klaren Fehler abbricht.
func Load(fsys fs.FS) ([]Rule, error) {
entries, err := fs.ReadDir(fsys, ".")
// LoadDatenklasse lädt und validiert die Datenklasse-Ableitungstabelle.
func LoadDatenklasse(fsys fs.FS, path string) (DatenklasseRegelwerk, error) {
var rw DatenklasseRegelwerk
data, err := fs.ReadFile(fsys, path)
if err != nil {
return nil, fmt.Errorf("rules: read dir: %w", err)
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse lesen: %w", err)
}
seen := make(map[string]bool)
var result []Rule
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".yaml" {
continue
}
data, err := fs.ReadFile(fsys, entry.Name())
if err != nil {
return nil, fmt.Errorf("rules: read %s: %w", entry.Name(), err)
}
var r Rule
if err := yaml.Unmarshal(data, &r); err != nil {
return nil, fmt.Errorf("rules: parse %s: %w", entry.Name(), err)
}
if r.ID == "" {
return nil, fmt.Errorf("rules: %s: missing id", entry.Name())
}
if r.Version == 0 {
return nil, fmt.Errorf("rules: %s: missing version", entry.Name())
}
if r.Jurisdiction == "" {
return nil, fmt.Errorf("rules: %s: missing land (jurisdiction)", entry.Name())
}
if seen[r.ID] {
return nil, fmt.Errorf("rules: %s: duplicate rule id %s", entry.Name(), r.ID)
}
seen[r.ID] = true
result = append(result, r)
if err := yaml.Unmarshal(data, &rw); err != nil {
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse parsen: %w", err)
}
return result, nil
if rw.Version < 1 {
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: version fehlt oder ungültig")
}
if len(rw.Stufen) == 0 {
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: keine stufen definiert")
}
seenIDs := map[string]bool{}
seenRaenge := map[int]string{}
for _, st := range rw.Stufen {
if st.ID == "" {
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: stufe ohne id")
}
if seenIDs[st.ID] {
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: doppelte stufe-id %q", st.ID)
}
seenIDs[st.ID] = true
if other, ok := seenRaenge[st.Rang]; ok {
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: stufen %q und %q teilen sich rang %d — \"höchste Stufe gewinnt\" braucht eindeutige Ränge", st.ID, other, st.Rang)
}
seenRaenge[st.Rang] = st.ID
if len(st.Ausloeser) == 0 {
return DatenklasseRegelwerk{}, fmt.Errorf("rules: datenklasse: stufe %q hat keine ausloeser", st.ID)
}
}
return rw, nil
}
// LoadEinstufung lädt und validiert die KI-VO-Einstufungstabelle.
func LoadEinstufung(fsys fs.FS, path string) (EinstufungRegelwerk, error) {
var rw EinstufungRegelwerk
data, err := fs.ReadFile(fsys, path)
if err != nil {
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung lesen: %w", err)
}
if err := yaml.Unmarshal(data, &rw); err != nil {
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung parsen: %w", err)
}
if rw.Version < 1 {
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: version fehlt oder ungültig")
}
if len(rw.Stufen) == 0 {
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: keine stufen definiert")
}
seenIDs := map[string]bool{}
for i, st := range rw.Stufen {
if st.ID == "" {
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: stufe ohne id")
}
if seenIDs[st.ID] {
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: doppelte stufe-id %q", st.ID)
}
seenIDs[st.ID] = true
// Die letzte Stufe darf eine Auffangregel ohne Bedingungen sein
// (z. B. "minimal"), alle davor brauchen mindestens eine Variante.
if len(st.Varianten) == 0 && i != len(rw.Stufen)-1 {
return EinstufungRegelwerk{}, fmt.Errorf("rules: einstufung: stufe %q hat keine varianten (nur die letzte Stufe darf eine Auffangregel sein)", st.ID)
}
}
return rw, nil
}
// LoadAnforderungen lädt und validiert die Anforderungsprofil-Tabelle.
func LoadAnforderungen(fsys fs.FS, path string) (AnforderungsRegelwerk, error) {
var rw AnforderungsRegelwerk
data, err := fs.ReadFile(fsys, path)
if err != nil {
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen lesen: %w", err)
}
if err := yaml.Unmarshal(data, &rw); err != nil {
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen parsen: %w", err)
}
if rw.Version < 1 {
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen: version fehlt oder ungültig")
}
if len(rw.Anforderungen) == 0 {
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen: keine anforderungen definiert")
}
seenIDs := map[string]bool{}
for _, a := range rw.Anforderungen {
if a.ID == "" {
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen: anforderung ohne id")
}
if seenIDs[a.ID] {
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen: doppelte anforderung-id %q", a.ID)
}
seenIDs[a.ID] = true
if len(a.AusDatenklassen) == 0 && len(a.AusEinstufungen) == 0 {
return AnforderungsRegelwerk{}, fmt.Errorf("rules: anforderungen: anforderung %q hat weder aus_datenklassen noch aus_einstufungen", a.ID)
}
}
return rw, nil
}

View File

@@ -0,0 +1,132 @@
package rules_test
import (
"io/fs"
"os"
"testing"
"testing/fstest"
"github.com/netcell-it/deklarix/internal/rules"
)
func realRulesFS(t *testing.T) fs.FS {
t.Helper()
return os.DirFS("../../rules")
}
func TestLoadDatenklasseRealFile(t *testing.T) {
rw, err := rules.LoadDatenklasse(realRulesFS(t), "datenklasse.yaml")
if err != nil {
t.Fatalf("LoadDatenklasse: %v", err)
}
if rw.Version != 1 {
t.Fatalf("Version = %d, want 1", rw.Version)
}
if len(rw.Stufen) != 6 {
t.Fatalf("expected 6 Stufen, got %d", len(rw.Stufen))
}
byID := map[string]rules.DatenklasseStufe{}
for _, st := range rw.Stufen {
byID[st.ID] = st
}
for _, id := range []string{"oeffentlich", "intern", "auftragsdaten", "personenbezogen", "berufsgeheimnis", "besondere_kategorie"} {
if _, ok := byID[id]; !ok {
t.Errorf("expected Stufe %q to be defined", id)
}
}
if byID["besondere_kategorie"].Rang <= byID["personenbezogen"].Rang {
t.Error("expected besondere_kategorie to outrank personenbezogen")
}
}
func TestLoadDatenklasseRejectsDuplicateRang(t *testing.T) {
fsys := fstest.MapFS{
"datenklasse.yaml": &fstest.MapFile{Data: []byte(`
version: 1
stufen:
- {id: a, rang: 1, ausloeser: [b1]}
- {id: b, rang: 1, ausloeser: [b2]}
`)},
}
if _, err := rules.LoadDatenklasse(fsys, "datenklasse.yaml"); err == nil {
t.Fatal("expected an error for duplicate rang values")
}
}
func TestLoadDatenklasseRejectsMissingVersion(t *testing.T) {
fsys := fstest.MapFS{
"datenklasse.yaml": &fstest.MapFile{Data: []byte(`
stufen:
- {id: a, rang: 1, ausloeser: [b1]}
`)},
}
if _, err := rules.LoadDatenklasse(fsys, "datenklasse.yaml"); err == nil {
t.Fatal("expected an error for a missing version")
}
}
func TestLoadEinstufungRealFile(t *testing.T) {
rw, err := rules.LoadEinstufung(realRulesFS(t), "kivo_einstufung.yaml")
if err != nil {
t.Fatalf("LoadEinstufung: %v", err)
}
if len(rw.Stufen) != 4 {
t.Fatalf("expected 4 Stufen, got %d", len(rw.Stufen))
}
if rw.Stufen[0].ID != "verboten" {
t.Fatalf("expected 'verboten' to be checked first (K.-o.-Prüfung), got %q", rw.Stufen[0].ID)
}
if rw.Stufen[len(rw.Stufen)-1].ID != "minimal" {
t.Fatalf("expected 'minimal' as the last (catch-all) Stufe, got %q", rw.Stufen[len(rw.Stufen)-1].ID)
}
}
func TestLoadEinstufungRejectsNonLastStufeWithoutVarianten(t *testing.T) {
fsys := fstest.MapFS{
"kivo_einstufung.yaml": &fstest.MapFile{Data: []byte(`
version: 1
stufen:
- {id: verboten, varianten: []}
- {id: minimal, varianten: []}
`)},
}
if _, err := rules.LoadEinstufung(fsys, "kivo_einstufung.yaml"); err == nil {
t.Fatal("expected an error when a non-last Stufe has no Varianten")
}
}
func TestLoadAnforderungenRealFile(t *testing.T) {
rw, err := rules.LoadAnforderungen(realRulesFS(t), "anforderungen.yaml")
if err != nil {
t.Fatalf("LoadAnforderungen: %v", err)
}
wantIDs := []string{
"avv_erforderlich", "eu_verarbeitung", "kein_training_auf_eingabe",
"loeschfrist_max_tage", "menschliche_aufsicht", "kennzeichnungspflicht", "dsfa_erforderlich",
}
if len(rw.Anforderungen) != len(wantIDs) {
t.Fatalf("expected %d Anforderungen, got %d", len(wantIDs), len(rw.Anforderungen))
}
byID := map[string]bool{}
for _, a := range rw.Anforderungen {
byID[a.ID] = true
}
for _, id := range wantIDs {
if !byID[id] {
t.Errorf("expected Anforderung %q to be defined", id)
}
}
}
func TestLoadAnforderungenRejectsAnforderungWithoutTrigger(t *testing.T) {
fsys := fstest.MapFS{
"anforderungen.yaml": &fstest.MapFile{Data: []byte(`
version: 1
anforderungen:
- {id: x}
`)},
}
if _, err := rules.LoadAnforderungen(fsys, "anforderungen.yaml"); err == nil {
t.Fatal("expected an error for an Anforderung without any trigger")
}
}

View File

@@ -0,0 +1,68 @@
// Package rules lädt das Bewertungsregelwerk (Datenklasse-Ableitung,
// KI-VO-Einstufung, Anforderungsprofil) aus versionierten YAML-Dateien.
// Regelbasiert, nicht ML — die Regeln liegen als Daten vor, nicht im
// Code (siehe CLAUDE.md). Dieses Paket lädt und validiert nur die
// Struktur; die eigentliche Auswertung gegen Fragebogen-Antworten ist
// bewusst noch nicht Teil von Phase 1 (siehe rules/OPEN.md und die
// Baureihenfolge in CLAUDE.md — "Ableitungen und harte Filter" ist ein
// eigener, späterer Schritt, sobald der Fragebogen aus Phase 2 die
// exakten Fakten-Feldnamen festlegt).
package rules
// DatenklasseStufe ist eine mögliche Datenklasse mit ihren Auslösern aus
// Fragebogen-Abschnitt B. Rang bestimmt, welche Stufe gewinnt, wenn
// mehrere Auslöser gleichzeitig zutreffen ("höchste zutreffende Stufe
// gewinnt") — höherer Rang gewinnt.
type DatenklasseStufe struct {
ID string `yaml:"id"`
Rang int `yaml:"rang"`
Ausloeser []string `yaml:"ausloeser"`
Folge string `yaml:"folge"`
}
// DatenklasseRegelwerk ist die vollständige Ableitungstabelle für
// Fragebogen-Abschnitt B.
type DatenklasseRegelwerk struct {
Version int `yaml:"version"`
Stufen []DatenklasseStufe `yaml:"stufen"`
}
// EinstufungVariante ist eine mögliche Kombination von Fragebogen-Fakten
// (Abschnitt C), die zu einer KI-VO-Einstufung führt — mehrere Varianten
// sind ODER-verknüpft, die Felder innerhalb einer Variante UND-verknüpft.
// Bewusst als freie Schlüssel-Werte-Paare statt fester Go-Felder: welche
// Fakten-Schlüssel es gibt, legt der Fragebogen aus Phase 2 fest, nicht
// dieses Paket.
type EinstufungVariante map[string]any
// EinstufungStufe ist eine mögliche KI-VO-Einstufung.
type EinstufungStufe struct {
ID string `yaml:"id"`
Quelle string `yaml:"quelle,omitempty"`
Varianten []EinstufungVariante `yaml:"varianten"`
}
// EinstufungRegelwerk ist die vollständige Ableitungstabelle für
// Fragebogen-Abschnitt C. Die Reihenfolge der Stufen ist Prüfreihenfolge
// (erste zutreffende Stufe gewinnt) — siehe Bewertungslogik, Schritt 1
// (K.-o.-Prüfung: "verboten" muss zuerst geprüft werden).
type EinstufungRegelwerk struct {
Version int `yaml:"version"`
Stufen []EinstufungStufe `yaml:"stufen"`
}
// AnforderungsRegel leitet eine einzelne Anforderung aus Datenklasse
// und/oder KI-VO-Einstufung ab (z. B. avv_erforderlich).
type AnforderungsRegel struct {
ID string `yaml:"id"`
AusDatenklassen []string `yaml:"aus_datenklassen,omitempty"`
AusEinstufungen []string `yaml:"aus_einstufungen,omitempty"`
Beschreibung string `yaml:"beschreibung,omitempty"`
}
// AnforderungsRegelwerk ist die vollständige Mapping-Tabelle
// Datenklasse/Einstufung -> Anforderungsprofil.
type AnforderungsRegelwerk struct {
Version int `yaml:"version"`
Anforderungen []AnforderungsRegel `yaml:"anforderungen"`
}

View File

@@ -1,62 +0,0 @@
package rules
// Severity ist die Schwere eines Findings.
type Severity string
const (
SeverityLow Severity = "niedrig"
SeverityMedium Severity = "mittel"
SeverityHigh Severity = "hoch"
)
// Rule ist eine versionierte Regel aus einer YAML-Datei in rules/.
// Regel-IDs werden nie umbenannt oder wiederverwendet — Änderungen an
// einer Regel erhöhen die Version.
//
// Jurisdiction ordnet eine Regel genau einer Rechtsordnung zu (aktuell
// nur "DE" — deutsche Rechtslage zuerst). Damit lassen sich künftige
// AT/CH-Regeln als zusätzliche Dateien ergänzen, ohne bestehende Regeln
// anzufassen: eine Regel gilt nie für mehrere Rechtsordnungen gleichzeitig,
// auch wenn sich Gesetzestexte ähneln — jede Rechtsordnung bekommt ihre
// eigene, einzeln geprüfte Fundstelle.
type Rule struct {
ID string `yaml:"id"`
Version int `yaml:"version"`
Jurisdiction string `yaml:"land"`
Title string `yaml:"titel"`
Condition Condition `yaml:"bedingung"`
Severity Severity `yaml:"schwere"`
Sources []string `yaml:"fundstelle"`
Fix string `yaml:"korrektur"`
}
// Condition ist eine flache UND-Bedingung über Facts. Ein nil/leeres
// Feld bedeutet "keine Einschränkung durch dieses Feld".
type Condition struct {
Consideration []Consideration `yaml:"gegenleistung,omitempty"`
DisclosurePresent *bool `yaml:"kennzeichnung_vorhanden,omitempty"`
DisclosureBeforeCut *bool `yaml:"kennzeichnung_vor_kuerzung,omitempty"`
}
// Matches prüft, ob f alle gesetzten Bedingungsfelder erfüllt.
func (c Condition) Matches(f Facts) bool {
if len(c.Consideration) > 0 {
found := false
for _, allowed := range c.Consideration {
if allowed == f.Consideration {
found = true
break
}
}
if !found {
return false
}
}
if c.DisclosurePresent != nil && *c.DisclosurePresent != f.DisclosurePresent {
return false
}
if c.DisclosureBeforeCut != nil && *c.DisclosureBeforeCut != f.DisclosureBeforeCut {
return false
}
return true
}

View File

@@ -1,133 +0,0 @@
package rules_test
import (
"testing"
"testing/fstest"
"github.com/netcell-it/deklarix/internal/rules"
)
func TestLoadRejectsMissingID(t *testing.T) {
fsys := fstest.MapFS{
"bad.yaml": &fstest.MapFile{Data: []byte("version: 1\ntitel: x\nschwere: hoch\n")},
}
if _, err := rules.Load(fsys); err == nil {
t.Fatal("expected error for rule without id, got nil")
}
}
func TestLoadRejectsMissingVersion(t *testing.T) {
fsys := fstest.MapFS{
"bad.yaml": &fstest.MapFile{Data: []byte("id: WK-999\ntitel: x\nschwere: hoch\n")},
}
if _, err := rules.Load(fsys); err == nil {
t.Fatal("expected error for rule without version, got nil")
}
}
func TestLoadRejectsMissingJurisdiction(t *testing.T) {
fsys := fstest.MapFS{
"bad.yaml": &fstest.MapFile{Data: []byte("id: WK-999\nversion: 1\ntitel: x\nschwere: hoch\n")},
}
if _, err := rules.Load(fsys); err == nil {
t.Fatal("expected error for rule without land (jurisdiction), got nil")
}
}
func TestLoadRejectsDuplicateID(t *testing.T) {
fsys := fstest.MapFS{
"a.yaml": &fstest.MapFile{Data: []byte("id: WK-999\nversion: 1\nland: DE\ntitel: x\nschwere: hoch\n")},
"b.yaml": &fstest.MapFile{Data: []byte("id: WK-999\nversion: 2\nland: DE\ntitel: y\nschwere: hoch\n")},
}
if _, err := rules.Load(fsys); err == nil {
t.Fatal("expected error for duplicate rule id, got nil")
}
}
func TestLoadIgnoresNonYAMLFiles(t *testing.T) {
fsys := fstest.MapFS{
"a.yaml": &fstest.MapFile{Data: []byte("id: WK-999\nversion: 1\nland: DE\ntitel: x\nschwere: hoch\n")},
"README.md": &fstest.MapFile{Data: []byte("not a rule")},
}
got, err := rules.Load(fsys)
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1 rule, got %d", len(got))
}
}
func TestConditionMatches(t *testing.T) {
yes := true
no := false
cases := []struct {
name string
cond rules.Condition
fact rules.Facts
want bool
}{
{
name: "consideration list matches",
cond: rules.Condition{Consideration: []rules.Consideration{rules.ConsiderationPaid}},
fact: rules.Facts{Consideration: rules.ConsiderationPaid},
want: true,
},
{
name: "consideration list does not match",
cond: rules.Condition{Consideration: []rules.Consideration{rules.ConsiderationPaid}},
fact: rules.Facts{Consideration: rules.ConsiderationNone},
want: false,
},
{
name: "disclosure present must match",
cond: rules.Condition{DisclosurePresent: &no},
fact: rules.Facts{DisclosurePresent: true},
want: false,
},
{
name: "unset fields impose no constraint",
cond: rules.Condition{},
fact: rules.Facts{Consideration: rules.ConsiderationUnclear},
want: true,
},
{
name: "all constraints must hold (AND)",
cond: rules.Condition{
Consideration: []rules.Consideration{rules.ConsiderationPaid},
DisclosurePresent: &yes,
DisclosureBeforeCut: &no,
},
fact: rules.Facts{Consideration: rules.ConsiderationPaid, DisclosurePresent: true, DisclosureBeforeCut: false},
want: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.cond.Matches(tc.fact); got != tc.want {
t.Fatalf("Matches() = %v, want %v", got, tc.want)
}
})
}
}
func TestEvaluateFiltersByJurisdiction(t *testing.T) {
rule := rules.Rule{
ID: "WK-TEST",
Version: 1,
Jurisdiction: "AT",
Condition: rules.Condition{Consideration: []rules.Consideration{rules.ConsiderationPaid}},
Severity: rules.SeverityHigh,
}
fact := rules.Facts{Jurisdiction: "DE", Consideration: rules.ConsiderationPaid}
findings, needsClarification := rules.Evaluate([]rules.Rule{rule}, fact)
if needsClarification {
t.Fatal("needsClarification = true, want false")
}
if len(findings) != 0 {
t.Fatalf("expected no findings for a rule from a different jurisdiction, got %v", findings)
}
}

View File

@@ -0,0 +1,85 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Abteilung ist Stammdatum für den Fragebogen (Feld A.abteilung).
type Abteilung struct {
ID string
AccountID string
Name string
CreatedAt time.Time
}
// CreateAbteilung legt eine Abteilung für einen Mandanten an.
func (s *Store) CreateAbteilung(ctx context.Context, accountID, name string) (Abteilung, error) {
var a Abteilung
err := s.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).Exec(ctx, `DELETE FROM abteilung WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("store: delete abteilung: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}

View File

@@ -0,0 +1,62 @@
package store_test
import (
"context"
"errors"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
func TestAbteilungCRUD(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
a, err := s.CreateAbteilung(ctx, accID, "IT")
if err != nil {
t.Fatalf("CreateAbteilung: %v", err)
}
got, err := s.GetAbteilung(ctx, a.ID)
if err != nil {
t.Fatalf("GetAbteilung: %v", err)
}
if got.Name != "IT" || got.AccountID != accID {
t.Fatalf("GetAbteilung = %+v, unerwartete Werte", got)
}
list, err := s.ListAbteilungenForAccount(ctx, accID)
if err != nil {
t.Fatalf("ListAbteilungenForAccount: %v", err)
}
if len(list) != 1 || list[0].ID != a.ID {
t.Fatalf("ListAbteilungenForAccount = %+v, want exactly the created Abteilung", list)
}
if err := s.DeleteAbteilung(ctx, a.ID); err != nil {
t.Fatalf("DeleteAbteilung: %v", err)
}
if _, err := s.GetAbteilung(ctx, a.ID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err nach Delete = %v, want store.ErrNotFound", err)
}
}
func TestAbteilungIsolatedPerAccount(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accA := testAccountID(t, s)
accB := testAccountID(t, s)
if _, err := s.CreateAbteilung(ctx, accA, "Marketing"); err != nil {
t.Fatalf("CreateAbteilung: %v", err)
}
list, err := s.ListAbteilungenForAccount(ctx, accB)
if err != nil {
t.Fatalf("ListAbteilungenForAccount: %v", err)
}
if len(list) != 0 {
t.Fatalf("expected no Abteilungen for Mandant B, got %+v", list)
}
}

View File

@@ -2,39 +2,172 @@ package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Account ist ein Mandant (Creator, Agentur, Marke oder Kanzlei als
// eigene Organisation). Jeder Beitrag gehört genau einem Account.
// Account ist ein Mandant (ein Unternehmen, das die Antragsprüfung
// nutzt). Jeder Antrag gehört genau einem Account. EinladungToken ist
// der Sammellink für die Mitarbeiter-Selbstanmeldung (Ebene 1,
// "Einladung annehmen") — ein Token pro Account, per Admin erneuerbar.
// Strasse/PLZ/Ort/Land/UStID/Rechnungsemail sind die Firmen- und
// Abrechnungsdaten (Migration 0023) — bei bestehenden, vor dieser
// Migration angelegten Accounts können sie leer sein, siehe dort.
type Account struct {
ID string
Name string
CreatedAt time.Time
ID string
Name string
EinladungToken string
Strasse string
PLZ string
Ort string
Land string
UStID string
Rechnungsemail string
CreatedAt time.Time
}
// CreateAccount legt einen neuen Mandanten an.
func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) {
// AccountInput bündelt die Firmendaten für CreateAccount/
// UpdateAccountDetails. Name ist die einzige Pflichtangabe auf
// Store-Ebene — welche der übrigen Felder ein Formular tatsächlich
// verlangt (z. B. Adresse bei Neuanlage), entscheidet die Web-Schicht,
// nicht der Store (Tests legen Accounts oft ohne vollständige
// Firmendaten an, das ist auf Store-Ebene kein Fehler).
type AccountInput struct {
Name string
Strasse string
PLZ string
Ort string
Land string
UStID string
Rechnungsemail string
}
const accountColumns = `id, name, einladung_token, strasse, plz, ort, land, ust_id, rechnungsemail, created_at`
func scanAccount(row interface {
Scan(dest ...any) error
}) (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)
err := row.Scan(&a.ID, &a.Name, &a.EinladungToken, &a.Strasse, &a.PLZ, &a.Ort, &a.Land, &a.UStID, &a.Rechnungsemail, &a.CreatedAt)
return a, err
}
// CreateAccount legt einen neuen Mandanten an. einladung_token wird von
// der Datenbank per DEFAULT erzeugt (siehe Migration 0013).
func (s *Store) CreateAccount(ctx context.Context, in AccountInput) (Account, error) {
row := s.db(ctx).QueryRow(ctx, `
INSERT INTO account (name, strasse, plz, ort, land, ust_id, rechnungsemail)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING `+accountColumns,
in.Name, in.Strasse, in.PLZ, in.Ort, in.Land, in.UStID, in.Rechnungsemail,
)
a, err := scanAccount(row)
if err != nil {
return Account{}, fmt.Errorf("store: create account: %w", err)
}
return a, nil
}
// UpdateAccount benennt einen Mandanten um (z. B. Tippfehler bei der
// Betreiber-gestützten Anlage korrigieren) — ändert bewusst nur den
// Namen, nicht die übrigen Firmendaten, siehe UpdateAccountDetails.
func (s *Store) UpdateAccount(ctx context.Context, id, name string) (Account, error) {
row := s.db(ctx).QueryRow(ctx, `UPDATE account SET name = $2 WHERE id = $1 RETURNING `+accountColumns, id, name)
a, err := scanAccount(row)
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, ErrNotFound
}
if err != nil {
return Account{}, fmt.Errorf("store: update account: %w", err)
}
return a, nil
}
// UpdateAccountDetails aktualisiert Name und Firmen-/Abrechnungsdaten
// gemeinsam — genutzt von der Firmendaten-Seite (Ebene 4, admin), auf
// der ein Mandant seine eigenen Angaben pflegt/nachträgt.
func (s *Store) UpdateAccountDetails(ctx context.Context, id string, in AccountInput) (Account, error) {
row := s.db(ctx).QueryRow(ctx, `
UPDATE account SET name = $2, strasse = $3, plz = $4, ort = $5, land = $6, ust_id = $7, rechnungsemail = $8
WHERE id = $1
RETURNING `+accountColumns,
id, in.Name, in.Strasse, in.PLZ, in.Ort, in.Land, in.UStID, in.Rechnungsemail,
)
a, err := scanAccount(row)
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, ErrNotFound
}
if err != nil {
return Account{}, fmt.Errorf("store: update account details: %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)
row := s.db(ctx).QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE id = $1`, id)
a, err := scanAccount(row)
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, ErrNotFound
}
if err != nil {
return Account{}, fmt.Errorf("store: get account: %w", err)
}
return a, nil
}
// GetAccountByEinladungToken liest den Mandanten zu einem
// Einladungslink — für die öffentliche Mitarbeiter-Selbstanmeldung
// (Ebene 1, kein Login nötig). Liefert ErrNotFound bei unbekanntem
// oder bereits erneuertem (damit ungültig gewordenem) Token.
func (s *Store) GetAccountByEinladungToken(ctx context.Context, token string) (Account, error) {
row := s.db(ctx).QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE einladung_token = $1`, token)
a, err := scanAccount(row)
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, ErrNotFound
}
if err != nil {
return Account{}, fmt.Errorf("store: get account by einladung token: %w", err)
}
return a, nil
}
// RegenerateEinladungToken ersetzt den Einladungslink eines Mandanten
// durch einen neuen — der alte Link wird damit sofort ungültig (z. B.
// wenn er versehentlich außerhalb des Unternehmens geteilt wurde).
func (s *Store) RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error {
tag, err := s.db(ctx).Exec(ctx, `UPDATE account SET einladung_token = $2 WHERE id = $1`, accountID, newToken)
if err != nil {
return fmt.Errorf("store: regenerate einladung token: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// ListAccounts liefert alle Mandanten, neueste zuerst — für den
// Admin-Bereich (Accounts-Verwaltung).
func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) {
rows, err := s.db(ctx).Query(ctx, `SELECT `+accountColumns+` FROM account ORDER BY created_at DESC`)
if err != nil {
return nil, fmt.Errorf("store: list accounts: %w", err)
}
defer rows.Close()
var out []Account
for rows.Next() {
a, err := scanAccount(rows)
if err != nil {
return nil, fmt.Errorf("store: scan account: %w", err)
}
out = append(out, a)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list accounts: %w", err)
}
return out, nil
}

View File

@@ -0,0 +1,140 @@
package store_test
import (
"context"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
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, store.AccountInput{Name: "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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).Query(ctx, `
SELECT id, actor_user_id, action, target_type, target_id, details, created_at
FROM audit_log ORDER BY created_at DESC LIMIT $1
`, limit)
if err != nil {
return nil, fmt.Errorf("store: list audit log: %w", err)
}
defer rows.Close()
var out []AuditEntry
for rows.Next() {
var e AuditEntry
if err := rows.Scan(&e.ID, &e.ActorUserID, &e.Action, &e.TargetType, &e.TargetID, &e.Details, &e.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan audit entry: %w", err)
}
out = append(out, e)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list audit log: %w", err)
}
return out, nil
}

View File

@@ -13,7 +13,7 @@ func TestAccountCRUD(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH")
acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Beispiel Agentur GmbH"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
@@ -25,6 +25,142 @@ func TestAccountCRUD(t *testing.T) {
if got.Name != "Beispiel Agentur GmbH" {
t.Fatalf("Name = %q, want Beispiel Agentur GmbH", got.Name)
}
if got.EinladungToken == "" {
t.Error("expected a newly created account to have a non-empty EinladungToken")
}
}
func TestCreateAccountMitFirmendaten(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, store.AccountInput{
Name: "Vollstaendig GmbH", Strasse: "Musterstraße 1", PLZ: "12345", Ort: "Musterstadt",
Land: "Deutschland", UStID: "DE123456789", Rechnungsemail: "rechnung@vollstaendig.example.com",
})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if acc.Strasse != "Musterstraße 1" || acc.PLZ != "12345" || acc.Ort != "Musterstadt" ||
acc.Land != "Deutschland" || acc.UStID != "DE123456789" || acc.Rechnungsemail != "rechnung@vollstaendig.example.com" {
t.Fatalf("Account = %+v, Firmendaten unvollständig gespeichert", acc)
}
got, err := s.GetAccount(ctx, acc.ID)
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if got.Rechnungsemail != acc.Rechnungsemail {
t.Fatalf("Rechnungsemail nach GetAccount = %q, want %q", got.Rechnungsemail, acc.Rechnungsemail)
}
}
func TestUpdateAccountDetails(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Alte Firma", Ort: "Altstadt"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
updated, err := s.UpdateAccountDetails(ctx, acc.ID, store.AccountInput{
Name: "Neue Firma", Strasse: "Neue Straße 2", PLZ: "54321", Ort: "Neustadt",
Land: "Österreich", UStID: "", Rechnungsemail: "buchhaltung@neue-firma.example.com",
})
if err != nil {
t.Fatalf("UpdateAccountDetails: %v", err)
}
if updated.Name != "Neue Firma" || updated.Ort != "Neustadt" || updated.Land != "Österreich" {
t.Fatalf("Account nach Update = %+v, nicht wie erwartet aktualisiert", updated)
}
}
func TestUpdateAccountDetailsNotFound(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
_, err := s.UpdateAccountDetails(ctx, "00000000-0000-0000-0000-000000000000", store.AccountInput{Name: "X"})
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
}
func TestUpdateAccount(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Alter Name GmbH"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
updated, err := s.UpdateAccount(ctx, acc.ID, "Neuer Name GmbH")
if err != nil {
t.Fatalf("UpdateAccount: %v", err)
}
if updated.Name != "Neuer Name GmbH" {
t.Fatalf("Name = %q, want Neuer Name GmbH", updated.Name)
}
got, err := s.GetAccount(ctx, acc.ID)
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if got.Name != "Neuer Name GmbH" {
t.Fatalf("Name nach erneutem Laden = %q, want Neuer Name GmbH", got.Name)
}
}
func TestUpdateAccountNotFound(t *testing.T) {
s := openTestStore(t)
_, err := s.UpdateAccount(context.Background(), "00000000-0000-0000-0000-000000000000", "X")
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound", err)
}
}
func TestGetAccountByEinladungToken(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Beispiel Agentur GmbH"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
got, err := s.GetAccountByEinladungToken(ctx, acc.EinladungToken)
if err != nil {
t.Fatalf("GetAccountByEinladungToken: %v", err)
}
if got.ID != acc.ID {
t.Fatalf("GetAccountByEinladungToken returned a different account")
}
_, err = s.GetAccountByEinladungToken(ctx, "unbekanntes-token")
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound for an unknown token", err)
}
}
func TestRegenerateEinladungToken(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Beispiel Agentur GmbH"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
alterToken := acc.EinladungToken
if err := s.RegenerateEinladungToken(ctx, acc.ID, "ein-neues-token"); err != nil {
t.Fatalf("RegenerateEinladungToken: %v", err)
}
if _, err := s.GetAccountByEinladungToken(ctx, alterToken); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("expected the old token to be invalid, got err = %v", err)
}
got, err := s.GetAccountByEinladungToken(ctx, "ein-neues-token")
if err != nil {
t.Fatalf("GetAccountByEinladungToken (neu): %v", err)
}
if got.ID != acc.ID {
t.Fatalf("GetAccountByEinladungToken (neu) returned a different account")
}
}
func TestUserCRUD(t *testing.T) {
@@ -32,7 +168,7 @@ func TestUserCRUD(t *testing.T) {
ctx := context.Background()
accID := testAccountID(t, s)
user, err := s.CreateUser(ctx, accID, "team@example.com", "bcrypt-hash", "agentur")
user, err := s.CreateUser(ctx, accID, "team@example.com", "bcrypt-hash", "mitarbeiter")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
@@ -52,6 +188,49 @@ func TestUserCRUD(t *testing.T) {
if byID.Email != "team@example.com" || byID.AccountID != accID {
t.Fatalf("GetUser = %+v, unerwartete Werte", byID)
}
if !byID.Active {
t.Error("expected a newly created user to be Active by default")
}
}
func TestSetUserActive(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
user, err := s.CreateUser(ctx, accID, "deaktivierbar@example.com", "bcrypt-hash", "mitarbeiter")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
if err := s.SetUserActive(ctx, user.ID, false); err != nil {
t.Fatalf("SetUserActive (false): %v", err)
}
deaktiviert, err := s.GetUser(ctx, user.ID)
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if deaktiviert.Active {
t.Fatal("expected the user to be inactive")
}
if err := s.SetUserActive(ctx, user.ID, true); err != nil {
t.Fatalf("SetUserActive (true): %v", err)
}
reaktiviert, err := s.GetUser(ctx, user.ID)
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if !reaktiviert.Active {
t.Fatal("expected the user to be active again")
}
}
func TestSetUserActiveNotFound(t *testing.T) {
s := openTestStore(t)
err := s.SetUserActive(context.Background(), "00000000-0000-0000-0000-000000000000", false)
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound", err)
}
}
func TestGetUserByEmailNotFound(t *testing.T) {
@@ -67,10 +246,10 @@ func TestUserEmailIsUnique(t *testing.T) {
ctx := context.Background()
accID := testAccountID(t, s)
if _, err := s.CreateUser(ctx, accID, "doppelt@example.com", "hash1", "creator"); err != nil {
if _, err := s.CreateUser(ctx, accID, "doppelt@example.com", "hash1", "mitarbeiter"); err != nil {
t.Fatalf("CreateUser (1): %v", err)
}
if _, err := s.CreateUser(ctx, accID, "doppelt@example.com", "hash2", "creator"); err == nil {
if _, err := s.CreateUser(ctx, accID, "doppelt@example.com", "hash2", "mitarbeiter"); err == nil {
t.Fatal("expected error for a duplicate email, got nil")
}
}
@@ -80,7 +259,7 @@ func TestSessionCRUD(t *testing.T) {
ctx := context.Background()
accID := testAccountID(t, s)
user, err := s.CreateUser(ctx, accID, "session@example.com", "hash", "marke")
user, err := s.CreateUser(ctx, accID, "session@example.com", "hash", "verantwortlicher")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
@@ -98,6 +277,9 @@ func TestSessionCRUD(t *testing.T) {
if got.UserID != user.ID {
t.Fatalf("UserID = %q, want %q", got.UserID, user.ID)
}
if got.ImpersonatedByUserID != nil {
t.Errorf("ImpersonatedByUserID = %v, want nil for a regular session", got.ImpersonatedByUserID)
}
if err := s.DeleteSession(ctx, sess.Token); err != nil {
t.Fatalf("DeleteSession: %v", err)
@@ -114,3 +296,38 @@ func TestGetSessionNotFound(t *testing.T) {
t.Fatalf("err = %v, want store.ErrNotFound", err)
}
}
func TestCreateImpersonatedSession(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
kundenAcc := testAccountID(t, s)
kundenNutzer, err := s.CreateUser(ctx, kundenAcc, "kunde@example.com", "hash", "admin")
if err != nil {
t.Fatalf("CreateUser (Kunde): %v", err)
}
betreiberAcc := testAccountID(t, s)
betreiber, err := s.CreateUser(ctx, betreiberAcc, "betreiber@example.com", "hash", "betreiber")
if err != nil {
t.Fatalf("CreateUser (Betreiber): %v", err)
}
expiresAt := time.Now().Add(time.Hour).Truncate(time.Millisecond)
sess, err := s.CreateImpersonatedSession(ctx, "support-token-123", kundenNutzer.ID, betreiber.ID, expiresAt)
if err != nil {
t.Fatalf("CreateImpersonatedSession: %v", err)
}
if sess.UserID != kundenNutzer.ID {
t.Errorf("UserID = %q, want %q", sess.UserID, kundenNutzer.ID)
}
if sess.ImpersonatedByUserID == nil || *sess.ImpersonatedByUserID != betreiber.ID {
t.Fatalf("ImpersonatedByUserID = %v, want %q", sess.ImpersonatedByUserID, betreiber.ID)
}
got, err := s.GetSession(ctx, sess.Token)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
if got.ImpersonatedByUserID == nil || *got.ImpersonatedByUserID != betreiber.ID {
t.Fatalf("GetSession ImpersonatedByUserID = %v, want %q", got.ImpersonatedByUserID, betreiber.ID)
}
}

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.db(ctx).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.db(ctx).QueryRow(ctx, `
SELECT `+bewertungColumns+`
FROM bewertung WHERE antrag_id = $1 ORDER BY created_at DESC LIMIT 1
`, antragID)
b, err := scanBewertung(row)
if errors.Is(err, pgx.ErrNoRows) {
return Bewertung{}, ErrNotFound
}
if err != nil {
return Bewertung{}, fmt.Errorf("store: get latest bewertung: %w", err)
}
return b, nil
}

View File

@@ -0,0 +1,126 @@
package store_test
import (
"context"
"errors"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
func testBewertungInput(antragID string) store.BewertungInput {
return store.BewertungInput{
AntragID: antragID, Datenklasse: "personenbezogen", DatenklasseHerleitung: "ausgelöst durch b1",
Einstufung: "minimal", EinstufungHerleitung: "Auffangregel", Verboten: false,
Anforderungen: []store.BewertungAnforderung{
{ID: "avv_erforderlich", Beschreibung: "AVV nötig", Herleitung: "aus Datenklasse personenbezogen"},
},
ZulaessigeWerkzeuge: []string{"werkzeug-a"},
AusgeschlosseneWerkzeuge: []store.BewertungAusschluss{
{WerkzeugID: "werkzeug-b", NichtErfuellt: []string{"avv_erforderlich"}},
},
RegelwerkVersion: "dk1.ei1.an1", KatalogVersion: "3-1735300000",
}
}
func TestBewertungCreateAndGetLatest(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
userID := testUserID(t, s, accID)
antrag, err := s.CreateAntrag(ctx, accID, userID, nil, "Testantrag")
if err != nil {
t.Fatalf("CreateAntrag: %v", err)
}
b, err := s.CreateBewertung(ctx, testBewertungInput(antrag.ID))
if err != nil {
t.Fatalf("CreateBewertung: %v", err)
}
if b.Datenklasse != "personenbezogen" || len(b.Anforderungen) != 1 {
t.Fatalf("CreateBewertung = %+v, unerwartete Werte", b)
}
if b.Anforderungen[0].ID != "avv_erforderlich" {
t.Fatalf("Anforderungen[0] = %+v, unerwartet", b.Anforderungen[0])
}
if len(b.AusgeschlosseneWerkzeuge) != 1 || b.AusgeschlosseneWerkzeuge[0].WerkzeugID != "werkzeug-b" {
t.Fatalf("AusgeschlosseneWerkzeuge = %+v, unerwartet", b.AusgeschlosseneWerkzeuge)
}
got, err := s.GetLatestBewertungForAntrag(ctx, antrag.ID)
if err != nil {
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
}
if got.ID != b.ID {
t.Fatalf("GetLatestBewertungForAntrag returned a different row than CreateBewertung")
}
}
func TestGetLatestBewertungForAntragNotFound(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
userID := testUserID(t, s, accID)
antrag, err := s.CreateAntrag(ctx, accID, userID, nil, "Ohne Bewertung")
if err != nil {
t.Fatalf("CreateAntrag: %v", err)
}
_, err = s.GetLatestBewertungForAntrag(ctx, antrag.ID)
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound", err)
}
}
func TestBewertungReturnsNewestWhenMultiple(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
userID := testUserID(t, s, accID)
antrag, err := s.CreateAntrag(ctx, accID, userID, nil, "Mehrfach bewertet")
if err != nil {
t.Fatalf("CreateAntrag: %v", err)
}
if _, err := s.CreateBewertung(ctx, testBewertungInput(antrag.ID)); err != nil {
t.Fatalf("CreateBewertung (1): %v", err)
}
second := testBewertungInput(antrag.ID)
second.Datenklasse = "besondere_kategorie"
newest, err := s.CreateBewertung(ctx, second)
if err != nil {
t.Fatalf("CreateBewertung (2): %v", err)
}
got, err := s.GetLatestBewertungForAntrag(ctx, antrag.ID)
if err != nil {
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
}
if got.ID != newest.ID || got.Datenklasse != "besondere_kategorie" {
t.Fatalf("expected the newest bewertung, got %+v", got)
}
}
func TestBewertungIsAppendOnly(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
userID := testUserID(t, s, accID)
antrag, err := s.CreateAntrag(ctx, accID, userID, nil, "Append-only Test")
if err != nil {
t.Fatalf("CreateAntrag: %v", err)
}
b, err := s.CreateBewertung(ctx, testBewertungInput(antrag.ID))
if err != nil {
t.Fatalf("CreateBewertung: %v", err)
}
_, err = s.Pool.Exec(ctx, `UPDATE bewertung SET datenklasse = 'geaendert' WHERE id = $1`, b.ID)
if err == nil {
t.Fatal("expected UPDATE on bewertung to be rejected by the append-only trigger")
}
_, err = s.Pool.Exec(ctx, `DELETE FROM bewertung WHERE id = $1`, b.ID)
if err == nil {
t.Fatal("expected DELETE on bewertung to be rejected by the append-only trigger")
}
}

View File

@@ -1,211 +0,0 @@
package store_test
import (
"context"
"encoding/json"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
func openTestStore(t *testing.T) *store.Store {
t.Helper()
url := testDatabaseURL(t)
if err := store.Migrate(url); err != nil {
t.Fatalf("Migrate: %v", err)
}
s, err := store.Open(context.Background(), url)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(s.Close)
return s
}
// testAccountID legt einen Mandanten an und liefert dessen ID — jede
// Submission braucht seit der Auth-Migration einen Account.
func testAccountID(t *testing.T, s *store.Store) string {
t.Helper()
acc, err := s.CreateAccount(context.Background(), "Test-Mandant")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
return acc.ID
}
func TestSubmissionCRUD(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
created, err := s.CreateSubmission(ctx, accID, "instagram", "reel", "Werbung fuer ein Produkt")
if err != nil {
t.Fatalf("CreateSubmission: %v", err)
}
if created.Status != "draft" {
t.Fatalf("Status = %q, want draft", created.Status)
}
got, err := s.GetSubmission(ctx, created.ID)
if err != nil {
t.Fatalf("GetSubmission: %v", err)
}
if got.Platform != "instagram" || got.Caption != "Werbung fuer ein Produkt" {
t.Fatalf("GetSubmission = %+v, unerwartete Werte", got)
}
if err := s.SetSubmissionStatus(ctx, created.ID, "checked"); err != nil {
t.Fatalf("SetSubmissionStatus: %v", err)
}
got, err = s.GetSubmission(ctx, created.ID)
if err != nil {
t.Fatalf("GetSubmission nach Statuswechsel: %v", err)
}
if got.Status != "checked" {
t.Fatalf("Status nach SetSubmissionStatus = %q, want checked", got.Status)
}
}
func TestGetSubmissionNotFound(t *testing.T) {
s := openTestStore(t)
if _, err := s.GetSubmission(context.Background(), "00000000-0000-0000-0000-000000000000"); err == nil {
t.Fatal("expected error for a nonexistent submission, got nil")
}
}
func TestSetSubmissionStatusNotFound(t *testing.T) {
s := openTestStore(t)
err := s.SetSubmissionStatus(context.Background(), "00000000-0000-0000-0000-000000000000", "checked")
if err == nil {
t.Fatal("expected error when updating a nonexistent submission, got nil")
}
}
func TestExtractionCRUD(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
sub, err := s.CreateSubmission(ctx, accID, "tiktok", "video", "...")
if err != nil {
t.Fatalf("CreateSubmission: %v", err)
}
payload := []byte(`{"gegenleistung":"bezahlt"}`)
ext, err := s.CreateExtraction(ctx, sub.ID, payload, "claude-sonnet-5", "v1")
if err != nil {
t.Fatalf("CreateExtraction: %v", err)
}
// JSONB normalisiert die Textform (z. B. Leerzeichen nach ':'), der Inhalt
// muss aber semantisch identisch bleiben — kein Byte-Vergleich.
var gotPayload, wantPayload map[string]any
if err := json.Unmarshal(ext.Payload, &gotPayload); err != nil {
t.Fatalf("unmarshal stored payload: %v", err)
}
if err := json.Unmarshal(payload, &wantPayload); err != nil {
t.Fatalf("unmarshal input payload: %v", err)
}
if gotPayload["gegenleistung"] != wantPayload["gegenleistung"] {
t.Fatalf("stored payload = %v, want %v", gotPayload, wantPayload)
}
got, err := s.GetLatestExtraction(ctx, sub.ID)
if err != nil {
t.Fatalf("GetLatestExtraction: %v", err)
}
if got.ID != ext.ID {
t.Fatalf("GetLatestExtraction returned a different row than CreateExtraction")
}
// Eine zweite Extraktion (erneute Pruefung) muss die "latest" sein.
payload2 := []byte(`{"gegenleistung":"keine"}`)
ext2, err := s.CreateExtraction(ctx, sub.ID, payload2, "claude-sonnet-5", "v1")
if err != nil {
t.Fatalf("CreateExtraction (2): %v", err)
}
got, err = s.GetLatestExtraction(ctx, sub.ID)
if err != nil {
t.Fatalf("GetLatestExtraction (2): %v", err)
}
if got.ID != ext2.ID {
t.Fatalf("GetLatestExtraction did not return the most recent extraction")
}
}
func TestFindingCRUDAndSupersedes(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
sub, err := s.CreateSubmission(ctx, accID, "instagram", "reel", "...")
if err != nil {
t.Fatalf("CreateSubmission: %v", err)
}
old, err := s.CreateFinding(ctx, sub.ID, nil, "WK-004", 1, "hoch", "alte Fassung", "alte Korrektur", []string{"§ 5a Abs. 4 UWG"})
if err != nil {
t.Fatalf("CreateFinding (old): %v", err)
}
current, err := s.ListCurrentFindings(ctx, sub.ID)
if err != nil {
t.Fatalf("ListCurrentFindings: %v", err)
}
if len(current) != 1 || current[0].ID != old.ID {
t.Fatalf("expected exactly the old finding before any correction, got %+v", current)
}
if len(current[0].Sources) != 1 || current[0].Sources[0] != "§ 5a Abs. 4 UWG" {
t.Fatalf("Sources = %v, want [§ 5a Abs. 4 UWG]", current[0].Sources)
}
// Korrektur: neue Zeile, die die alte per supersedes ersetzt.
_, err = s.Pool.Exec(ctx, `
INSERT INTO finding (submission_id, rule_id, rule_version, severity, title, fix, sources, supersedes)
VALUES ($1, 'WK-004', 2, 'hoch', 'korrigierte Fassung', 'neue Korrektur', '{}', $2)
`, sub.ID, old.ID)
if err != nil {
t.Fatalf("insert superseding finding: %v", err)
}
current, err = s.ListCurrentFindings(ctx, sub.ID)
if err != nil {
t.Fatalf("ListCurrentFindings nach Korrektur: %v", err)
}
if len(current) != 1 {
t.Fatalf("expected exactly one current finding after a correction, got %d: %+v", len(current), current)
}
if current[0].Title != "korrigierte Fassung" {
t.Fatalf("expected the corrected finding to be current, got %+v", current[0])
}
}
func TestEvidencePackageCRUD(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
sub, err := s.CreateSubmission(ctx, accID, "instagram", "reel", "...")
if err != nil {
t.Fatalf("CreateSubmission: %v", err)
}
token := []byte("fake-rfc3161-token-bytes")
pkg, err := s.CreateEvidencePackage(ctx, sub.ID, "/var/lib/deklarix/dossiers/x.pdf", "deadbeef", token)
if err != nil {
t.Fatalf("CreateEvidencePackage: %v", err)
}
got, err := s.GetLatestEvidencePackage(ctx, sub.ID)
if err != nil {
t.Fatalf("GetLatestEvidencePackage: %v", err)
}
if got.ID != pkg.ID || string(got.TimestampToken) != string(token) {
t.Fatalf("GetLatestEvidencePackage = %+v, unerwartete Werte", got)
}
// Append-only: ein UPDATE muss vom Trigger abgelehnt werden.
_, err = s.Pool.Exec(ctx, `UPDATE evidence_package SET sha256 = 'geaendert' WHERE id = $1`, pkg.ID)
if err == nil {
t.Fatal("expected UPDATE on evidence_package to be rejected, but it succeeded")
}
}

View File

@@ -0,0 +1,116 @@
// E-Mail-Vorlagen (Migration 0022) — zweistufig wie der Werkzeugkatalog:
// AccountID nil = plattformweiter Standard (Betreiber), gesetzt =
// mandantenspezifische Übersteuerung. ResolveEmailVorlage löst beides
// auf: eigene Vorlage, falls vorhanden, sonst der Plattform-Standard.
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
type EmailVorlage struct {
ID string
AccountID *string
Typ string
Betreff string
Text string
UpdatedAt time.Time
}
// UpsertEmailVorlage legt eine Vorlage an oder aktualisiert sie —
// accountID nil schreibt den plattformweiten Standard (nur für den
// Betreiber sinnvoll, RLS erzwingt das zusätzlich auf DB-Ebene).
//
// Zwei unterschiedliche ON-CONFLICT-Ziele, weil SQL NULL nie als gleich
// zu NULL behandelt: ein einzelner Unique-Index über (account_id, typ)
// hätte beliebig viele Plattform-Standard-Zeilen (account_id IS NULL)
// je typ zugelassen, siehe Migration 0022 und den dort dokumentierten
// Bug (ohne diese Aufteilung erzeugte jedes Speichern des Plattform-
// Standards eine neue Zeile statt die bestehende zu aktualisieren).
func (s *Store) UpsertEmailVorlage(ctx context.Context, accountID *string, typ, betreff, text string) (EmailVorlage, error) {
var v EmailVorlage
var err error
if accountID == nil {
err = s.db(ctx).QueryRow(ctx, `
INSERT INTO email_vorlage (account_id, typ, betreff, text)
VALUES (NULL, $1, $2, $3)
ON CONFLICT (typ) WHERE account_id IS NULL
DO UPDATE SET betreff = $2, text = $3, updated_at = now()
RETURNING id, account_id, typ, betreff, text, updated_at
`, typ, betreff, text).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
} else {
err = s.db(ctx).QueryRow(ctx, `
INSERT INTO email_vorlage (account_id, typ, betreff, text)
VALUES ($1, $2, $3, $4)
ON CONFLICT (account_id, typ) WHERE account_id IS NOT NULL
DO UPDATE SET betreff = $3, text = $4, updated_at = now()
RETURNING id, account_id, typ, betreff, text, updated_at
`, accountID, typ, betreff, text).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
}
if err != nil {
return EmailVorlage{}, fmt.Errorf("store: upsert email vorlage: %w", err)
}
return v, nil
}
// GetEmailVorlage liest eine Vorlage exakt (keine Fallback-Auflösung) —
// accountID nil sucht den plattformweiten Standard.
func (s *Store) GetEmailVorlage(ctx context.Context, accountID *string, typ string) (EmailVorlage, error) {
var v EmailVorlage
err := s.db(ctx).QueryRow(ctx, `
SELECT id, account_id, typ, betreff, text, updated_at FROM email_vorlage
WHERE account_id IS NOT DISTINCT FROM $1 AND typ = $2
`, accountID, typ).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return EmailVorlage{}, ErrNotFound
}
if err != nil {
return EmailVorlage{}, fmt.Errorf("store: get email vorlage: %w", err)
}
return v, nil
}
// ResolveEmailVorlage liefert die für einen Mandanten tatsächlich
// wirksame Vorlage: die eigene Übersteuerung, falls vorhanden, sonst
// den plattformweiten Standard (account_id IS NULL). ErrNotFound nur,
// wenn keins von beidem existiert (sollte praktisch nie vorkommen, der
// Plattform-Standard wird per Migration angelegt).
func (s *Store) ResolveEmailVorlage(ctx context.Context, accountID, typ string) (EmailVorlage, error) {
var v EmailVorlage
err := s.db(ctx).QueryRow(ctx, `
SELECT id, account_id, typ, betreff, text, updated_at FROM email_vorlage
WHERE typ = $2 AND (account_id = $1 OR account_id IS NULL)
ORDER BY account_id NULLS LAST
LIMIT 1
`, accountID, typ).Scan(&v.ID, &v.AccountID, &v.Typ, &v.Betreff, &v.Text, &v.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return EmailVorlage{}, ErrNotFound
}
if err != nil {
return EmailVorlage{}, fmt.Errorf("store: resolve email vorlage: %w", err)
}
return v, nil
}
// DeleteEmailVorlage entfernt eine Vorlage — bei einem Mandanten "auf
// Plattform-Standard zurücksetzen" (ResolveEmailVorlage greift danach
// wieder auf den plattformweiten Standard zurück), beim Betreiber
// bewusst nicht vorgesehen (der Standard muss immer existieren, siehe
// Migration 0022 — kein Lösch-Button auf der Betreiber-Seite).
func (s *Store) DeleteEmailVorlage(ctx context.Context, accountID *string, typ string) error {
tag, err := s.db(ctx).Exec(ctx, `
DELETE FROM email_vorlage WHERE account_id IS NOT DISTINCT FROM $1 AND typ = $2
`, accountID, typ)
if err != nil {
return fmt.Errorf("store: delete email vorlage: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}

View File

@@ -0,0 +1,117 @@
package store_test
import (
"context"
"errors"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
func TestEmailVorlagePlattformStandardExistiertNachMigration(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
v, err := s.GetEmailVorlage(ctx, nil, "passwort_zuruecksetzen")
if err != nil {
t.Fatalf("GetEmailVorlage (Plattform): %v", err)
}
if v.AccountID != nil {
t.Fatalf("AccountID = %v, want nil (Plattform-Standard)", v.AccountID)
}
if v.Betreff == "" || v.Text == "" {
t.Fatalf("Plattform-Standard unvollständig: %+v", v)
}
}
func TestResolveEmailVorlageFaelltAufPlattformStandardZurueck(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
// Ohne eigene Übersteuerung liefert Resolve den Plattform-Standard.
resolved, err := s.ResolveEmailVorlage(ctx, accID, "passwort_zuruecksetzen")
if err != nil {
t.Fatalf("ResolveEmailVorlage (kein eigener Override): %v", err)
}
if resolved.AccountID != nil {
t.Fatalf("AccountID = %v, want nil (geerbt vom Plattform-Standard)", resolved.AccountID)
}
// Eigene Übersteuerung anlegen — Resolve muss jetzt die eigene liefern.
eigene, err := s.UpsertEmailVorlage(ctx, &accID, "passwort_zuruecksetzen", "Eigener Betreff", "Eigener Text {{link}}")
if err != nil {
t.Fatalf("UpsertEmailVorlage: %v", err)
}
resolved, err = s.ResolveEmailVorlage(ctx, accID, "passwort_zuruecksetzen")
if err != nil {
t.Fatalf("ResolveEmailVorlage (mit Override): %v", err)
}
if resolved.ID != eigene.ID || resolved.Betreff != "Eigener Betreff" {
t.Fatalf("resolved = %+v, want eigene Vorlage", resolved)
}
// Zurücksetzen entfernt die Übersteuerung, Resolve fällt wieder zurück.
if err := s.DeleteEmailVorlage(ctx, &accID, "passwort_zuruecksetzen"); err != nil {
t.Fatalf("DeleteEmailVorlage: %v", err)
}
resolved, err = s.ResolveEmailVorlage(ctx, accID, "passwort_zuruecksetzen")
if err != nil {
t.Fatalf("ResolveEmailVorlage (nach Reset): %v", err)
}
if resolved.AccountID != nil {
t.Fatalf("AccountID nach Reset = %v, want nil (wieder Plattform-Standard)", resolved.AccountID)
}
}
func TestUpsertPlattformStandardAktualisiertStattZuDuplizieren(t *testing.T) {
// Regressionstest: SQL behandelt NULL nie als gleich zu NULL, ein
// naiver UNIQUE(account_id, typ)-Constraint hätte beliebig viele
// Plattform-Standard-Zeilen je typ zugelassen — jedes erneute
// Speichern hätte eine neue Zeile erzeugt statt die bestehende zu
// aktualisieren (genau das ist beim Live-Verifizieren passiert).
s := openTestStore(t)
ctx := context.Background()
first, err := s.UpsertEmailVorlage(ctx, nil, "passwort_zuruecksetzen", "Erster Betreff", "Erster Text {{link}}")
if err != nil {
t.Fatalf("UpsertEmailVorlage (1): %v", err)
}
second, err := s.UpsertEmailVorlage(ctx, nil, "passwort_zuruecksetzen", "Zweiter Betreff", "Zweiter Text {{link}}")
if err != nil {
t.Fatalf("UpsertEmailVorlage (2): %v", err)
}
if first.ID != second.ID {
t.Fatalf("zweites Upsert erzeugte eine neue Zeile (ID %s statt %s) statt zu aktualisieren", second.ID, first.ID)
}
got, err := s.GetEmailVorlage(ctx, nil, "passwort_zuruecksetzen")
if err != nil {
t.Fatalf("GetEmailVorlage: %v", err)
}
if got.Betreff != "Zweiter Betreff" {
t.Fatalf("Betreff = %q, want %q (aktueller Stand)", got.Betreff, "Zweiter Betreff")
}
}
func TestEmailVorlageZweierMandantenIsoliert(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accA := testAccountID(t, s)
accB := testAccountID(t, s)
if _, err := s.UpsertEmailVorlage(ctx, &accA, "passwort_zuruecksetzen", "Vorlage A", "Text A {{link}}"); err != nil {
t.Fatalf("UpsertEmailVorlage A: %v", err)
}
resolvedB, err := s.ResolveEmailVorlage(ctx, accB, "passwort_zuruecksetzen")
if err != nil {
t.Fatalf("ResolveEmailVorlage B: %v", err)
}
if resolvedB.Betreff == "Vorlage A" {
t.Fatal("Mandant B sieht die Vorlage von Mandant A — Isolation defekt")
}
if _, err := s.GetEmailVorlage(ctx, &accB, "passwort_zuruecksetzen"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("GetEmailVorlage B (kein eigener Override) err=%v, want ErrNotFound", err)
}
}

View File

@@ -0,0 +1,146 @@
package store
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Entscheidung ist die Entscheidung eines/einer Verantwortlichen über
// einen Antrag, auf Basis einer Bewertung (Vorschlag). Append-only —
// siehe Migration. WerkzeugSnapshot ist der eingefrorene, vollständige
// Werkzeug-Datensatz zum Entscheidungszeitpunkt (nil, wenn kein
// Werkzeug gewählt wurde, z. B. bei "abgelehnt") — ein späterer
// Katalog-Wandel darf nicht rückwirkend verändern, worauf die
// Entscheidung beruhte.
type Entscheidung struct {
ID string
AntragID string
BewertungID string
EntscheiderUserID string
Entscheidung string
WerkzeugID *string
WerkzeugSnapshot *Werkzeug
Begruendung string
GueltigBis *time.Time
CreatedAt time.Time
}
const entscheidungColumns = `id, antrag_id, bewertung_id, entscheider_user_id, entscheidung,
werkzeug_id, werkzeug_snapshot, begruendung, gueltig_bis, created_at`
func scanEntscheidung(row interface {
Scan(dest ...any) error
}) (Entscheidung, error) {
var e Entscheidung
var snapshotRaw []byte
err := row.Scan(
&e.ID, &e.AntragID, &e.BewertungID, &e.EntscheiderUserID, &e.Entscheidung,
&e.WerkzeugID, &snapshotRaw, &e.Begruendung, &e.GueltigBis, &e.CreatedAt,
)
if err != nil {
return Entscheidung{}, err
}
if snapshotRaw != nil {
var w Werkzeug
if err := json.Unmarshal(snapshotRaw, &w); err != nil {
return Entscheidung{}, fmt.Errorf("store: werkzeug_snapshot unmarshal: %w", err)
}
e.WerkzeugSnapshot = &w
}
return e, nil
}
// EntscheidungInput bündelt die Felder einer neuen Entscheidung.
type EntscheidungInput struct {
AntragID string
BewertungID string
EntscheiderUserID string
Entscheidung string
WerkzeugID *string
WerkzeugSnapshot *Werkzeug
Begruendung string
GueltigBis *time.Time
}
// CreateEntscheidung speichert eine Entscheidung über einen Antrag.
func (s *Store) CreateEntscheidung(ctx context.Context, in EntscheidungInput) (Entscheidung, error) {
var snapshotJSON []byte
if in.WerkzeugSnapshot != nil {
var err error
snapshotJSON, err = json.Marshal(in.WerkzeugSnapshot)
if err != nil {
return Entscheidung{}, fmt.Errorf("store: werkzeug_snapshot marshal: %w", err)
}
}
row := s.db(ctx).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.db(ctx).QueryRow(ctx, `
SELECT `+entscheidungColumns+`
FROM entscheidung WHERE antrag_id = $1 ORDER BY created_at DESC LIMIT 1
`, antragID)
e, err := scanEntscheidung(row)
if errors.Is(err, pgx.ErrNoRows) {
return Entscheidung{}, ErrNotFound
}
if err != nil {
return Entscheidung{}, fmt.Errorf("store: get latest entscheidung: %w", err)
}
return e, nil
}
// ListAktiveGenehmigungenForAccount liefert alle Genehmigungen
// ("genehmigt"/"genehmigt_mit_auflagen") eines Mandanten — Grundlage
// für die Wiedervorlage (Schritt 7): abgelaufene/bald ablaufende
// Genehmigungen und solche, deren zugrunde liegendes Werkzeug sich seit
// der Entscheidung im Katalog geändert hat, muss der/die Verantwortliche
// erneut prüfen. entscheidung trägt selbst kein account_id — der Bezug
// zum Mandanten läuft über den zugehörigen antrag.
func (s *Store) ListAktiveGenehmigungenForAccount(ctx context.Context, accountID string) ([]Entscheidung, error) {
rows, err := s.db(ctx).Query(ctx, `
SELECT e.id, e.antrag_id, e.bewertung_id, e.entscheider_user_id, e.entscheidung,
e.werkzeug_id, e.werkzeug_snapshot, e.begruendung, e.gueltig_bis, e.created_at
FROM entscheidung e
JOIN antrag a ON a.id = e.antrag_id
WHERE a.account_id = $1 AND e.entscheidung IN ('genehmigt', 'genehmigt_mit_auflagen')
ORDER BY e.created_at DESC
`, accountID)
if err != nil {
return nil, fmt.Errorf("store: list aktive genehmigungen for account: %w", err)
}
defer rows.Close()
var out []Entscheidung
for rows.Next() {
e, err := scanEntscheidung(rows)
if err != nil {
return nil, fmt.Errorf("store: scan entscheidung: %w", err)
}
out = append(out, e)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list aktive genehmigungen for account: %w", err)
}
return out, nil
}

View File

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

View File

@@ -1,54 +0,0 @@
package store
import (
"context"
"fmt"
"time"
)
// EvidencePackage ist das Ergebnis der Archivierung eines Beitrags:
// Dossier-Pfad, Hash der kanonisierten Metadaten und RFC-3161-Token.
// Append-only: siehe Migration.
type EvidencePackage struct {
ID string
SubmissionID string
DossierPath string
SHA256 string
TimestampToken []byte
CreatedAt time.Time
}
// CreateEvidencePackage speichert ein EvidencePackage.
func (s *Store) CreateEvidencePackage(ctx context.Context, submissionID, dossierPath, sha256Hex string, timestampToken []byte) (EvidencePackage, error) {
var e EvidencePackage
err := s.Pool.QueryRow(ctx, `
INSERT INTO evidence_package (submission_id, dossier_path, sha256, timestamp_token)
VALUES ($1, $2, $3, $4)
RETURNING id, submission_id, dossier_path, sha256, timestamp_token, created_at
`, submissionID, dossierPath, sha256Hex, timestampToken).Scan(
&e.ID, &e.SubmissionID, &e.DossierPath, &e.SHA256, &e.TimestampToken, &e.CreatedAt,
)
if err != nil {
return EvidencePackage{}, fmt.Errorf("store: create evidence package: %w", err)
}
return e, nil
}
// GetLatestEvidencePackage liefert das zuletzt erzeugte EvidencePackage
// für einen Beitrag.
func (s *Store) GetLatestEvidencePackage(ctx context.Context, submissionID string) (EvidencePackage, error) {
var e EvidencePackage
err := s.Pool.QueryRow(ctx, `
SELECT id, submission_id, dossier_path, sha256, timestamp_token, created_at
FROM evidence_package
WHERE submission_id = $1
ORDER BY created_at DESC
LIMIT 1
`, submissionID).Scan(
&e.ID, &e.SubmissionID, &e.DossierPath, &e.SHA256, &e.TimestampToken, &e.CreatedAt,
)
if err != nil {
return EvidencePackage{}, fmt.Errorf("store: get latest evidence package: %w", err)
}
return e, nil
}

View File

@@ -1,56 +0,0 @@
package store
import (
"context"
"fmt"
"time"
)
// Extraction ist das Ergebnis der Stufe-1-Extraktion für einen Beitrag.
// Payload ist das rohe, vom Modell gelieferte JSON — nicht eine
// abgeleitete Repräsentation. Append-only: siehe Migration.
type Extraction struct {
ID string
SubmissionID string
Payload []byte
ModelVersion string
PromptVersion string
CreatedAt time.Time
}
// CreateExtraction speichert eine Extraktion. payload ist das rohe
// JSON, wie es das Modell zurückgegeben hat (siehe extract.Result.RawJSON).
func (s *Store) CreateExtraction(ctx context.Context, submissionID string, payload []byte, modelVersion, promptVersion string) (Extraction, error) {
var e Extraction
err := s.Pool.QueryRow(ctx, `
INSERT INTO extraction (submission_id, payload, model_version, prompt_version)
VALUES ($1, $2, $3, $4)
RETURNING id, submission_id, payload, model_version, prompt_version, created_at
`, submissionID, payload, modelVersion, promptVersion).Scan(
&e.ID, &e.SubmissionID, &e.Payload, &e.ModelVersion, &e.PromptVersion, &e.CreatedAt,
)
if err != nil {
return Extraction{}, fmt.Errorf("store: create extraction: %w", err)
}
return e, nil
}
// GetLatestExtraction liest die zuletzt erzeugte Extraktion für einen
// Beitrag (append-only: es kann mehrere geben, z. B. bei einer erneuten
// Prüfung — die aktuellste zählt).
func (s *Store) GetLatestExtraction(ctx context.Context, submissionID string) (Extraction, error) {
var e Extraction
err := s.Pool.QueryRow(ctx, `
SELECT id, submission_id, payload, model_version, prompt_version, created_at
FROM extraction
WHERE submission_id = $1
ORDER BY created_at DESC
LIMIT 1
`, submissionID).Scan(
&e.ID, &e.SubmissionID, &e.Payload, &e.ModelVersion, &e.PromptVersion, &e.CreatedAt,
)
if err != nil {
return Extraction{}, fmt.Errorf("store: get latest extraction: %w", err)
}
return e, nil
}

View File

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

388
internal/store/freigabe.go Normal file
View File

@@ -0,0 +1,388 @@
// Konfigurierbarer Mehrfach-Freigabe-Workflow — siehe Migration 0017
// und CLAUDE.md. GenehmigerRolle ist eine reine Freigabe-Funktion
// (z. B. "Datenschutzbeauftragter", "Geschäftsführung"), unabhängig von
// app_user.role (Zugriffskontrolle) — eine Person kann beides zugleich
// sein oder keines von beidem.
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
type GenehmigerRolle struct {
ID string
AccountID string
Name string
Beschreibung string
CreatedAt time.Time
}
// CreateGenehmigerRolle legt eine neue Freigabe-Funktion für einen
// Mandanten an. Beschreibung ist reiner Freitext zur Orientierung des
// Admins (z. B. "Prüft den Antrag aus Sicherheitssicht") — bindet keine
// Bedingung, das bleibt Sache von freigabe_regel.
func (s *Store) CreateGenehmigerRolle(ctx context.Context, accountID, name, beschreibung string) (GenehmigerRolle, error) {
var g GenehmigerRolle
err := s.db(ctx).QueryRow(ctx, `
INSERT INTO genehmiger_rolle (account_id, name, beschreibung) VALUES ($1, $2, $3)
RETURNING id, account_id, name, beschreibung, created_at
`, accountID, name, beschreibung).Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt)
if err != nil {
return GenehmigerRolle{}, fmt.Errorf("store: create genehmiger rolle: %w", err)
}
return g, nil
}
// GetGenehmigerRolle liest eine Genehmiger-Rolle anhand ihrer ID.
func (s *Store) GetGenehmigerRolle(ctx context.Context, id string) (GenehmigerRolle, error) {
var g GenehmigerRolle
err := s.db(ctx).QueryRow(ctx, `
SELECT id, account_id, name, beschreibung, created_at FROM genehmiger_rolle WHERE id = $1
`, id).Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return GenehmigerRolle{}, ErrNotFound
}
if err != nil {
return GenehmigerRolle{}, fmt.Errorf("store: get genehmiger rolle: %w", err)
}
return g, nil
}
// ListGenehmigerRollenForAccount liefert alle Genehmiger-Rollen eines
// Mandanten.
func (s *Store) ListGenehmigerRollenForAccount(ctx context.Context, accountID string) ([]GenehmigerRolle, error) {
rows, err := s.db(ctx).Query(ctx, `
SELECT id, account_id, name, beschreibung, created_at FROM genehmiger_rolle
WHERE account_id = $1 ORDER BY name
`, accountID)
if err != nil {
return nil, fmt.Errorf("store: list genehmiger rollen: %w", err)
}
defer rows.Close()
var out []GenehmigerRolle
for rows.Next() {
var g GenehmigerRolle
if err := rows.Scan(&g.ID, &g.AccountID, &g.Name, &g.Beschreibung, &g.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan genehmiger rolle: %w", err)
}
out = append(out, g)
}
return out, rows.Err()
}
// DeleteGenehmigerRolle entfernt eine Genehmiger-Rolle. Zuordnungen
// (nutzer_genehmiger_rolle) und darauf verweisende Freigabe-Regeln
// hängen per Fremdschlüssel daran — der Aufrufer muss sie vorher
// entfernen, sonst schlägt das DELETE fehl (bewusst kein CASCADE: ein
// stillschweigendes Mit-Löschen von Freigabe-Regeln wäre überraschend).
func (s *Store) DeleteGenehmigerRolle(ctx context.Context, id string) error {
tag, err := s.db(ctx).Exec(ctx, `DELETE FROM genehmiger_rolle WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("store: delete genehmiger rolle: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// AddNutzerGenehmigerRolle weist einer Person eine Genehmiger-Rolle zu.
func (s *Store) AddNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
_, err := s.db(ctx).Exec(ctx, `
INSERT INTO nutzer_genehmiger_rolle (app_user_id, genehmiger_rolle_id) VALUES ($1, $2)
ON CONFLICT (app_user_id, genehmiger_rolle_id) DO NOTHING
`, userID, genehmigerRolleID)
if err != nil {
return fmt.Errorf("store: add nutzer genehmiger rolle: %w", err)
}
return nil
}
// RemoveNutzerGenehmigerRolle entzieht einer Person eine Genehmiger-Rolle.
func (s *Store) RemoveNutzerGenehmigerRolle(ctx context.Context, userID, genehmigerRolleID string) error {
_, err := s.db(ctx).Exec(ctx, `
DELETE FROM nutzer_genehmiger_rolle WHERE app_user_id = $1 AND genehmiger_rolle_id = $2
`, userID, genehmigerRolleID)
if err != nil {
return fmt.Errorf("store: remove nutzer genehmiger rolle: %w", err)
}
return nil
}
// ListGenehmigerRollenForUser liefert alle Genehmiger-Rollen einer
// Person — Grundlage dafür, ob und was sie unter "Meine Freigaben" sieht.
func (s *Store) ListGenehmigerRollenForUser(ctx context.Context, userID string) ([]GenehmigerRolle, error) {
rows, err := s.db(ctx).Query(ctx, `
SELECT gr.id, gr.account_id, gr.name, gr.created_at
FROM genehmiger_rolle gr
JOIN nutzer_genehmiger_rolle ngr ON ngr.genehmiger_rolle_id = gr.id
WHERE ngr.app_user_id = $1
ORDER BY gr.name
`, userID)
if err != nil {
return nil, fmt.Errorf("store: list genehmiger rollen for user: %w", err)
}
defer rows.Close()
var out []GenehmigerRolle
for rows.Next() {
var g GenehmigerRolle
if err := rows.Scan(&g.ID, &g.AccountID, &g.Name, &g.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan genehmiger rolle: %w", err)
}
out = append(out, g)
}
return out, rows.Err()
}
// GenehmigerMitglied ist ein Nutzer, der eine bestimmte Genehmiger-Rolle
// innehat — für die Anzeige "wer hat diese Rolle" auf der Verwaltungsseite.
type GenehmigerMitglied struct {
UserID string
Email string
}
// ListNutzerForGenehmigerRolle liefert alle Personen mit einer
// bestimmten Genehmiger-Rolle.
func (s *Store) ListNutzerForGenehmigerRolle(ctx context.Context, genehmigerRolleID string) ([]GenehmigerMitglied, error) {
rows, err := s.db(ctx).Query(ctx, `
SELECT u.id, u.email
FROM app_user u
JOIN nutzer_genehmiger_rolle ngr ON ngr.app_user_id = u.id
WHERE ngr.genehmiger_rolle_id = $1
ORDER BY u.email
`, genehmigerRolleID)
if err != nil {
return nil, fmt.Errorf("store: list nutzer for genehmiger rolle: %w", err)
}
defer rows.Close()
var out []GenehmigerMitglied
for rows.Next() {
var m GenehmigerMitglied
if err := rows.Scan(&m.UserID, &m.Email); err != nil {
return nil, fmt.Errorf("store: scan genehmiger mitglied: %w", err)
}
out = append(out, m)
}
return out, rows.Err()
}
// FreigabeRegel: "wenn Bedingung X zutrifft, ist zusätzlich eine
// Freigabe durch GenehmigerRolleID nötig." BedingungTyp ist eine von
// "anforderung"/"einstufung"/"datenklasse", BedingungWert die jeweilige
// ID aus dem Regelwerk (z. B. "dsfa_erforderlich", "hochrisiko").
type FreigabeRegel struct {
ID string
AccountID string
BedingungTyp string
BedingungWert string
GenehmigerRolleID string
CreatedAt time.Time
}
func (s *Store) CreateFreigabeRegel(ctx context.Context, accountID, bedingungTyp, bedingungWert, genehmigerRolleID string) (FreigabeRegel, error) {
var f FreigabeRegel
err := s.db(ctx).QueryRow(ctx, `
INSERT INTO freigabe_regel (account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id)
VALUES ($1, $2, $3, $4)
RETURNING id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
`, accountID, bedingungTyp, bedingungWert, genehmigerRolleID).Scan(
&f.ID, &f.AccountID, &f.BedingungTyp, &f.BedingungWert, &f.GenehmigerRolleID, &f.CreatedAt,
)
if err != nil {
return FreigabeRegel{}, fmt.Errorf("store: create freigabe regel: %w", err)
}
return f, nil
}
func (s *Store) ListFreigabeRegelnForAccount(ctx context.Context, accountID string) ([]FreigabeRegel, error) {
rows, err := s.db(ctx).Query(ctx, `
SELECT id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
FROM freigabe_regel WHERE account_id = $1 ORDER BY created_at
`, accountID)
if err != nil {
return nil, fmt.Errorf("store: list freigabe regeln: %w", err)
}
defer rows.Close()
var out []FreigabeRegel
for rows.Next() {
var f FreigabeRegel
if err := rows.Scan(&f.ID, &f.AccountID, &f.BedingungTyp, &f.BedingungWert, &f.GenehmigerRolleID, &f.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan freigabe regel: %w", err)
}
out = append(out, f)
}
return out, rows.Err()
}
func (s *Store) DeleteFreigabeRegel(ctx context.Context, id string) error {
tag, err := s.db(ctx).Exec(ctx, `DELETE FROM freigabe_regel WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("store: delete freigabe regel: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// GetFreigabeRegel liest eine einzelne Freigabe-Regel — genutzt, um
// beim Löschen den Mandanten gegen den angemeldeten Account zu prüfen.
func (s *Store) GetFreigabeRegel(ctx context.Context, id string) (FreigabeRegel, error) {
var f FreigabeRegel
err := s.db(ctx).QueryRow(ctx, `
SELECT id, account_id, bedingung_typ, bedingung_wert, genehmiger_rolle_id, created_at
FROM freigabe_regel WHERE id = $1
`, id).Scan(&f.ID, &f.AccountID, &f.BedingungTyp, &f.BedingungWert, &f.GenehmigerRolleID, &f.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return FreigabeRegel{}, ErrNotFound
}
if err != nil {
return FreigabeRegel{}, fmt.Errorf("store: get freigabe regel: %w", err)
}
return f, nil
}
// Freigabeschritt ist eine einzelne, durch eine FreigabeRegel
// ausgelöste Freigabe-Aufgabe für einen konkreten Antrag. Nicht
// append-only — ein Schritt geht von "ausstehend" in genau einen
// Endzustand über, keine Historie mehrerer Entscheidungen zum selben
// Schritt.
type Freigabeschritt struct {
ID string
AntragID string
GenehmigerRolleID string
Status string // ausstehend/genehmigt/abgelehnt
EntschiedenVon *string
EntschiedenAm *time.Time
Kommentar string
CreatedAt time.Time
}
func (s *Store) CreateFreigabeschritt(ctx context.Context, antragID, genehmigerRolleID string) (Freigabeschritt, error) {
var f Freigabeschritt
err := s.db(ctx).QueryRow(ctx, `
INSERT INTO freigabeschritt (antrag_id, genehmiger_rolle_id) VALUES ($1, $2)
RETURNING id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
`, antragID, genehmigerRolleID).Scan(
&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt,
)
if err != nil {
return Freigabeschritt{}, fmt.Errorf("store: create freigabeschritt: %w", err)
}
return f, nil
}
func (s *Store) GetFreigabeschritt(ctx context.Context, id string) (Freigabeschritt, error) {
var f Freigabeschritt
err := s.db(ctx).QueryRow(ctx, `
SELECT id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
FROM freigabeschritt WHERE id = $1
`, id).Scan(&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return Freigabeschritt{}, ErrNotFound
}
if err != nil {
return Freigabeschritt{}, fmt.Errorf("store: get freigabeschritt: %w", err)
}
return f, nil
}
func (s *Store) ListFreigabeschritteForAntrag(ctx context.Context, antragID string) ([]Freigabeschritt, error) {
rows, err := s.db(ctx).Query(ctx, `
SELECT id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
FROM freigabeschritt WHERE antrag_id = $1 ORDER BY created_at
`, antragID)
if err != nil {
return nil, fmt.Errorf("store: list freigabeschritte for antrag: %w", err)
}
defer rows.Close()
var out []Freigabeschritt
for rows.Next() {
var f Freigabeschritt
if err := rows.Scan(&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan freigabeschritt: %w", err)
}
out = append(out, f)
}
return out, rows.Err()
}
// ListAusstehendeFreigabeschritteForUser liefert alle offenen
// Freigabeschritte über JEDE Genehmiger-Rolle, die diese Person
// innehat — Grundlage für "Meine Freigaben".
func (s *Store) ListAusstehendeFreigabeschritteForUser(ctx context.Context, userID string) ([]Freigabeschritt, error) {
rows, err := s.db(ctx).Query(ctx, `
SELECT f.id, f.antrag_id, f.genehmiger_rolle_id, f.status, f.entschieden_von, f.entschieden_am, f.kommentar, f.created_at
FROM freigabeschritt f
JOIN nutzer_genehmiger_rolle ngr ON ngr.genehmiger_rolle_id = f.genehmiger_rolle_id
WHERE ngr.app_user_id = $1 AND f.status = 'ausstehend'
ORDER BY f.created_at
`, userID)
if err != nil {
return nil, fmt.Errorf("store: list ausstehende freigabeschritte: %w", err)
}
defer rows.Close()
var out []Freigabeschritt
for rows.Next() {
var f Freigabeschritt
if err := rows.Scan(&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan freigabeschritt: %w", err)
}
out = append(out, f)
}
return out, rows.Err()
}
// EntscheideFreigabeschritt trägt die Entscheidung einer Person zu
// einem einzelnen Freigabeschritt ein. Nur von "ausstehend" aus
// erlaubt — der Aufrufer prüft das vorher (ErrNotFound bei bereits
// entschiedenen Schritten wäre irreführend, deshalb hier ein
// generischer Fehler statt eines Sentinels).
func (s *Store) EntscheideFreigabeschritt(ctx context.Context, id, status, entschiedenVon, kommentar string) (Freigabeschritt, error) {
var f Freigabeschritt
err := s.db(ctx).QueryRow(ctx, `
UPDATE freigabeschritt
SET status = $2, entschieden_von = $3, entschieden_am = now(), kommentar = $4
WHERE id = $1 AND status = 'ausstehend'
RETURNING id, antrag_id, genehmiger_rolle_id, status, entschieden_von, entschieden_am, kommentar, created_at
`, id, status, entschiedenVon, kommentar).Scan(
&f.ID, &f.AntragID, &f.GenehmigerRolleID, &f.Status, &f.EntschiedenVon, &f.EntschiedenAm, &f.Kommentar, &f.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return Freigabeschritt{}, ErrNotFound
}
if err != nil {
return Freigabeschritt{}, fmt.Errorf("store: entscheide freigabeschritt: %w", err)
}
return f, nil
}
// KaskadiereAblehnung lehnt alle noch ausstehenden Freigabeschritte
// eines Antrags automatisch ab, nachdem ein anderer Schritt desselben
// Antrags bereits abgelehnt wurde — eine einzelne Ablehnung kippt den
// gesamten Antrag, die übrigen offenen Freigaben werden dadurch
// gegenstandslos statt für immer in fremden "Meine Freigaben"-Listen
// hängen zu bleiben.
func (s *Store) KaskadiereAblehnung(ctx context.Context, antragID, ausloesenderSchrittID string) error {
_, err := s.db(ctx).Exec(ctx, `
UPDATE freigabeschritt
SET status = 'abgelehnt', entschieden_am = now(),
kommentar = 'Automatisch abgelehnt, da eine andere erforderliche Freigabe für diesen Antrag abgelehnt wurde.'
WHERE antrag_id = $1 AND status = 'ausstehend' AND id != $2
`, antragID, ausloesenderSchrittID)
if err != nil {
return fmt.Errorf("store: kaskadiere ablehnung: %w", err)
}
return nil
}

View File

@@ -0,0 +1,174 @@
package store_test
import (
"context"
"errors"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
func TestGenehmigerRolleCRUDAndMitgliedschaft(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
g, err := s.CreateGenehmigerRolle(ctx, accID, "Datenschutzbeauftragter", "")
if err != nil {
t.Fatalf("CreateGenehmigerRolle: %v", err)
}
if g.AccountID != accID || g.Name != "Datenschutzbeauftragter" {
t.Fatalf("GenehmigerRolle = %+v, unerwartete Werte", g)
}
got, err := s.GetGenehmigerRolle(ctx, g.ID)
if err != nil || got.Name != "Datenschutzbeauftragter" {
t.Fatalf("GetGenehmigerRolle = %+v, err=%v", got, err)
}
liste, err := s.ListGenehmigerRollenForAccount(ctx, accID)
if err != nil || len(liste) != 1 {
t.Fatalf("ListGenehmigerRollenForAccount = %+v, err=%v", liste, err)
}
user := testUserID(t, s, accID)
if err := s.AddNutzerGenehmigerRolle(ctx, user, g.ID); err != nil {
t.Fatalf("AddNutzerGenehmigerRolle: %v", err)
}
// Erneutes Hinzufügen derselben Zuordnung ist ein No-op (ON CONFLICT),
// kein Fehler.
if err := s.AddNutzerGenehmigerRolle(ctx, user, g.ID); err != nil {
t.Fatalf("AddNutzerGenehmigerRolle (erneut): %v", err)
}
rollenDesNutzers, err := s.ListGenehmigerRollenForUser(ctx, user)
if err != nil || len(rollenDesNutzers) != 1 || rollenDesNutzers[0].ID != g.ID {
t.Fatalf("ListGenehmigerRollenForUser = %+v, err=%v", rollenDesNutzers, err)
}
mitglieder, err := s.ListNutzerForGenehmigerRolle(ctx, g.ID)
if err != nil || len(mitglieder) != 1 || mitglieder[0].UserID != user {
t.Fatalf("ListNutzerForGenehmigerRolle = %+v, err=%v", mitglieder, err)
}
if err := s.RemoveNutzerGenehmigerRolle(ctx, user, g.ID); err != nil {
t.Fatalf("RemoveNutzerGenehmigerRolle: %v", err)
}
rollenNachEntfernen, err := s.ListGenehmigerRollenForUser(ctx, user)
if err != nil || len(rollenNachEntfernen) != 0 {
t.Fatalf("ListGenehmigerRollenForUser nach Entfernen = %+v, err=%v", rollenNachEntfernen, err)
}
if err := s.DeleteGenehmigerRolle(ctx, g.ID); err != nil {
t.Fatalf("DeleteGenehmigerRolle: %v", err)
}
if _, err := s.GetGenehmigerRolle(ctx, g.ID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err nach Delete = %v, want ErrNotFound", err)
}
}
func TestFreigabeRegelCRUD(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
g, err := s.CreateGenehmigerRolle(ctx, accID, "Geschaeftsfuehrung", "")
if err != nil {
t.Fatalf("CreateGenehmigerRolle: %v", err)
}
regel, err := s.CreateFreigabeRegel(ctx, accID, "einstufung", "hochrisiko", g.ID)
if err != nil {
t.Fatalf("CreateFreigabeRegel: %v", err)
}
if regel.BedingungTyp != "einstufung" || regel.BedingungWert != "hochrisiko" {
t.Fatalf("FreigabeRegel = %+v, unerwartete Werte", regel)
}
liste, err := s.ListFreigabeRegelnForAccount(ctx, accID)
if err != nil || len(liste) != 1 {
t.Fatalf("ListFreigabeRegelnForAccount = %+v, err=%v", liste, err)
}
got, err := s.GetFreigabeRegel(ctx, regel.ID)
if err != nil || got.ID != regel.ID {
t.Fatalf("GetFreigabeRegel = %+v, err=%v", got, err)
}
if err := s.DeleteFreigabeRegel(ctx, regel.ID); err != nil {
t.Fatalf("DeleteFreigabeRegel: %v", err)
}
if _, err := s.GetFreigabeRegel(ctx, regel.ID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err nach Delete = %v, want ErrNotFound", err)
}
}
func TestFreigabeschrittLifecycleUndKaskade(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
user := testUserID(t, s, accID)
antrag, err := s.CreateAntrag(ctx, accID, user, nil, "Test-Antrag für Freigabeschritte")
if err != nil {
t.Fatalf("CreateAntrag: %v", err)
}
antragID := antrag.ID
dsb, _ := s.CreateGenehmigerRolle(ctx, accID, "Datenschutzbeauftragter", "")
gf, _ := s.CreateGenehmigerRolle(ctx, accID, "Geschaeftsfuehrung", "")
schrittDSB, err := s.CreateFreigabeschritt(ctx, antragID, dsb.ID)
if err != nil {
t.Fatalf("CreateFreigabeschritt: %v", err)
}
if schrittDSB.Status != "ausstehend" {
t.Fatalf("Status = %q, want ausstehend", schrittDSB.Status)
}
schrittGF, err := s.CreateFreigabeschritt(ctx, antragID, gf.ID)
if err != nil {
t.Fatalf("CreateFreigabeschritt: %v", err)
}
alle, err := s.ListFreigabeschritteForAntrag(ctx, antragID)
if err != nil || len(alle) != 2 {
t.Fatalf("ListFreigabeschritteForAntrag = %+v, err=%v", alle, err)
}
ausstehend, err := s.ListAusstehendeFreigabeschritteForUser(ctx, user)
if err != nil {
t.Fatalf("ListAusstehendeFreigabeschritteForUser: %v", err)
}
if len(ausstehend) != 0 {
t.Fatalf("erwartet 0 ausstehende Freigaben ohne Genehmiger-Rollen-Zuordnung, got %d", len(ausstehend))
}
if err := s.AddNutzerGenehmigerRolle(ctx, user, dsb.ID); err != nil {
t.Fatalf("AddNutzerGenehmigerRolle: %v", err)
}
ausstehend, err = s.ListAusstehendeFreigabeschritteForUser(ctx, user)
if err != nil || len(ausstehend) != 1 || ausstehend[0].ID != schrittDSB.ID {
t.Fatalf("ListAusstehendeFreigabeschritteForUser = %+v, err=%v", ausstehend, err)
}
entschieden, err := s.EntscheideFreigabeschritt(ctx, schrittDSB.ID, "abgelehnt", user, "nicht ausreichend")
if err != nil {
t.Fatalf("EntscheideFreigabeschritt: %v", err)
}
if entschieden.Status != "abgelehnt" || entschieden.EntschiedenVon == nil || *entschieden.EntschiedenVon != user {
t.Fatalf("Freigabeschritt nach Entscheidung = %+v, unerwartete Werte", entschieden)
}
// Ein bereits entschiedener Schritt kann nicht erneut entschieden werden.
if _, err := s.EntscheideFreigabeschritt(ctx, schrittDSB.ID, "genehmigt", user, ""); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("erneute Entscheidung err = %v, want ErrNotFound", err)
}
if err := s.KaskadiereAblehnung(ctx, antragID, schrittDSB.ID); err != nil {
t.Fatalf("KaskadiereAblehnung: %v", err)
}
nachKaskade, err := s.GetFreigabeschritt(ctx, schrittGF.ID)
if err != nil {
t.Fatalf("GetFreigabeschritt: %v", err)
}
if nachKaskade.Status != "abgelehnt" {
t.Fatalf("Status nach Kaskade = %q, want abgelehnt", nachKaskade.Status)
}
}

View File

@@ -0,0 +1,69 @@
// Löschfristen je Datenklasse (Migration 0020) — schließt die in
// rules/OPEN.md, Punkt 4 dokumentierte Lücke: loeschfrist_max_tage war
// bisher nur als Anforderung "vorhanden", ohne Tageswerte, und wurde
// deshalb nicht hart gefiltert. Die konkreten Tageswerte sind KEINE
// gesetzliche Vorgabe (die DSGVO nennt keine festen Fristen, nur den
// Grundsatz "so lange wie für den Zweck nötig", Art. 5 Abs. 1 lit. e) —
// deshalb pro Mandant einstellbar statt hartkodiert, vom
// Datenschutzbeauftragten hinterlegt. Bei Firmenanlage werden
// risikogestaffelte Vorschlagswerte vorbelegt (siehe
// internal/web/loeschfrist_handlers.go), frei editierbar.
package store
import (
"context"
"fmt"
"time"
)
type LoeschfristEinstellung struct {
ID string
AccountID string
DatenklasseID string
MaxTage int
UpdatedAt time.Time
}
// UpsertLoeschfristEinstellung legt eine Einstellung an oder
// aktualisiert sie — ein Mandant hat höchstens eine Frist je
// Datenklasse (UNIQUE-Constraint).
func (s *Store) UpsertLoeschfristEinstellung(ctx context.Context, accountID, datenklasseID string, maxTage int) (LoeschfristEinstellung, error) {
var e LoeschfristEinstellung
err := s.db(ctx).QueryRow(ctx, `
INSERT INTO loeschfrist_einstellung (account_id, datenklasse_id, max_tage)
VALUES ($1, $2, $3)
ON CONFLICT (account_id, datenklasse_id)
DO UPDATE SET max_tage = $3, updated_at = now()
RETURNING id, account_id, datenklasse_id, max_tage, updated_at
`, accountID, datenklasseID, maxTage).Scan(&e.ID, &e.AccountID, &e.DatenklasseID, &e.MaxTage, &e.UpdatedAt)
if err != nil {
return LoeschfristEinstellung{}, fmt.Errorf("store: upsert loeschfrist einstellung: %w", err)
}
return e, nil
}
// ListLoeschfristEinstellungenForAccount liefert alle konfigurierten
// Fristen eines Mandanten.
func (s *Store) ListLoeschfristEinstellungenForAccount(ctx context.Context, accountID string) ([]LoeschfristEinstellung, error) {
rows, err := s.db(ctx).Query(ctx, `
SELECT id, account_id, datenklasse_id, max_tage, updated_at
FROM loeschfrist_einstellung WHERE account_id = $1 ORDER BY datenklasse_id
`, accountID)
if err != nil {
return nil, fmt.Errorf("store: list loeschfrist einstellungen: %w", err)
}
defer rows.Close()
var out []LoeschfristEinstellung
for rows.Next() {
var e LoeschfristEinstellung
if err := rows.Scan(&e.ID, &e.AccountID, &e.DatenklasseID, &e.MaxTage, &e.UpdatedAt); err != nil {
return nil, fmt.Errorf("store: scan loeschfrist einstellung: %w", err)
}
out = append(out, e)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list loeschfrist einstellungen: %w", err)
}
return out, nil
}

View File

@@ -0,0 +1,41 @@
package store_test
import (
"context"
"testing"
)
func TestLoeschfristEinstellungUpsert(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
e, err := s.UpsertLoeschfristEinstellung(ctx, accID, "personenbezogen", 90)
if err != nil {
t.Fatalf("UpsertLoeschfristEinstellung: %v", err)
}
if e.MaxTage != 90 {
t.Fatalf("MaxTage = %d, want 90", e.MaxTage)
}
// Erneutes Upsert für dieselbe Datenklasse überschreibt statt zu duplizieren.
if _, err := s.UpsertLoeschfristEinstellung(ctx, accID, "personenbezogen", 30); err != nil {
t.Fatalf("UpsertLoeschfristEinstellung (Update): %v", err)
}
if _, err := s.UpsertLoeschfristEinstellung(ctx, accID, "besondere_kategorie", 14); err != nil {
t.Fatalf("UpsertLoeschfristEinstellung: %v", err)
}
liste, err := s.ListLoeschfristEinstellungenForAccount(ctx, accID)
if err != nil {
t.Fatalf("ListLoeschfristEinstellungenForAccount: %v", err)
}
if len(liste) != 2 {
t.Fatalf("got %d Einstellungen, want 2 (Upsert darf nicht duplizieren)", len(liste))
}
for _, e := range liste {
if e.DatenklasseID == "personenbezogen" && e.MaxTage != 30 {
t.Fatalf("personenbezogen MaxTage = %d, want 30 nach Update", e.MaxTage)
}
}
}

View File

@@ -0,0 +1,6 @@
DROP TRIGGER audit_log_append_only ON audit_log;
DROP TABLE audit_log;
ALTER TABLE account DROP COLUMN verified;
ALTER TABLE app_user DROP CONSTRAINT app_user_role_check;
ALTER TABLE app_user ADD CONSTRAINT app_user_role_check
CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei'));

View File

@@ -0,0 +1,36 @@
-- "admin" ist eine fünfte app_user-Rolle: Betreiber-Personal (Netcell-IT),
-- nicht an einen Mandanten-Geschäftszweck (creator/agentur/marke/kanzlei)
-- gebunden, sondern zuständig für die Plattform selbst (Accounts,
-- Kanzlei-Verzeichnis, Audit-Log). Bewusst KEIN eigenes account_role-Feld
-- getrennt von app_user.role — ein Admin-Login ist genauso ein app_user
-- wie jeder andere, nur mit einer anderen Rolle. Es gibt bewusst keine
-- Selbstregistrierung für "admin" über /register (siehe internal/web) —
-- der erste Admin wird per SQL angelegt (siehe CLAUDE.md).
ALTER TABLE app_user DROP CONSTRAINT app_user_role_check;
ALTER TABLE app_user ADD CONSTRAINT app_user_role_check
CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei', 'admin'));
-- verified markiert eine Kanzlei-Account als für das öffentliche,
-- kostenlose Kanzlei-Verzeichnis freigegeben (siehe CLAUDE.md, § 49b
-- Abs. 3 BRAO: keine Sachvorteile, kein Routing — das Verzeichnis ist
-- eine reine Auflistung, keine Vermittlung). Nur für Accounts mit
-- mindestens einem Nutzer der Rolle "kanzlei" sinnvoll; das erzwingt die
-- Anwendungsschicht, nicht die Datenbank.
ALTER TABLE account ADD COLUMN verified BOOLEAN NOT NULL DEFAULT false;
-- Audit-Log für Admin-Aktionen: append-only aus demselben Grund wie
-- finding/extraction/evidence_package — ein Protokoll, das man ändern
-- kann, ist kein Nachweis mehr.
CREATE TABLE audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
actor_user_id UUID NOT NULL REFERENCES app_user (id),
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
details TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TRIGGER audit_log_append_only
BEFORE UPDATE OR DELETE ON audit_log
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();

View File

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

View File

@@ -0,0 +1,9 @@
-- asset war in 0001 ohne append-only-Trigger angelegt, weil bis jetzt
-- nichts Assets tatsächlich schrieb. Ein hochgeladenes Standbild ist
-- Teil der Beweiskette (SHA-256, siehe CLAUDE.md) genau wie extraction/
-- finding/evidence_package — es nachträglich austauschen zu können,
-- würde denselben Grund unterlaufen, aus dem diese Tabellen append-only
-- sind.
CREATE TRIGGER asset_append_only
BEFORE UPDATE OR DELETE ON asset
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();

View File

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

View File

@@ -0,0 +1,21 @@
-- Ein Kunde kann seinen eigenen Instagram- oder TikTok-Account per
-- OAuth verbinden (siehe internal/socialconnect), damit die
-- Beweissicherung einen veröffentlichten Beitrag künftig direkt
-- abrufen kann statt ihn manuell hochzuladen. Anders als
-- extraction/finding/asset ist das NICHT append-only: Tokens laufen ab
-- und werden erneuert, eine Verbindung kann getrennt und neu
-- hergestellt werden — das ist ein normaler Konfigurationszustand, kein
-- Beweis-Eintrag. Ein Account hat höchstens eine Verbindung pro
-- Plattform (erneutes Verbinden ersetzt die alte, siehe
-- ON CONFLICT in UpsertPlatformConnection).
CREATE TABLE platform_connection (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES account (id),
platform TEXT NOT NULL CHECK (platform IN ('instagram', 'tiktok')),
platform_user_id TEXT NOT NULL,
access_token TEXT NOT NULL,
refresh_token TEXT NOT NULL DEFAULT '',
expires_at TIMESTAMPTZ,
connected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (account_id, platform)
);

View File

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

View File

@@ -0,0 +1,9 @@
-- Ein Beitrag kann mehr als ein Standbild bekommen: das ursprüngliche
-- Beweisfoto beim Prüfen (purpose='initial') und später ein Nachweis
-- flüchtiger Kennzahlen (purpose='insights') — Instagram hält Story-
-- Insights nach eigener Aussage nur 24 Stunden vor, danach sind sie
-- auch über den offiziellen Datenexport nicht mehr zu bekommen.
-- Default 'initial' erhält die Bedeutung aller vor dieser Migration
-- angelegten Zeilen unverändert.
ALTER TABLE asset ADD COLUMN purpose TEXT NOT NULL DEFAULT 'initial'
CHECK (purpose IN ('initial', 'insights'));

View File

@@ -0,0 +1,104 @@
-- Best-effort-Rückbau auf die Schema-Form des alten Werberecht-Produkts
-- (Stand nach Migration 0007) — reine Struktur, keine Daten. Ein
-- kompletter Produktwechsel wird in der Praxis nicht zurückgerollt,
-- diese Datei existiert nur, damit "migrate down" nicht bricht.
DROP TABLE IF EXISTS antrag;
DROP TABLE IF EXISTS werkzeug_sperre;
DROP TABLE IF EXISTS werkzeug;
DROP TABLE IF EXISTS abteilung;
ALTER TABLE app_user DROP CONSTRAINT app_user_role_check;
ALTER TABLE app_user ADD CONSTRAINT app_user_role_check
CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei', 'admin'));
ALTER TABLE account ADD COLUMN verified BOOLEAN NOT NULL DEFAULT false;
CREATE TABLE submission (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
platform TEXT NOT NULL CHECK (platform IN ('instagram', 'tiktok', 'youtube', 'linkedin')),
post_type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'checked', 'published', 'archived')),
caption TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
account_id UUID NOT NULL REFERENCES account (id)
);
CREATE TABLE asset (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
submission_id UUID NOT NULL REFERENCES submission (id),
kind TEXT NOT NULL CHECK (kind IN ('image', 'video', 'file')),
path TEXT NOT NULL,
sha256 TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
purpose TEXT NOT NULL DEFAULT 'initial' CHECK (purpose IN ('initial', 'insights'))
);
CREATE TRIGGER asset_append_only
BEFORE UPDATE OR DELETE ON asset
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
CREATE TABLE extraction (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
submission_id UUID NOT NULL REFERENCES submission (id),
payload JSONB NOT NULL,
model_version TEXT NOT NULL,
prompt_version TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TRIGGER extraction_append_only
BEFORE UPDATE OR DELETE ON extraction
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
CREATE TABLE finding (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
submission_id UUID NOT NULL REFERENCES submission (id),
extraction_id UUID REFERENCES extraction (id),
rule_id TEXT NOT NULL,
rule_version INTEGER NOT NULL,
severity TEXT NOT NULL CHECK (severity IN ('niedrig', 'mittel', 'hoch')),
supersedes UUID REFERENCES finding (id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
title TEXT NOT NULL,
fix TEXT NOT NULL,
sources TEXT[] NOT NULL DEFAULT '{}'
);
CREATE INDEX finding_supersedes_idx ON finding (supersedes) WHERE supersedes IS NOT NULL;
CREATE TRIGGER finding_append_only
BEFORE UPDATE OR DELETE ON finding
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
CREATE TABLE evidence_package (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
submission_id UUID NOT NULL REFERENCES submission (id),
dossier_path TEXT NOT NULL,
sha256 TEXT NOT NULL,
timestamp_token BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TRIGGER evidence_package_append_only
BEFORE UPDATE OR DELETE ON evidence_package
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
CREATE TABLE participant (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
submission_id UUID NOT NULL REFERENCES submission (id),
role TEXT NOT NULL CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei')),
name TEXT NOT NULL,
vorgegeben BOOLEAN NOT NULL DEFAULT false,
freigegeben BOOLEAN NOT NULL DEFAULT false,
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE platform_connection (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES account (id),
platform TEXT NOT NULL CHECK (platform IN ('instagram', 'tiktok')),
platform_user_id TEXT NOT NULL,
access_token TEXT NOT NULL,
refresh_token TEXT NOT NULL DEFAULT '',
expires_at TIMESTAMPTZ,
connected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (account_id, platform)
);

View File

@@ -0,0 +1,117 @@
-- Produktwechsel: Deklarix war eine Kennzeichnungsprüfung für
-- Werbe-Content (UWG/MStV), wird jetzt eine KI-Antragsprüfung
-- (Fragebogen -> Datenklasse/KI-VO-Einstufung -> Werkzeug-Katalog ->
-- Entscheidung). Alles, was ausschließlich für das alte Werberecht-
-- Produkt existierte, wird entfernt. account/app_user/session/
-- audit_log bleiben — Mandantentrennung, Login und
-- Protokollierungsprinzip sind produktunabhängig.
DROP TABLE IF EXISTS platform_connection;
DROP TABLE IF EXISTS asset;
DROP TABLE IF EXISTS participant;
DROP TABLE IF EXISTS evidence_package;
DROP TABLE IF EXISTS finding;
DROP TABLE IF EXISTS extraction;
DROP TABLE IF EXISTS submission;
-- "verified"/Kanzlei-Verzeichnis gab es nur für die alte Berufsrecht-
-- Sonderrolle "kanzlei".
ALTER TABLE account DROP COLUMN IF EXISTS verified;
-- Fünf Rollen (Ebenen 2-5 der Frontend-Spezifikation):
-- mitarbeiter Ebene 2 — stellt Anträge, sieht nur eigene
-- verantwortlicher Ebene 3 — KI-Verantwortlicher, volles Entscheidungsrecht
-- pruefer Ebene 3 — identische Sicht wie verantwortlicher, aber
-- ohne Entscheidungsrecht (reine Prüfsicht)
-- admin Ebene 4 — Mandanten-Verwaltung (Nutzer, Abteilungen,
-- Anmeldeverfahren, eigene Werkzeug-Freigaben) — bezogen
-- auf GENAU EINEN Mandanten, nicht plattformweit
-- betreiber Ebene 5 — Netcell-IT-Personal, plattformweit
-- (Werkzeugkatalog, Regelwerk, Mandantenverwaltung);
-- entspricht der alten "admin"-Rolle vor dieser Migration
--
-- Die alte CHECK-Constraint muss zuerst weg, sonst lehnt sie die
-- Datenmigration unten (die neue Rollenwerte wie "betreiber" schreibt)
-- sofort ab — die alte Constraint kennt diese Werte ja noch nicht.
ALTER TABLE app_user DROP CONSTRAINT app_user_role_check;
-- Bestehende Zeilen auf gültige neue Werte umstellen, bevor die neue
-- Constraint das erzwingt — sonst schlägt sie auf jeder Installation
-- mit echten Nutzern fehl. Die alte Rolle "admin" war der Plattform-
-- Betreiber (Netcell-IT) — wird zu "betreiber", nicht zum neuen,
-- mandantenbezogenen "admin". Alle anderen alten Rollen (creator/agentur/
-- marke/kanzlei) waren gewöhnliche Mandanten-Logins ohne Sonderrechte —
-- werden zu "mitarbeiter", der schlankesten neuen Rolle.
UPDATE app_user SET role = 'betreiber' WHERE role = 'admin';
UPDATE app_user SET role = 'mitarbeiter' WHERE role IN ('creator', 'agentur', 'marke', 'kanzlei');
ALTER TABLE app_user ADD CONSTRAINT app_user_role_check
CHECK (role IN ('mitarbeiter', 'verantwortlicher', 'pruefer', 'admin', 'betreiber'));
-- Stammdaten: Abteilungen zur Auswahl im Fragebogen (Feld A.abteilung).
CREATE TABLE abteilung (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES account (id),
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (account_id, name)
);
-- Werkzeugkatalog: der eigentliche Wert des Produkts. account_id NULL
-- markiert einen zentral gepflegten, für alle Mandanten identischen
-- Katalogeintrag; ein gesetzter account_id ist eine mandantenspezifische
-- Ergänzung (siehe Spezifikation "jeder Mandant kann zusätzlich eigene
-- Einträge ... führen"). letzte_pruefung und quelle sind Pflicht — jede
-- Zusicherung im Katalog muss belegbar sein, siehe CLAUDE.md.
CREATE TABLE werkzeug (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID REFERENCES account (id),
name TEXT NOT NULL,
anbieter TEXT NOT NULL,
verarbeitungsort TEXT NOT NULL CHECK (verarbeitungsort IN ('EU', 'USA', 'gemischt', 'on-prem')),
avv_verfuegbar BOOLEAN NOT NULL DEFAULT false,
avv_url TEXT NOT NULL DEFAULT '',
training_opt_out BOOLEAN NOT NULL DEFAULT false,
training_standard BOOLEAN NOT NULL DEFAULT false,
aufbewahrung_tage INTEGER NOT NULL DEFAULT 0,
zertifizierungen TEXT[] NOT NULL DEFAULT '{}',
geeignete_zwecke TEXT[] NOT NULL DEFAULT '{}',
einschraenkungen TEXT[] NOT NULL DEFAULT '{}',
letzte_pruefung TIMESTAMPTZ NOT NULL,
quelle TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Ein Mandant kann einen (auch zentralen) Katalogeintrag für sich
-- sperren, ohne den zentralen Katalog selbst zu verändern.
CREATE TABLE werkzeug_sperre (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES account (id),
werkzeug_id UUID NOT NULL REFERENCES werkzeug (id),
grund TEXT NOT NULL,
gesperrt_am TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (account_id, werkzeug_id)
);
-- Antrag: das Vorhaben aus Fragebogen-Abschnitt A, plus die vollständigen
-- Antworten aus B/C/D als JSON (adaptiver Fragebogen — welche Folgefragen
-- beantwortet wurden, hängt von vorherigen Antworten ab, ein starres
-- Spaltenschema würde das nicht abbilden). status ist eine normale
-- Zustandsänderung (wie submission es früher war), kein Beweis-Eintrag.
CREATE TABLE antrag (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES account (id),
ersteller_user_id UUID NOT NULL REFERENCES app_user (id),
abteilung_id UUID REFERENCES abteilung (id),
titel TEXT NOT NULL,
beschreibung TEXT NOT NULL DEFAULT '',
ergebnis TEXT NOT NULL DEFAULT '',
haeufigkeit TEXT NOT NULL DEFAULT 'einmalig'
CHECK (haeufigkeit IN ('einmalig', 'gelegentlich', 'taeglich', 'automatisiert')),
antworten JSONB NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'entwurf'
CHECK (status IN ('entwurf', 'eingereicht', 'entschieden')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

View File

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

View File

@@ -0,0 +1,26 @@
-- Bewertung: der berechnete Vorschlag für einen Antrag (Datenklasse,
-- KI-VO-Einstufung, Anforderungsprofil, zulässige/ausgeschlossene
-- Werkzeuge mit Herleitung). Append-only wie finding/extraction im
-- alten Produkt — eine neue Bewertung (z. B. nach Änderung der
-- Antworten oder des Katalogs) ergänzt die alte, ersetzt sie nicht;
-- die Historie bleibt für den Audit erhalten. regelwerk_version und
-- katalog_version werden zum Berechnungszeitpunkt eingefroren.
CREATE TABLE bewertung (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
antrag_id UUID NOT NULL REFERENCES antrag (id),
datenklasse TEXT NOT NULL,
datenklasse_herleitung TEXT NOT NULL,
einstufung TEXT NOT NULL,
einstufung_herleitung TEXT NOT NULL,
verboten BOOLEAN NOT NULL DEFAULT false,
anforderungen JSONB NOT NULL DEFAULT '[]',
zulaessige_werkzeuge TEXT[] NOT NULL DEFAULT '{}',
ausgeschlossene_werkzeuge JSONB NOT NULL DEFAULT '[]',
regelwerk_version TEXT NOT NULL,
katalog_version TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TRIGGER bewertung_append_only
BEFORE UPDATE OR DELETE ON bewertung
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
-- Registereintrag: wird automatisch bei jeder Genehmigung erzeugt
-- (genehmigt / genehmigt_mit_auflagen), siehe CLAUDE.md, Entscheidung/
-- Register/Wiedervorlage. Bewusst denormalisiert (eigene Textspalten
-- statt Joins über bewertung/entscheidung/werkzeug) — ein Registereintrag
-- ist ein für sich lesbarer, exportierbarer Nachweis, der nicht von
-- einem späteren Katalog- oder Abteilungs-Wandel abhängen darf.
-- Append-only wie bewertung/entscheidung.
CREATE TABLE registereintrag (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES account (id),
antrag_id UUID NOT NULL REFERENCES antrag (id),
entscheidung_id UUID NOT NULL REFERENCES entscheidung (id),
zweck TEXT NOT NULL,
abteilung TEXT NOT NULL DEFAULT '',
werkzeug TEXT NOT NULL,
datenklasse TEXT NOT NULL,
einstufung TEXT NOT NULL,
auflagen TEXT[] NOT NULL DEFAULT '{}',
verantwortlicher TEXT NOT NULL,
entschieden_am TIMESTAMPTZ NOT NULL,
gueltig_bis TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TRIGGER registereintrag_append_only
BEFORE UPDATE OR DELETE ON registereintrag
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();

View File

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

View File

@@ -0,0 +1,7 @@
-- Nutzer können nicht gelöscht werden (app_user wird von antrag,
-- session, bewertung [über antrag], entscheidung, audit_log,
-- registereintrag [über antrag] per Foreign Key referenziert — ein
-- Hard-Delete würde die Historie zerstören). Stattdessen: deaktivieren.
-- Ein deaktivierter Nutzer kann sich nicht mehr anmelden, bleibt aber
-- als Akteur in Anträgen/Entscheidungen/Audit-Log nachvollziehbar.
ALTER TABLE app_user ADD COLUMN active BOOLEAN NOT NULL DEFAULT true;

View File

@@ -0,0 +1 @@
ALTER TABLE account DROP COLUMN einladung_token;

View File

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

View File

@@ -0,0 +1 @@
ALTER TABLE session DROP COLUMN impersonated_by_user_id;

View File

@@ -0,0 +1,8 @@
-- Support-Login (Betreiber meldet sich als Kunden-Nutzer an, siehe
-- internal/web/betreiber_handlers.go). impersonated_by_user_id ist
-- gesetzt, wenn diese Sitzung durch einen Betreiber-Support-Login
-- entstanden ist (nicht durch den regulären Login des Nutzers selbst)
-- — die authenticate-Middleware liest das, um in der Nav einen
-- deutlichen Hinweis-Banner anzuzeigen, damit niemand vergisst, dass
-- er/sie gerade im Kontext eines fremden Kontos handelt.
ALTER TABLE session ADD COLUMN impersonated_by_user_id UUID REFERENCES app_user (id);

View File

@@ -0,0 +1,5 @@
ALTER TABLE werkzeug DROP COLUMN subprozessoren;
UPDATE werkzeug SET aufbewahrung_tage = 0 WHERE aufbewahrung_tage IS NULL;
ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage SET DEFAULT 0;
ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage SET NOT NULL;

View File

@@ -0,0 +1,12 @@
-- aufbewahrung_tage war bisher NOT NULL DEFAULT 0 — das machte "0" doppeldeutig:
-- mal eine echte, belegte Zusicherung (z. B. DeepL: sofortige Löschung),
-- mal ein Platzhalter für "vom Anbieter nicht beziffert". NULL trennt das
-- sauber: "unbekannt" statt "angeblich sofort gelöscht".
ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage DROP NOT NULL;
ALTER TABLE werkzeug ALTER COLUMN aufbewahrung_tage DROP DEFAULT;
-- Subprozessoren (z. B. Anthropic als Unterauftragsverarbeiter bei
-- Microsoft 365 Copilot, AWS bei DeepL) verändern die EU/USA-Einstufung
-- eines Werkzeugs materiell — bisher nur als Freitext in
-- "einschraenkungen" erfasst, jetzt als eigenes, durchsuchbares Feld.
ALTER TABLE werkzeug ADD COLUMN subprozessoren TEXT[] NOT NULL DEFAULT '{}';

View File

@@ -0,0 +1,20 @@
ALTER TABLE werkzeug ADD COLUMN verarbeitungsort TEXT;
-- Ein Rückbau von Freitext-Ländern auf den alten Vier-Werte-Eimer ist
-- verlustbehaftet (welches Land genau "gemischt" war, geht verloren) —
-- bestmögliche Näherung statt Datenverlust durch NULL.
UPDATE werkzeug SET verarbeitungsort =
CASE
WHEN verarbeitungslaender = ARRAY['On-Premise (selbst gehostet)'] THEN 'on-prem'
WHEN verarbeitungslaender = ARRAY['USA'] THEN 'USA'
WHEN array_length(verarbeitungslaender, 1) IS NULL OR array_length(verarbeitungslaender, 1) = 0 THEN 'gemischt'
WHEN 'USA' = ANY(verarbeitungslaender) THEN 'gemischt'
ELSE 'EU'
END;
ALTER TABLE werkzeug ALTER COLUMN verarbeitungsort SET NOT NULL;
ALTER TABLE werkzeug ADD CONSTRAINT werkzeug_verarbeitungsort_check
CHECK (verarbeitungsort IN ('EU', 'USA', 'gemischt', 'on-prem'));
ALTER TABLE werkzeug DROP COLUMN verarbeitungslaender;
ALTER TABLE werkzeug DROP COLUMN dpf_zertifiziert;

View File

@@ -0,0 +1,29 @@
-- "EU"/"USA"/"gemischt"/"on-prem" als grobe Eimer verschleierten, dass
-- auch die USA bereits ein Drittland sind (DSGVO Art. 44 ff. kennt nur
-- EU/EWR vs. Drittland) und dass "gemischt" nichts über die tatsächlich
-- beteiligten Länder aussagt (z. B. Irland+USA bei Microsoft 365
-- Copilot vs. Frankreich bei Mistral) — beides macht einen Unterschied
-- für die Einschätzung, nicht nur für die Anzeige. Ersetzt durch eine
-- Liste tatsächlicher Länder (oder einer Region, wenn der Anbieter
-- selbst keinen einzelnen Staat benennt, z. B. "EU-Region" bei
-- OpenAI/Microsoft).
ALTER TABLE werkzeug ADD COLUMN verarbeitungslaender TEXT[] NOT NULL DEFAULT '{}';
ALTER TABLE werkzeug DROP CONSTRAINT werkzeug_verarbeitungsort_check;
UPDATE werkzeug SET verarbeitungslaender =
CASE verarbeitungsort
WHEN 'EU' THEN ARRAY['EU-Region (Land nicht migriert, bitte prüfen)']
WHEN 'USA' THEN ARRAY['USA']
WHEN 'on-prem' THEN ARRAY['On-Premise (selbst gehostet)']
ELSE ARRAY['nicht spezifiziert (bitte prüfen)']
END;
ALTER TABLE werkzeug DROP COLUMN verarbeitungsort;
-- Ob ein US-Anbieter unter dem EU-US Data Privacy Framework zertifiziert
-- ist (schmalere, für Schrems-Nachfolgeverfahren anfällige Rechtsgrundlage)
-- oder sich rein auf Standardvertragsklauseln stützt, stand bisher nur als
-- Fließtext in "einschraenkungen" — jetzt strukturiert und damit künftig
-- filterbar.
ALTER TABLE werkzeug ADD COLUMN dpf_zertifiziert BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,9 @@
ALTER TABLE antrag DROP CONSTRAINT antrag_status_check;
UPDATE antrag SET status = 'eingereicht' WHERE status = 'wartet_auf_freigabe';
ALTER TABLE antrag ADD CONSTRAINT antrag_status_check
CHECK (status IN ('entwurf', 'eingereicht', 'entschieden'));
DROP TABLE freigabeschritt;
DROP TABLE freigabe_regel;
DROP TABLE nutzer_genehmiger_rolle;
DROP TABLE genehmiger_rolle;

View File

@@ -0,0 +1,59 @@
-- Konfigurierbarer Mehrfach-Freigabe-Workflow (2026-08-31): manche
-- Anträge brauchen zusätzlich zur normalen Fachebene-Entscheidung eine
-- Freigabe durch bestimmte Personen (z. B. Datenschutzbeauftragte bei
-- dsfa_erforderlich, Geschäftsführung bei hochrisiko). Bewusst additiv
-- zur bestehenden app_user.role (Zugriffskontrolle) — eine
-- Genehmiger-Rolle ist eine reine Freigabe-Funktion, keine Berechtigung,
-- und eine Person kann mehrere davon gleichzeitig innehaben.
-- Stammdaten wie abteilung, pro Mandant frei benennbar.
CREATE TABLE genehmiger_rolle (
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)
);
-- Welche Personen eine Genehmiger-Rolle innehaben (n:m).
CREATE TABLE nutzer_genehmiger_rolle (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
app_user_id UUID NOT NULL REFERENCES app_user (id),
genehmiger_rolle_id UUID NOT NULL REFERENCES genehmiger_rolle (id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (app_user_id, genehmiger_rolle_id)
);
-- "Wenn Bedingung X zutrifft, ist zusätzlich eine Freigabe durch
-- Genehmiger-Rolle Y nötig." Bedingung ist bewusst eine der bereits vom
-- Regelwerk abgeleiteten Größen (Anforderungs-ID, Einstufungs-ID,
-- Datenklasse-ID) — kein freier Regel-Editor, keine beliebige Logik.
CREATE TABLE freigabe_regel (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES account (id),
bedingung_typ TEXT NOT NULL CHECK (bedingung_typ IN ('anforderung', 'einstufung', 'datenklasse')),
bedingung_wert TEXT NOT NULL,
genehmiger_rolle_id UUID NOT NULL REFERENCES genehmiger_rolle (id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Pro Antrag ein Eintrag je durch eine Regel ausgelöster nötiger
-- Freigabe. Nicht append-only (wie werkzeug) — ein Freigabeschritt ist
-- eine einzelne Aufgabe, die von ausstehend in einen Endzustand
-- übergeht, keine Historie mehrerer Entscheidungen zum selben Schritt.
CREATE TABLE freigabeschritt (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
antrag_id UUID NOT NULL REFERENCES antrag (id),
genehmiger_rolle_id UUID NOT NULL REFERENCES genehmiger_rolle (id),
status TEXT NOT NULL DEFAULT 'ausstehend' CHECK (status IN ('ausstehend', 'genehmigt', 'abgelehnt')),
entschieden_von UUID REFERENCES app_user (id),
entschieden_am TIMESTAMPTZ,
kommentar TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Ein Antrag, der auf zusätzliche Freigaben wartet, ist weder "nur
-- eingereicht" noch schon "entschieden" — eigener Zwischenzustand.
ALTER TABLE antrag DROP CONSTRAINT antrag_status_check;
ALTER TABLE antrag ADD CONSTRAINT antrag_status_check
CHECK (status IN ('entwurf', 'eingereicht', 'wartet_auf_freigabe', 'entschieden'));

View File

@@ -0,0 +1 @@
ALTER TABLE genehmiger_rolle DROP COLUMN beschreibung;

View File

@@ -0,0 +1 @@
ALTER TABLE genehmiger_rolle ADD COLUMN beschreibung TEXT NOT NULL DEFAULT '';

View File

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

View File

@@ -0,0 +1,10 @@
CREATE TABLE password_reset_token (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES app_user(id),
token TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX password_reset_token_user_id_idx ON password_reset_token(user_id);

View File

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

View File

@@ -0,0 +1,8 @@
CREATE TABLE loeschfrist_einstellung (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES account(id),
datenklasse_id TEXT NOT NULL,
max_tage INTEGER NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (account_id, datenklasse_id)
);

View File

@@ -0,0 +1,58 @@
ALTER TABLE antrag NO FORCE ROW LEVEL SECURITY;
ALTER TABLE antrag DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON antrag;
ALTER TABLE registereintrag NO FORCE ROW LEVEL SECURITY;
ALTER TABLE registereintrag DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON registereintrag;
ALTER TABLE abteilung NO FORCE ROW LEVEL SECURITY;
ALTER TABLE abteilung DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON abteilung;
ALTER TABLE werkzeug_sperre NO FORCE ROW LEVEL SECURITY;
ALTER TABLE werkzeug_sperre DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON werkzeug_sperre;
ALTER TABLE genehmiger_rolle NO FORCE ROW LEVEL SECURITY;
ALTER TABLE genehmiger_rolle DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON genehmiger_rolle;
ALTER TABLE freigabe_regel NO FORCE ROW LEVEL SECURITY;
ALTER TABLE freigabe_regel DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON freigabe_regel;
ALTER TABLE loeschfrist_einstellung NO FORCE ROW LEVEL SECURITY;
ALTER TABLE loeschfrist_einstellung DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON loeschfrist_einstellung;
ALTER TABLE werkzeug NO FORCE ROW LEVEL SECURITY;
ALTER TABLE werkzeug DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON werkzeug;
ALTER TABLE bewertung NO FORCE ROW LEVEL SECURITY;
ALTER TABLE bewertung DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON bewertung;
ALTER TABLE entscheidung NO FORCE ROW LEVEL SECURITY;
ALTER TABLE entscheidung DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON entscheidung;
ALTER TABLE freigabeschritt NO FORCE ROW LEVEL SECURITY;
ALTER TABLE freigabeschritt DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON freigabeschritt;
ALTER TABLE nutzer_genehmiger_rolle NO FORCE ROW LEVEL SECURITY;
ALTER TABLE nutzer_genehmiger_rolle DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON nutzer_genehmiger_rolle;
DO $$
BEGIN
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'deklarix_app') THEN
EXECUTE 'REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM deklarix_app';
EXECUTE 'ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE SELECT, INSERT, UPDATE, DELETE ON TABLES FROM deklarix_app';
EXECUTE 'REVOKE USAGE ON SCHEMA public FROM deklarix_app';
DROP ROLE deklarix_app;
END IF;
END
$$;

View File

@@ -0,0 +1,187 @@
-- Mandantenisolation auf Datenbankebene (Postgres Row-Level Security).
--
-- WICHTIG, per Incident am 2026-09-01 gelernt: RLS-Policies wirken NIE
-- bei Postgres-Superusern, und NIE beim Tabellenbesitzer ohne FORCE ROW
-- LEVEL SECURITY. Welcher Fall zutrifft, hängt von der Umgebung ab:
-- - Produktion verbindet als eigene, nicht-privilegierte Rolle (z. B.
-- "deklarix"), die zugleich Eigentümerin der Tabellen ist (sie hat
-- sie über die Migrationen selbst angelegt) — für sie reicht FORCE
-- ROW LEVEL SECURITY völlig aus, keine weitere Rolle nötig.
-- - Manche Entwicklungs-/Testumgebungen verbinden dagegen als
-- echter Postgres-Superuser (z. B. lokales Docker-Postgres mit
-- "postgres") — für den wirkt FORCE nicht (Superuser sind davon
-- laut Postgres-Dokumentation ausdrücklich ausgenommen). Dort kann
-- zusätzlich eine eingeschränkte Rolle "deklarix_app" angelegt
-- werden, für die die Policies unabhängig von FORCE gelten.
--
-- Diese Migration deckt BEIDE Fälle ab, ohne bei fehlendem CREATEROLE
-- fehlzuschlagen (das brachte den Dienst am 2026-09-01 für ~3 Minuten
-- zum Absturz, siehe CLAUDE.md) — das Anlegen von "deklarix_app" ist
-- rein optional und wird übersprungen, wenn die aktuelle Rolle dafür
-- keine Berechtigung hat.
DO $$
BEGIN
IF EXISTS (SELECT FROM pg_roles WHERE rolname = current_user AND rolcreaterole) THEN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'deklarix_app') THEN
CREATE ROLE deklarix_app NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS NOLOGIN;
END IF;
EXECUTE 'GRANT USAGE ON SCHEMA public TO deklarix_app';
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO deklarix_app';
EXECUTE 'ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO deklarix_app';
ELSE
RAISE NOTICE 'Rolle % hat kein CREATEROLE — deklarix_app wird übersprungen, FORCE ROW LEVEL SECURITY schützt stattdessen direkt die bestehende (Tabellenbesitzer-)Rolle.', current_user;
END IF;
END
$$;
-- Login-Fähigkeit + Passwort für deklarix_app (falls angelegt) werden
-- einmalig manuell je Umgebung gesetzt, NIE in einer versionierten
-- Migration (Klartext-Secret gehört nicht ins Repo):
-- ALTER ROLE deklarix_app WITH LOGIN PASSWORD '<generiertes Secret>';
-- Danach optional DATABASE_URL_APP in der jeweiligen deklarix.env
-- eintragen. Für Umgebungen, in denen die Anwendung bereits als
-- Tabellenbesitzer (nicht-Superuser) verbindet, ist das NICHT nötig —
-- FORCE ROW LEVEL SECURITY unten reicht dort aus.
-- ─── Tabellen MIT direkter account_id-Spalte ───────────────────────────
-- Eine einzelne Policy (FOR ALL) pro Tabelle deckt SELECT/UPDATE/DELETE
-- (USING) und INSERT/UPDATE (WITH CHECK) ab. NULLIF(..., '')::uuid
-- verhindert einen harten Cast-Fehler, wenn app.account_id nie gesetzt
-- oder auf '' steht (z. B. vor der eigentlichen Anmeldung) — die
-- Bedingung wird dann einfach UNKNOWN/false statt eines SQL-Fehlers.
CREATE POLICY tenant_isolation ON antrag FOR ALL USING (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
) WITH CHECK (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
);
ALTER TABLE antrag ENABLE ROW LEVEL SECURITY;
ALTER TABLE antrag FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON registereintrag FOR ALL USING (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
) WITH CHECK (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
);
ALTER TABLE registereintrag ENABLE ROW LEVEL SECURITY;
ALTER TABLE registereintrag FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON abteilung FOR ALL USING (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
) WITH CHECK (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
);
ALTER TABLE abteilung ENABLE ROW LEVEL SECURITY;
ALTER TABLE abteilung FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON werkzeug_sperre FOR ALL USING (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
) WITH CHECK (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
);
ALTER TABLE werkzeug_sperre ENABLE ROW LEVEL SECURITY;
ALTER TABLE werkzeug_sperre FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON genehmiger_rolle FOR ALL USING (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
) WITH CHECK (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
);
ALTER TABLE genehmiger_rolle ENABLE ROW LEVEL SECURITY;
ALTER TABLE genehmiger_rolle FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON freigabe_regel FOR ALL USING (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
) WITH CHECK (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
);
ALTER TABLE freigabe_regel ENABLE ROW LEVEL SECURITY;
ALTER TABLE freigabe_regel FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON loeschfrist_einstellung FOR ALL USING (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
) WITH CHECK (
account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
);
ALTER TABLE loeschfrist_einstellung ENABLE ROW LEVEL SECURITY;
ALTER TABLE loeschfrist_einstellung FORCE ROW LEVEL SECURITY;
-- ─── werkzeug: account_id NULLABLE (NULL = zentraler Katalog) ──────────
-- Lesen: jeder sieht zentrale (NULL) Einträge plus die eigenen. NUR der
-- Betreiber darf einen zentralen (NULL) Eintrag anlegen/ändern, ein
-- Mandant nur seine eigenen — sonst könnte ein Mandant über einen
-- vergessenen Anwendungscheck einen zentralen Katalogeintrag verändern.
CREATE POLICY tenant_isolation ON werkzeug FOR ALL USING (
account_id IS NULL
OR account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
) WITH CHECK (
(account_id IS NULL AND current_setting('app.is_betreiber', true) = 'true')
OR account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
);
ALTER TABLE werkzeug ENABLE ROW LEVEL SECURITY;
ALTER TABLE werkzeug FORCE ROW LEVEL SECURITY;
-- ─── Tabellen OHNE eigene account_id, über Fremdschlüssel abgeleitet ───
-- antrag/genehmiger_rolle sind selbst schon RLS-geschützt (s. o.) — eine
-- Unterabfrage gegen sie erbt in derselben Sitzung automatisch dieselbe
-- Mandantengrenze, ohne die Bedingung hier zu duplizieren.
CREATE POLICY tenant_isolation ON bewertung FOR ALL USING (
antrag_id IN (SELECT id FROM antrag)
) WITH CHECK (
antrag_id IN (SELECT id FROM antrag)
);
ALTER TABLE bewertung ENABLE ROW LEVEL SECURITY;
ALTER TABLE bewertung FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON entscheidung FOR ALL USING (
antrag_id IN (SELECT id FROM antrag)
) WITH CHECK (
antrag_id IN (SELECT id FROM antrag)
);
ALTER TABLE entscheidung ENABLE ROW LEVEL SECURITY;
ALTER TABLE entscheidung FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON freigabeschritt FOR ALL USING (
antrag_id IN (SELECT id FROM antrag)
) WITH CHECK (
antrag_id IN (SELECT id FROM antrag)
);
ALTER TABLE freigabeschritt ENABLE ROW LEVEL SECURITY;
ALTER TABLE freigabeschritt FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON nutzer_genehmiger_rolle FOR ALL USING (
genehmiger_rolle_id IN (SELECT id FROM genehmiger_rolle)
) WITH CHECK (
genehmiger_rolle_id IN (SELECT id FROM genehmiger_rolle)
);
ALTER TABLE nutzer_genehmiger_rolle ENABLE ROW LEVEL SECURITY;
ALTER TABLE nutzer_genehmiger_rolle FORCE ROW LEVEL SECURITY;
-- ─── Bewusst OHNE RLS ───────────────────────────────────────────────────
-- account: hat keine account_id-Spalte (ist selbst der Mandant) und
-- muss bei der Registrierung uneingeschränkt INSERT erlauben, bevor
-- die neue ID überhaupt bekannt ist.
-- app_user: Login/Passwort-Zurücksetzen suchen per E-Mail über ALLE
-- Mandanten hinweg (die Ziel-account_id ist zu diesem Zeitpunkt noch
-- nicht bekannt) — kein Datenleck, da E-Mail-Adressen exakt und nicht
-- in Bulk abgefragt werden, kein sequentiell erratbarer Schlüssel.
-- session, password_reset_token: werden ausschließlich über einen
-- kryptographisch zufälligen, praktisch unerratbaren Token gesucht,
-- nicht über eine sequentielle ID — dieselbe Begründung wie app_user.
-- audit_log: plattformweites Protokoll, wird ausschließlich vom
-- Betreiber (Ebene 5, sieht ohnehin alle Mandanten) gelesen, hat keine
-- eigene account_id-Spalte.

View File

@@ -0,0 +1,4 @@
ALTER TABLE email_vorlage NO FORCE ROW LEVEL SECURITY;
ALTER TABLE email_vorlage DISABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON email_vorlage;
DROP TABLE email_vorlage;

View File

@@ -0,0 +1,55 @@
-- Editierbare E-Mail-Vorlagen, zweistufig wie der Werkzeugkatalog:
-- account_id NULL = plattformweiter Standard (nur vom Betreiber
-- editierbar, z. B. künftige E-Mails, die die Plattform selbst an
-- Mandanten-Admins schickt), account_id gesetzt = mandantenspezifische
-- Übersteuerung (vom Mandanten-Admin editierbar, z. B. eigener Ton/
-- Branding für eine E-Mail, die an die eigenen Mitarbeiter geht).
-- "typ" identifiziert, welche vom System versendete E-Mail gemeint ist
-- (aktuell nur "passwort_zuruecksetzen" — die einzige E-Mail, die das
-- System bisher verschickt, siehe internal/mail). Text darf Platzhalter
-- wie "{{link}}" enthalten, die beim Versand ersetzt werden (siehe
-- internal/web/email_vorlage_handlers.go).
CREATE TABLE email_vorlage (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID REFERENCES account(id),
typ TEXT NOT NULL,
betreff TEXT NOT NULL,
text TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ACHTUNG: ein einfaches UNIQUE (account_id, typ) würde NICHT reichen —
-- SQL behandelt NULL nie als gleich zu NULL, ein normaler UNIQUE-
-- Constraint hätte also beliebig viele Plattform-Standard-Zeilen
-- (account_id IS NULL) je typ zugelassen. Zwei partielle Unique-Indizes
-- statt eines gemeinsamen Constraints, dafür braucht UpsertEmailVorlage
-- zwei unterschiedliche ON-CONFLICT-Ziele (siehe internal/store/email_vorlage.go).
CREATE UNIQUE INDEX email_vorlage_plattform_uidx ON email_vorlage (typ) WHERE account_id IS NULL;
CREATE UNIQUE INDEX email_vorlage_mandant_uidx ON email_vorlage (account_id, typ) WHERE account_id IS NOT NULL;
-- Plattformweiter Standard für die einzige aktuell existierende
-- E-Mail — ohne diese Zeile gäbe es nichts, worauf ResolveEmailVorlage
-- zurückfallen könnte, solange ein Mandant keine eigene Vorlage hat.
INSERT INTO email_vorlage (account_id, typ, betreff, text) VALUES (
NULL,
'passwort_zuruecksetzen',
'Deklarix — Passwort zurücksetzen',
'Hallo,' || E'\n\n' ||
'über diesen Link kannst du dein Deklarix-Passwort zurücksetzen (gültig 1 Stunde):' || E'\n' ||
'{{link}}' || E'\n\n' ||
'Falls du das nicht angefordert hast, ignoriere diese E-Mail.'
);
-- Wie werkzeug (account_id NULLABLE): NULL-Zeilen sind für alle lesbar,
-- aber nur vom Betreiber schreibbar; ein Mandant darf nur seine eigene
-- account_id-Zeile anlegen/ändern.
CREATE POLICY tenant_isolation ON email_vorlage FOR ALL USING (
account_id IS NULL
OR account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
OR current_setting('app.is_betreiber', true) = 'true'
) WITH CHECK (
(account_id IS NULL AND current_setting('app.is_betreiber', true) = 'true')
OR account_id = NULLIF(current_setting('app.account_id', true), '')::uuid
);
ALTER TABLE email_vorlage ENABLE ROW LEVEL SECURITY;
ALTER TABLE email_vorlage FORCE ROW LEVEL SECURITY;

View File

@@ -0,0 +1,6 @@
ALTER TABLE account DROP COLUMN strasse;
ALTER TABLE account DROP COLUMN plz;
ALTER TABLE account DROP COLUMN ort;
ALTER TABLE account DROP COLUMN land;
ALTER TABLE account DROP COLUMN ust_id;
ALTER TABLE account DROP COLUMN rechnungsemail;

View File

@@ -0,0 +1,20 @@
-- Adress- und Abrechnungsdaten der Firma — bisher trug account nur den
-- Namen. NOT NULL DEFAULT '' statt einer harten NOT-NULL-Pflicht ohne
-- Default: bestehende Accounts (vor dieser Migration angelegt) haben
-- diese Daten schlicht noch nicht, das darf die Migration nicht
-- blockieren. Die Anwendungsschicht erzwingt Pflichtfelder nur für NEU
-- angelegte Firmen (Registrierung, Betreiber-Firmenanlage); bestehende
-- Firmen werden nicht rückwirkend gezwungen, sie können es über die
-- neue Firmendaten-Seite (Ebene 4) nachtragen.
ALTER TABLE account ADD COLUMN strasse TEXT NOT NULL DEFAULT '';
ALTER TABLE account ADD COLUMN plz TEXT NOT NULL DEFAULT '';
ALTER TABLE account ADD COLUMN ort TEXT NOT NULL DEFAULT '';
ALTER TABLE account ADD COLUMN land TEXT NOT NULL DEFAULT '';
-- Umsatzsteuer-ID ist bewusst optional (NOT NULL DEFAULT '', keine
-- Pflicht auch bei Neuanlage) — Kleinunternehmer nach §19 UStG haben
-- keine.
ALTER TABLE account ADD COLUMN ust_id TEXT NOT NULL DEFAULT '';
-- Rechnungsemail kann von der E-Mail des ersten (admin-)Logins
-- abweichen (z. B. eine buchhaltung@-Adresse) — eigenes Feld statt
-- Wiederverwendung der Login-E-Mail.
ALTER TABLE account ADD COLUMN rechnungsemail TEXT NOT NULL DEFAULT '';

View File

@@ -0,0 +1,77 @@
// Passwort-Zurücksetzen-Tokens (Migration 0019). Ein Token ist einmal
// verwendbar (used_at) und läuft ab (expires_at) — GetValidPasswordResetToken
// liefert ErrNotFound für "nicht gefunden", "abgelaufen" und "schon
// verwendet" gleichermaßen, damit ein Angreifer über die Fehlermeldung
// nichts über den Zustand eines geratenen Tokens lernt.
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// PasswordResetTokenDuration ist die Gültigkeitsdauer eines frisch
// erzeugten Zurücksetzen-Links.
const PasswordResetTokenDuration = 1 * time.Hour
type PasswordResetToken struct {
ID string
UserID string
Token string
ExpiresAt time.Time
UsedAt *time.Time
CreatedAt time.Time
}
// CreatePasswordResetToken legt einen neuen Zurücksetzen-Token für
// einen Nutzer an. token muss bereits kryptographisch zufällig erzeugt
// sein (siehe auth.NewSessionToken) — store erzeugt keine Tokens selbst.
func (s *Store) CreatePasswordResetToken(ctx context.Context, userID, token string) (PasswordResetToken, error) {
var t PasswordResetToken
err := s.db(ctx).QueryRow(ctx, `
INSERT INTO password_reset_token (user_id, token, expires_at)
VALUES ($1, $2, $3)
RETURNING id, user_id, token, expires_at, used_at, created_at
`, userID, token, time.Now().Add(PasswordResetTokenDuration)).Scan(
&t.ID, &t.UserID, &t.Token, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt,
)
if err != nil {
return PasswordResetToken{}, fmt.Errorf("store: create password reset token: %w", err)
}
return t, nil
}
// GetValidPasswordResetToken liest einen Token nur, wenn er existiert,
// noch nicht abgelaufen und noch nicht verwendet ist.
func (s *Store) GetValidPasswordResetToken(ctx context.Context, token string) (PasswordResetToken, error) {
var t PasswordResetToken
err := s.db(ctx).QueryRow(ctx, `
SELECT id, user_id, token, expires_at, used_at, created_at
FROM password_reset_token
WHERE token = $1 AND used_at IS NULL AND expires_at > now()
`, token).Scan(&t.ID, &t.UserID, &t.Token, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return PasswordResetToken{}, ErrNotFound
}
if err != nil {
return PasswordResetToken{}, fmt.Errorf("store: get valid password reset token: %w", err)
}
return t, nil
}
// MarkPasswordResetTokenUsed verbraucht einen Token, damit derselbe
// Link kein zweites Mal ein Passwort setzen kann.
func (s *Store) MarkPasswordResetTokenUsed(ctx context.Context, id string) error {
tag, err := s.db(ctx).Exec(ctx, `UPDATE password_reset_token SET used_at = now() WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("store: mark password reset token used: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}

View File

@@ -0,0 +1,58 @@
package store_test
import (
"context"
"errors"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
func TestPasswordResetTokenLifecycle(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
userID := testUserID(t, s, accID)
tok, err := s.CreatePasswordResetToken(ctx, userID, "test-token-123")
if err != nil {
t.Fatalf("CreatePasswordResetToken: %v", err)
}
if tok.UserID != userID || tok.Token != "test-token-123" {
t.Fatalf("PasswordResetToken = %+v, unerwartete Werte", tok)
}
got, err := s.GetValidPasswordResetToken(ctx, "test-token-123")
if err != nil || got.ID != tok.ID {
t.Fatalf("GetValidPasswordResetToken = %+v, err=%v", got, err)
}
if err := s.MarkPasswordResetTokenUsed(ctx, tok.ID); err != nil {
t.Fatalf("MarkPasswordResetTokenUsed: %v", err)
}
// Ein verbrauchter Token gilt nicht mehr als gültig.
if _, err := s.GetValidPasswordResetToken(ctx, "test-token-123"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err nach Verbrauch = %v, want ErrNotFound", err)
}
// Ein unbekannter Token ist von Anfang an ErrNotFound.
if _, err := s.GetValidPasswordResetToken(ctx, "existiert-nicht"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err für unbekannten Token = %v, want ErrNotFound", err)
}
}
func TestSetUserPassword(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
userID := testUserID(t, s, accID)
if err := s.SetUserPassword(ctx, userID, "neuer-hash"); err != nil {
t.Fatalf("SetUserPassword: %v", err)
}
got, err := s.GetUser(ctx, userID)
if err != nil || got.PasswordHash != "neuer-hash" {
t.Fatalf("GetUser nach SetUserPassword = %+v, err=%v", got, err)
}
}

View File

@@ -0,0 +1,106 @@
package store
import (
"context"
"fmt"
"time"
)
// Registereintrag wird automatisch bei jeder Genehmigung erzeugt
// (siehe CLAUDE.md, Entscheidung/Register/Wiedervorlage). Append-only
// — siehe Migration. Bewusst denormalisiert: eigene Textspalten statt
// Joins über bewertung/entscheidung/werkzeug, damit ein einmal
// erzeugter Eintrag nicht von einem späteren Katalog- oder Abteilungs-
// Wandel abhängt.
type Registereintrag struct {
ID string
AccountID string
AntragID string
EntscheidungID string
Zweck string
Abteilung string
Werkzeug string
Datenklasse string
Einstufung string
Auflagen []string
Verantwortlicher string
EntschiedenAm time.Time
GueltigBis *time.Time
CreatedAt time.Time
}
const registereintragColumns = `id, account_id, antrag_id, entscheidung_id, zweck, abteilung, werkzeug,
datenklasse, einstufung, auflagen, verantwortlicher, entschieden_am, gueltig_bis, created_at`
func scanRegistereintrag(row interface {
Scan(dest ...any) error
}) (Registereintrag, error) {
var e Registereintrag
err := row.Scan(
&e.ID, &e.AccountID, &e.AntragID, &e.EntscheidungID, &e.Zweck, &e.Abteilung, &e.Werkzeug,
&e.Datenklasse, &e.Einstufung, &e.Auflagen, &e.Verantwortlicher, &e.EntschiedenAm, &e.GueltigBis, &e.CreatedAt,
)
return e, err
}
// RegistereintragInput bündelt die Felder eines neuen Registereintrags.
type RegistereintragInput struct {
AccountID string
AntragID string
EntscheidungID string
Zweck string
Abteilung string
Werkzeug string
Datenklasse string
Einstufung string
Auflagen []string
Verantwortlicher string
EntschiedenAm time.Time
GueltigBis *time.Time
}
// CreateRegistereintrag speichert einen Registereintrag.
func (s *Store) CreateRegistereintrag(ctx context.Context, in RegistereintragInput) (Registereintrag, error) {
if in.Auflagen == nil {
in.Auflagen = []string{}
}
row := s.db(ctx).QueryRow(ctx, `
INSERT INTO registereintrag (
account_id, antrag_id, entscheidung_id, zweck, abteilung, werkzeug,
datenklasse, einstufung, auflagen, verantwortlicher, entschieden_am, gueltig_bis
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING `+registereintragColumns,
in.AccountID, in.AntragID, in.EntscheidungID, in.Zweck, in.Abteilung, in.Werkzeug,
in.Datenklasse, in.Einstufung, in.Auflagen, in.Verantwortlicher, in.EntschiedenAm, in.GueltigBis,
)
e, err := scanRegistereintrag(row)
if err != nil {
return Registereintrag{}, fmt.Errorf("store: create registereintrag: %w", err)
}
return e, nil
}
// ListRegistereintraegeForAccount liefert alle Registereinträge eines
// Mandanten, neueste zuerst.
func (s *Store) ListRegistereintraegeForAccount(ctx context.Context, accountID string) ([]Registereintrag, error) {
rows, err := s.db(ctx).Query(ctx, `
SELECT `+registereintragColumns+` FROM registereintrag WHERE account_id = $1 ORDER BY entschieden_am DESC
`, accountID)
if err != nil {
return nil, fmt.Errorf("store: list registereintraege for account: %w", err)
}
defer rows.Close()
var out []Registereintrag
for rows.Next() {
e, err := scanRegistereintrag(rows)
if err != nil {
return nil, fmt.Errorf("store: scan registereintrag: %w", err)
}
out = append(out, e)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list registereintraege for account: %w", err)
}
return out, nil
}

View File

@@ -0,0 +1,100 @@
package store_test
import (
"context"
"testing"
"time"
"github.com/netcell-it/deklarix/internal/store"
)
func testRegistereintragInput(accountID, antragID, entscheidungID string) store.RegistereintragInput {
gueltigBis := time.Now().Add(365 * 24 * time.Hour).Truncate(time.Millisecond)
return store.RegistereintragInput{
AccountID: accountID, AntragID: antragID, EntscheidungID: entscheidungID,
Zweck: "Angebotstexte generieren", Abteilung: "Vertrieb", Werkzeug: "ChatGPT Enterprise",
Datenklasse: "personenbezogen", Einstufung: "minimal",
Auflagen: []string{"AVV erforderlich", "kein Training auf Eingaben"},
Verantwortlicher: "verantwortlicher@example.com", EntschiedenAm: time.Now().Truncate(time.Millisecond),
GueltigBis: &gueltigBis,
}
}
func testEntscheidungID(t *testing.T, s *store.Store, antragID, bewertungID, userID string) string {
t.Helper()
e, err := s.CreateEntscheidung(context.Background(), store.EntscheidungInput{
AntragID: antragID, BewertungID: bewertungID, EntscheiderUserID: userID, Entscheidung: "genehmigt",
})
if err != nil {
t.Fatalf("CreateEntscheidung: %v", err)
}
return e.ID
}
func TestRegistereintragCreateAndList(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
userID := testUserID(t, s, accID)
antrag, bewertung := testAntragMitBewertung(t, s, accID, userID)
entscheidungID := testEntscheidungID(t, s, antrag.ID, bewertung.ID, userID)
e, err := s.CreateRegistereintrag(ctx, testRegistereintragInput(accID, antrag.ID, entscheidungID))
if err != nil {
t.Fatalf("CreateRegistereintrag: %v", err)
}
if e.Werkzeug != "ChatGPT Enterprise" || len(e.Auflagen) != 2 {
t.Fatalf("CreateRegistereintrag = %+v, unerwartete Werte", e)
}
list, err := s.ListRegistereintraegeForAccount(ctx, accID)
if err != nil {
t.Fatalf("ListRegistereintraegeForAccount: %v", err)
}
if len(list) != 1 || list[0].ID != e.ID {
t.Fatalf("ListRegistereintraegeForAccount = %+v, want exactly one entry", list)
}
}
func TestRegistereintragIsolatesTenants(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accA := testAccountID(t, s)
userA := testUserID(t, s, accA)
antragA, bewertungA := testAntragMitBewertung(t, s, accA, userA)
entscheidungA := testEntscheidungID(t, s, antragA.ID, bewertungA.ID, userA)
if _, err := s.CreateRegistereintrag(ctx, testRegistereintragInput(accA, antragA.ID, entscheidungA)); err != nil {
t.Fatalf("CreateRegistereintrag (A): %v", err)
}
accB := testAccountID(t, s)
list, err := s.ListRegistereintraegeForAccount(ctx, accB)
if err != nil {
t.Fatalf("ListRegistereintraegeForAccount (B): %v", err)
}
if len(list) != 0 {
t.Fatalf("expected no registereintraege for a different tenant, got %+v", list)
}
}
func TestRegistereintragIsAppendOnly(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
userID := testUserID(t, s, accID)
antrag, bewertung := testAntragMitBewertung(t, s, accID, userID)
entscheidungID := testEntscheidungID(t, s, antrag.ID, bewertung.ID, userID)
e, err := s.CreateRegistereintrag(ctx, testRegistereintragInput(accID, antrag.ID, entscheidungID))
if err != nil {
t.Fatalf("CreateRegistereintrag: %v", err)
}
_, err = s.Pool.Exec(ctx, `UPDATE registereintrag SET werkzeug = 'geaendert' WHERE id = $1`, e.ID)
if err == nil {
t.Fatal("expected UPDATE on registereintrag to be rejected by the append-only trigger")
}
_, err = s.Pool.Exec(ctx, `DELETE FROM registereintrag WHERE id = $1`, e.ID)
if err == nil {
t.Fatal("expected DELETE on registereintrag to be rejected by the append-only trigger")
}
}

View File

@@ -12,38 +12,70 @@ import (
// 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.
// ImpersonatedByUserID ist gesetzt, wenn diese Sitzung durch einen
// Betreiber-Support-Login entstanden ist (siehe CreateImpersonatedSession)
// statt durch den regulären Login des Nutzers selbst.
type Session struct {
Token string
UserID string
ExpiresAt time.Time
CreatedAt time.Time
Token string
UserID string
ImpersonatedByUserID *string
ExpiresAt time.Time
CreatedAt time.Time
}
const sessionColumns = `token, user_id, impersonated_by_user_id, expires_at, created_at`
func scanSession(row interface {
Scan(dest ...any) error
}) (Session, error) {
var sess Session
err := row.Scan(&sess.Token, &sess.UserID, &sess.ImpersonatedByUserID, &sess.ExpiresAt, &sess.CreatedAt)
return sess, err
}
// 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, `
row := s.db(ctx).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)
RETURNING `+sessionColumns,
token, userID, expiresAt,
)
sess, err := scanSession(row)
if err != nil {
return Session{}, fmt.Errorf("store: create session: %w", err)
}
return sess, nil
}
// CreateImpersonatedSession speichert eine Support-Login-Sitzung: ein
// Betreiber meldet sich als ein bestimmter Kunden-Nutzer an, ohne
// dessen Passwort zu kennen (siehe handleBetreiberLoginAls). Anders als
// bei CreateSession bleibt hier festgehalten, WER die Sitzung ausgelöst
// hat — für den sichtbaren Hinweis-Banner und das Audit-Log.
func (s *Store) CreateImpersonatedSession(ctx context.Context, token, userID, impersonatedByUserID string, expiresAt time.Time) (Session, error) {
row := s.db(ctx).QueryRow(ctx, `
INSERT INTO session (token, user_id, impersonated_by_user_id, expires_at)
VALUES ($1, $2, $3, $4)
RETURNING `+sessionColumns,
token, userID, impersonatedByUserID, expiresAt,
)
sess, err := scanSession(row)
if err != nil {
return Session{}, fmt.Errorf("store: create impersonated 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)
row := s.db(ctx).QueryRow(ctx, `SELECT `+sessionColumns+` FROM session WHERE token = $1`, token)
sess, err := scanSession(row)
if errors.Is(err, pgx.ErrNoRows) {
return Session{}, ErrNotFound
}
@@ -55,7 +87,7 @@ func (s *Store) GetSession(ctx context.Context, token string) (Session, error) {
// 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 {
if _, err := s.db(ctx).Exec(ctx, `DELETE FROM session WHERE token = $1`, token); err != nil {
return fmt.Errorf("store: delete session: %w", err)
}
return nil

View File

@@ -19,6 +19,30 @@ func testDatabaseURL(t *testing.T) string {
return url
}
func openTestStore(t *testing.T) *store.Store {
t.Helper()
url := testDatabaseURL(t)
if err := store.Migrate(url); err != nil {
t.Fatalf("Migrate: %v", err)
}
s, err := store.Open(context.Background(), url)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(s.Close)
return s
}
// testAccountID legt einen Mandanten an und liefert dessen ID.
func testAccountID(t *testing.T, s *store.Store) string {
t.Helper()
acc, err := s.CreateAccount(context.Background(), store.AccountInput{Name: "Test-Mandant"})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
return acc.ID
}
func TestMigrateAndOpen(t *testing.T) {
url := testDatabaseURL(t)
@@ -36,56 +60,11 @@ func TestMigrateAndOpen(t *testing.T) {
err = s.Pool.QueryRow(context.Background(), `
SELECT count(*) FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = ANY($1)
`, []string{"submission", "asset", "extraction", "finding", "evidence_package", "participant"}).Scan(&tableCount)
`, []string{"account", "app_user", "session", "audit_log", "abteilung", "werkzeug", "werkzeug_sperre", "antrag"}).Scan(&tableCount)
if err != nil {
t.Fatalf("query tables: %v", err)
}
if tableCount != 6 {
t.Fatalf("expected 6 tables, got %d", tableCount)
}
}
func TestFindingIsAppendOnly(t *testing.T) {
url := testDatabaseURL(t)
if err := store.Migrate(url); err != nil {
t.Fatalf("Migrate: %v", err)
}
s, err := store.Open(context.Background(), url)
if err != nil {
t.Fatalf("Open: %v", err)
}
defer s.Close()
ctx := context.Background()
acc, err := s.CreateAccount(ctx, "Test-Mandant")
if err != nil {
t.Fatalf("create account: %v", err)
}
var submissionID string
err = s.Pool.QueryRow(ctx, `
INSERT INTO submission (account_id, platform, post_type) VALUES ($1, 'instagram', 'reel')
RETURNING id
`, acc.ID).Scan(&submissionID)
if err != nil {
t.Fatalf("insert submission: %v", err)
}
var findingID string
err = s.Pool.QueryRow(ctx, `
INSERT INTO finding (submission_id, rule_id, rule_version, severity, title, fix, sources)
VALUES ($1, 'WK-004', 3, 'hoch', 'Testfeststellung', 'Testkorrektur', '{}')
RETURNING id
`, submissionID).Scan(&findingID)
if err != nil {
t.Fatalf("insert finding: %v", err)
}
_, err = s.Pool.Exec(ctx, `UPDATE finding SET title = 'geändert' WHERE id = $1`, findingID)
if err == nil {
t.Fatal("expected UPDATE on finding to be rejected, but it succeeded")
if tableCount != 8 {
t.Fatalf("expected 8 tables, got %d", tableCount)
}
}

View File

@@ -1,79 +0,0 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Submission ist ein eingereichter Beitrag. AccountID ist der Mandant,
// dem der Beitrag gehört (Mandantentrennung) — jede Abfrage, die einen
// Beitrag ausliefert, muss AccountID gegen den angemeldeten Account
// prüfen (siehe internal/web-Middleware), store selbst erzwingt das
// nicht auf Zeilenebene.
type Submission struct {
ID string
AccountID string
Platform string
PostType string
Caption string
Status string
CreatedAt time.Time
UpdatedAt time.Time
}
// CreateSubmission legt einen neuen Beitrag für einen Mandanten an
// (Status "draft").
func (s *Store) CreateSubmission(ctx context.Context, accountID, platform, postType, caption string) (Submission, error) {
var sub Submission
err := s.Pool.QueryRow(ctx, `
INSERT INTO submission (account_id, platform, post_type, caption)
VALUES ($1, $2, $3, $4)
RETURNING id, account_id, platform, post_type, caption, status, created_at, updated_at
`, accountID, platform, postType, caption).Scan(
&sub.ID, &sub.AccountID, &sub.Platform, &sub.PostType, &sub.Caption, &sub.Status, &sub.CreatedAt, &sub.UpdatedAt,
)
if err != nil {
return Submission{}, fmt.Errorf("store: create submission: %w", err)
}
return sub, nil
}
// GetSubmission liest einen Beitrag anhand seiner ID — ohne
// Mandanten-Prüfung, das ist Sache des Aufrufers (siehe Submission.AccountID).
func (s *Store) GetSubmission(ctx context.Context, id string) (Submission, error) {
var sub Submission
err := s.Pool.QueryRow(ctx, `
SELECT id, account_id, platform, post_type, caption, status, created_at, updated_at
FROM submission WHERE id = $1
`, id).Scan(
&sub.ID, &sub.AccountID, &sub.Platform, &sub.PostType, &sub.Caption, &sub.Status, &sub.CreatedAt, &sub.UpdatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return Submission{}, ErrNotFound
}
if err != nil {
return Submission{}, fmt.Errorf("store: get submission: %w", err)
}
return sub, nil
}
// SetSubmissionStatus setzt den Status eines Beitrags (submission ist,
// anders als extraction/finding/evidence_package, NICHT append-only —
// der Lebenszyklus draft → checked → published → archived ist eine
// normale Zustandsänderung, kein Beweis-Eintrag).
func (s *Store) SetSubmissionStatus(ctx context.Context, id, status string) error {
tag, err := s.Pool.Exec(ctx, `
UPDATE submission SET status = $2, updated_at = now() WHERE id = $1
`, id, status)
if err != nil {
return fmt.Errorf("store: set submission status: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("store: set submission status: submission %s nicht gefunden", id)
}
return nil
}

View File

@@ -0,0 +1,104 @@
// Row-Level-Security-Unterstützung. Isolation zwischen Mandanten lief
// bisher ausschließlich anwendungsseitig (Handler vergleichen AccountID,
// siehe CLAUDE.md) — das ist die einzige Stelle, die einen vergessenen
// WHERE-account_id-Filter in einer neuen Store-Methode nicht auffängt.
// WithTenantScope öffnet für die Dauer eines Requests eine Transaktion
// und setzt zwei Postgres-Sitzungsvariablen (SET LOCAL, per set_config
// mit Parameterbindung statt String-Interpolation — SQL-Injection-frei
// und automatisch auf die Transaktion begrenzt, kein manuelles Zurück-
// setzen nötig):
//
// - app.account_id — der Mandant, für den dieser Request angemeldet ist
// - app.is_betreiber — "true" für Ebene-5-Zugriff (sieht alle Mandanten)
//
// Migration 0021 aktiviert FORCE ROW LEVEL SECURITY auf den Tabellen mit
// echten Mandanten-Geschäftsdaten und legt Policies an, die genau diese
// beiden Variablen auswerten. Bewusst NICHT auf account/app_user/
// session/password_reset_token (siehe Migration 0021 für die Begründung
// — diese vier brauchen unmandantierte Lookups, z. B. Login per E-Mail).
package store
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
// pgxIface wird sowohl von *pgxpool.Pool als auch von pgx.Tx erfüllt —
// db(ctx) kann so transparent zwischen "kein Tenant-Kontext" (Pool,
// z. B. in Store-Tests ohne WithTenantScope) und "innerhalb eines
// Requests" (Tx mit gesetzten Sitzungsvariablen) wählen, ohne dass jede
// einzelne Store-Methode das selbst unterscheiden müsste.
type pgxIface interface {
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
}
type tenantTxKey struct{}
// db liefert die für ctx passende Ausführungsschnittstelle: die aktive
// Transaktion, falls WithTenantScope sie gesetzt hat, sonst den Pool
// direkt (z. B. für Store-Tests, die ohne Tenant-Kontext laufen — diese
// Tabellen sind dann nicht durch RLS geschützt, was für White-Box-Tests
// der Store-Logik selbst unkritisch ist, siehe dedizierte RLS-Tests in
// tenant_scope_test.go für den tatsächlichen Isolationsnachweis).
func (s *Store) db(ctx context.Context) pgxIface {
if tx, ok := ctx.Value(tenantTxKey{}).(pgx.Tx); ok {
return tx
}
return s.Pool
}
// WithTenantScope führt fn in einer Transaktion aus, die die Sitzungs-
// variablen für RLS setzt. accountID kann leer sein (z. B. während der
// Registrierung, bevor der neue Account existiert) — SetTenantScope
// erlaubt, die Variable mitten in derselben Transaktion nachträglich zu
// setzen, sobald die ID bekannt ist.
func (s *Store) WithTenantScope(ctx context.Context, accountID string, isBetreiber bool, fn func(ctx context.Context) error) error {
tx, err := s.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("store: begin tenant scope: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck // no-op nach erfolgreichem Commit
scopedCtx := context.WithValue(ctx, tenantTxKey{}, tx)
if err := setTenantSessionVars(scopedCtx, tx, accountID, isBetreiber); err != nil {
return err
}
if err := fn(scopedCtx); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("store: commit tenant scope: %w", err)
}
return nil
}
// SetTenantScope aktualisiert die Sitzungsvariablen innerhalb einer
// bereits laufenden WithTenantScope-Transaktion — nötig, wenn eine neue
// Firma erst mitten im Request entsteht (die account_id ist vorher
// nicht bekannt, siehe handleRegister/handleBetreiberAccountCreate).
// Ruft man es außerhalb von WithTenantScope auf, ist es ein No-op ohne
// Effekt (kein Tx im Context) — daher immer den Rückgabewert prüfen,
// falls das je außerhalb eines Handlers genutzt wird.
func (s *Store) SetTenantScope(ctx context.Context, accountID string, isBetreiber bool) error {
tx, ok := ctx.Value(tenantTxKey{}).(pgx.Tx)
if !ok {
return fmt.Errorf("store: SetTenantScope außerhalb von WithTenantScope aufgerufen")
}
return setTenantSessionVars(ctx, tx, accountID, isBetreiber)
}
func setTenantSessionVars(ctx context.Context, tx pgx.Tx, accountID string, isBetreiber bool) error {
betreiberFlag := "false"
if isBetreiber {
betreiberFlag = "true"
}
if _, err := tx.Exec(ctx, `SELECT set_config('app.account_id', $1, true), set_config('app.is_betreiber', $2, true)`, accountID, betreiberFlag); err != nil {
return fmt.Errorf("store: set tenant scope: %w", err)
}
return nil
}

View File

@@ -0,0 +1,152 @@
// Beweis, dass Row-Level-Security tatsächlich greift — nicht nur, dass
// die Store-Methoden mit einer Transaktion statt dem Pool funktionieren
// (das würde auch mit einer Superuser-Verbindung "bestehen", ohne dass
// RLS irgendetwas tut, siehe CLAUDE.md/Migration 0021: Superuser
// umgehen RLS-Policies immer). Diese Tests laufen deshalb NICHT gegen
// DATABASE_URL (Superuser, für Migrationen), sondern gegen
// DATABASE_URL_APP — die eingeschränkte Rolle "deklarix_app", für die
// die Policies tatsächlich wirken. Ohne DATABASE_URL_APP werden sie
// übersprungen (die Rolle existiert erst nach manuellem Passwort-Setup,
// siehe Migration 0021 und CLAUDE.md).
package store_test
import (
"context"
"errors"
"os"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
// openRestrictedTestStore öffnet eine zweite Verbindung über
// DATABASE_URL_APP (die eingeschränkte Rolle) — Migrationen und
// Fixture-Aufbau laufen weiterhin über die normale, privilegierte
// openTestStore-Verbindung.
func openRestrictedTestStore(t *testing.T) *store.Store {
t.Helper()
url := os.Getenv("DATABASE_URL_APP")
if url == "" {
t.Skip("DATABASE_URL_APP nicht gesetzt, überspringe RLS-Test (siehe Migration 0021)")
}
s, err := store.Open(context.Background(), url)
if err != nil {
t.Fatalf("Open (restricted): %v", err)
}
t.Cleanup(s.Close)
return s
}
func TestRLSAbteilungIsoliertZwischenMandanten(t *testing.T) {
privileged := openTestStore(t)
restricted := openRestrictedTestStore(t)
ctx := context.Background()
accA := testAccountID(t, privileged)
accB := testAccountID(t, privileged)
abtA, err := privileged.CreateAbteilung(ctx, accA, "Abteilung-A")
if err != nil {
t.Fatalf("CreateAbteilung A: %v", err)
}
if _, err := privileged.CreateAbteilung(ctx, accB, "Abteilung-B"); err != nil {
t.Fatalf("CreateAbteilung B: %v", err)
}
// Mit Tenant-Kontext A: nur die eigene Abteilung ist sichtbar, auch
// wenn wir explizit nach IDs von B fragen würden — hier geprüft über
// die Listen-Methode, die auf accA gefiltert nach RLS zusätzlich
// noch mal (redundant) accountID=accA übergibt; der Beweis liegt in
// TestRLSVerweigertFremdenAccountAuchBeiFalscherAccountID unten, wo
// die Anwendungsschicht bewusst "falsch" fragt.
err = restricted.WithTenantScope(ctx, accA, false, func(scoped context.Context) error {
liste, err := restricted.ListAbteilungenForAccount(scoped, accA)
if err != nil {
return err
}
if len(liste) != 1 || liste[0].Name != "Abteilung-A" {
t.Fatalf("liste = %+v, want genau [Abteilung-A]", liste)
}
return nil
})
if err != nil {
t.Fatalf("WithTenantScope A: %v", err)
}
// Der eigentliche RLS-Beweis: im Tenant-Kontext A nach ListAbteilungenForAccount(B)
// fragen — ein Programmierfehler, der die AccountID nicht prüft, wäre
// ohne RLS ein echtes Datenleck. Mit RLS liefert die DB trotzdem 0 Zeilen,
// weil die Sitzungsvariable (Kontext A) nicht zu den B-Zeilen passt.
err = restricted.WithTenantScope(ctx, accA, false, func(scoped context.Context) error {
liste, err := restricted.ListAbteilungenForAccount(scoped, accB)
if err != nil {
return err
}
if len(liste) != 0 {
t.Fatalf("RLS-LECK: Kontext A sieht %d Zeilen von Account B, want 0", len(liste))
}
return nil
})
if err != nil {
t.Fatalf("WithTenantScope A->B: %v", err)
}
// GetAbteilung(abtA.ID) im Kontext B muss ErrNotFound liefern — RLS
// versteckt die fremde Zeile, unabhängig davon, ob die Anwendung die
// AccountID selbst vergleicht.
err = restricted.WithTenantScope(ctx, accB, false, func(scoped context.Context) error {
_, err := restricted.GetAbteilung(scoped, abtA.ID)
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("RLS-LECK: GetAbteilung(A) im Kontext B err=%v, want ErrNotFound", err)
}
return nil
})
if err != nil {
t.Fatalf("WithTenantScope B: %v", err)
}
// Betreiber-Kontext sieht beide.
err = restricted.WithTenantScope(ctx, "", true, func(scoped context.Context) error {
listeA, err := restricted.ListAbteilungenForAccount(scoped, accA)
if err != nil {
return err
}
listeB, err := restricted.ListAbteilungenForAccount(scoped, accB)
if err != nil {
return err
}
if len(listeA) != 1 || len(listeB) != 1 {
t.Fatalf("Betreiber-Kontext: listeA=%+v listeB=%+v, want je 1", listeA, listeB)
}
return nil
})
if err != nil {
t.Fatalf("WithTenantScope Betreiber: %v", err)
}
// Ohne jeden Tenant-Kontext (direkter Pool-Zugriff, keine Transaktion,
// keine Sitzungsvariable gesetzt): fail closed, 0 Zeilen — nicht "alle".
liste, err := restricted.ListAbteilungenForAccount(ctx, accA)
if err != nil {
t.Fatalf("ListAbteilungenForAccount ohne Kontext: %v", err)
}
if len(liste) != 0 {
t.Fatalf("RLS-LECK: ohne Tenant-Kontext sichtbar: %+v, want 0 Zeilen (fail closed)", liste)
}
}
func TestRLSVerhindertInsertFuerFremdenAccount(t *testing.T) {
privileged := openTestStore(t)
restricted := openRestrictedTestStore(t)
ctx := context.Background()
accA := testAccountID(t, privileged)
accB := testAccountID(t, privileged)
err := restricted.WithTenantScope(ctx, accA, false, func(scoped context.Context) error {
_, err := restricted.CreateAbteilung(scoped, accB, "Boesartig-Eingeschleust")
return err
})
if err == nil {
t.Fatal("RLS-LECK: INSERT für fremde account_id im Kontext A wurde nicht abgelehnt")
}
}

View File

@@ -15,28 +15,41 @@ import (
// Login: falsche E-Mail vs. Datenbankfehler).
var ErrNotFound = errors.New("store: nicht gefunden")
// User ist ein Login innerhalb eines Account (Mandanten).
// User ist ein Login innerhalb eines Account (Mandanten). Nutzer werden
// nicht gelöscht (app_user wird von antrag/session/entscheidung/
// audit_log per Foreign Key referenziert — ein Hard-Delete würde die
// Historie zerstören), sondern über Active deaktiviert.
type User struct {
ID string
AccountID string
Email string
PasswordHash string
Role string
Active bool
CreatedAt time.Time
}
const userColumns = `id, account_id, email, password_hash, role, active, created_at`
func scanUser(row interface {
Scan(dest ...any) error
}) (User, error) {
var u User
err := row.Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.Active, &u.CreatedAt)
return u, err
}
// CreateUser legt einen neuen Nutzer innerhalb eines Accounts an.
// passwordHash muss bereits gehasht sein (siehe internal/auth) — store
// speichert nur, es hasht nicht selbst.
func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (User, error) {
var u User
err := s.Pool.QueryRow(ctx, `
row := s.db(ctx).QueryRow(ctx, `
INSERT INTO app_user (account_id, email, password_hash, role)
VALUES ($1, $2, $3, $4)
RETURNING id, account_id, email, password_hash, role, created_at
`, accountID, email, passwordHash, role).Scan(
&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt,
RETURNING `+userColumns,
accountID, email, passwordHash, role,
)
u, err := scanUser(row)
if err != nil {
return User{}, fmt.Errorf("store: create user: %w", err)
}
@@ -46,11 +59,8 @@ func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash,
// GetUserByEmail liest einen Nutzer anhand seiner E-Mail-Adresse.
// Liefert ErrNotFound, wenn keine E-Mail passt (kein Datenbankfehler).
func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error) {
var u User
err := s.Pool.QueryRow(ctx, `
SELECT id, account_id, email, password_hash, role, created_at
FROM app_user WHERE email = $1
`, email).Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt)
row := s.db(ctx).QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE email = $1`, email)
u, err := scanUser(row)
if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrNotFound
}
@@ -62,11 +72,8 @@ func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error)
// GetUser liest einen Nutzer anhand seiner ID.
func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
var u User
err := s.Pool.QueryRow(ctx, `
SELECT id, account_id, email, password_hash, role, created_at
FROM app_user WHERE id = $1
`, id).Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt)
row := s.db(ctx).QueryRow(ctx, `SELECT `+userColumns+` FROM app_user WHERE id = $1`, id)
u, err := scanUser(row)
if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrNotFound
}
@@ -75,3 +82,57 @@ func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
}
return u, nil
}
// ListUsersForAccount liefert alle Logins eines Mandanten — für den
// Admin-Bereich (Account-Detailansicht).
func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]User, error) {
rows, err := s.db(ctx).Query(ctx, `
SELECT `+userColumns+` FROM app_user WHERE account_id = $1 ORDER BY created_at
`, accountID)
if err != nil {
return nil, fmt.Errorf("store: list users for account: %w", err)
}
defer rows.Close()
var out []User
for rows.Next() {
u, err := scanUser(rows)
if err != nil {
return nil, fmt.Errorf("store: scan user: %w", err)
}
out = append(out, u)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list users for account: %w", err)
}
return out, nil
}
// SetUserPassword ersetzt den Passwort-Hash eines Nutzers (Passwort-
// Zurücksetzen). passwordHash muss bereits gehasht sein, wie bei
// CreateUser.
func (s *Store) SetUserPassword(ctx context.Context, id, passwordHash string) error {
tag, err := s.db(ctx).Exec(ctx, `UPDATE app_user SET password_hash = $2 WHERE id = $1`, id, passwordHash)
if err != nil {
return fmt.Errorf("store: set user password: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// SetUserActive (de-)aktiviert einen Login. Ein deaktivierter Nutzer
// kann sich nicht mehr anmelden (siehe web.handleLogin), bleibt aber
// als Akteur in bestehenden Anträgen/Entscheidungen/Audit-Log-Einträgen
// nachvollziehbar — deshalb (de-)aktivieren statt löschen.
func (s *Store) SetUserActive(ctx context.Context, id string, active bool) error {
tag, err := s.db(ctx).Exec(ctx, `UPDATE app_user SET active = $2 WHERE id = $1`, id, active)
if err != nil {
return fmt.Errorf("store: set user active: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}

322
internal/store/werkzeug.go Normal file
View File

@@ -0,0 +1,322 @@
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
// Verarbeitungslaender nennt die tatsächlichen Länder (oder, wenn
// der Anbieter selbst keinen einzelnen Staat zusichert, eine Region
// wie "EU-Region") statt eines groben EU/USA/gemischt-Eimers — die
// USA sind DSGVO-rechtlich bereits ein Drittland wie jedes andere,
// "gemischt" verschleierte, welche Länder konkret beteiligt sind.
Verarbeitungslaender []string
AVVVerfuegbar bool
AVVURL string
TrainingOptOut bool
TrainingStandard bool
// DPFZertifiziert: EU-US Data Privacy Framework — schmalere,
// gerichtlich schon zweimal gekippte Rechtsgrundlage (Safe Harbor,
// Privacy Shield) für Transfers in die USA, gilt nur für zertifizierte
// Unternehmen. false bedeutet nicht "nicht zertifiziert" im Sinne von
// widerlegt, sondern "nicht als Zusicherung belegt" — wie bei den
// übrigen Booleans hier.
DPFZertifiziert bool
AufbewahrungTage *int // nil = vom Anbieter nicht beziffert, NICHT gleichbedeutend mit 0 Tagen
Zertifizierungen []string
Subprozessoren []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 15 Feldern lesbarer als eine positionale
// Parameterliste.
type WerkzeugInput struct {
AccountID *string
Name string
Anbieter string
Verarbeitungslaender []string
AVVVerfuegbar bool
AVVURL string
TrainingOptOut bool
TrainingStandard bool
DPFZertifiziert bool
AufbewahrungTage *int
Zertifizierungen []string
Subprozessoren []string
GeeigneteZwecke []string
Einschraenkungen []string
LetztePruefung time.Time
Quelle string
}
const werkzeugColumns = `id, account_id, name, anbieter, verarbeitungslaender, avv_verfuegbar, avv_url,
training_opt_out, training_standard, dpf_zertifiziert, aufbewahrung_tage, zertifizierungen, subprozessoren,
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.Verarbeitungslaender, &w.AVVVerfuegbar, &w.AVVURL,
&w.TrainingOptOut, &w.TrainingStandard, &w.DPFZertifiziert, &w.AufbewahrungTage, &w.Zertifizierungen, &w.Subprozessoren,
&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.Verarbeitungslaender == nil {
in.Verarbeitungslaender = []string{}
}
if in.Zertifizierungen == nil {
in.Zertifizierungen = []string{}
}
if in.GeeigneteZwecke == nil {
in.GeeigneteZwecke = []string{}
}
if in.Einschraenkungen == nil {
in.Einschraenkungen = []string{}
}
if in.Subprozessoren == nil {
in.Subprozessoren = []string{}
}
}
// CreateWerkzeug legt einen Katalogeintrag an.
func (s *Store) CreateWerkzeug(ctx context.Context, in WerkzeugInput) (Werkzeug, error) {
normalizeWerkzeugSlices(&in)
row := s.db(ctx).QueryRow(ctx, `
INSERT INTO werkzeug (
account_id, name, anbieter, verarbeitungslaender, avv_verfuegbar, avv_url,
training_opt_out, training_standard, dpf_zertifiziert, aufbewahrung_tage, zertifizierungen, subprozessoren,
geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING `+werkzeugColumns,
in.AccountID, in.Name, in.Anbieter, in.Verarbeitungslaender, in.AVVVerfuegbar, in.AVVURL,
in.TrainingOptOut, in.TrainingStandard, in.DPFZertifiziert, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren,
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.db(ctx).QueryRow(ctx, `
UPDATE werkzeug SET
name = $2, anbieter = $3, verarbeitungslaender = $4, avv_verfuegbar = $5, avv_url = $6,
training_opt_out = $7, training_standard = $8, dpf_zertifiziert = $9, aufbewahrung_tage = $10,
zertifizierungen = $11, subprozessoren = $12, geeignete_zwecke = $13, einschraenkungen = $14,
letzte_pruefung = $15, quelle = $16, updated_at = now()
WHERE id = $1
RETURNING `+werkzeugColumns,
id, in.Name, in.Anbieter, in.Verarbeitungslaender, in.AVVVerfuegbar, in.AVVURL,
in.TrainingOptOut, in.TrainingStandard, in.DPFZertifiziert, in.AufbewahrungTage, in.Zertifizierungen, in.Subprozessoren,
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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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.db(ctx).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,240 @@
package store_test
import (
"context"
"errors"
"testing"
"time"
"github.com/netcell-it/deklarix/internal/store"
)
func intPtr(v int) *int { return &v }
func testWerkzeugInput(accountID *string, name string) store.WerkzeugInput {
return store.WerkzeugInput{
AccountID: accountID,
Name: name,
Anbieter: "Beispiel-Anbieter GmbH",
Verarbeitungslaender: []string{"Deutschland"},
AVVVerfuegbar: true,
AVVURL: "https://beispiel.example/avv",
TrainingOptOut: true,
TrainingStandard: true,
AufbewahrungTage: intPtr(30),
Zertifizierungen: []string{"ISO 27001"},
Subprozessoren: []string{"Beispiel-Subprozessor Inc."},
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)
}
if len(w.Subprozessoren) != 1 || w.Subprozessoren[0] != "Beispiel-Subprozessor Inc." {
t.Fatalf("Subprozessoren = %v, want [Beispiel-Subprozessor Inc.]", w.Subprozessoren)
}
if w.AufbewahrungTage == nil || *w.AufbewahrungTage != 30 {
t.Fatalf("AufbewahrungTage = %v, want 30", w.AufbewahrungTage)
}
unbekannt, err := s.CreateWerkzeug(ctx, func() store.WerkzeugInput {
in := testWerkzeugInput(nil, "Werkzeug ohne Aufbewahrungsangabe")
in.AufbewahrungTage = nil
return in
}())
if err != nil {
t.Fatalf("CreateWerkzeug (ohne Aufbewahrungsangabe): %v", err)
}
if unbekannt.AufbewahrungTage != nil {
t.Fatalf("AufbewahrungTage = %v, want nil (unbekannt)", *unbekannt.AufbewahrungTage)
}
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 = intPtr(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 == nil || *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")
}
}
}

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