diff --git a/CLAUDE.md b/CLAUDE.md index b0f28a7..2019db5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,102 +1,242 @@ # Deklarix -> Prüfung und Nachweis für Werbe-Content. Pre-Publish-Kennzeichnungsprüfung -> und revisionssichere Archivierung für Creator, Agenturen, Marken und -> Kanzleien. `deklarix.de` / `deklarix.com` +> KI-Antragsprüfung für Unternehmen: Mitarbeitende beschreiben ein +> KI-Vorhaben in eigenen Worten, das System leitet Datenklasse, +> KI-VO-Einstufung und Anforderungsprofil ab, gleicht sie gegen einen +> gepflegten Werkzeugkatalog ab und erzeugt einen begründeten +> Entscheidungsvorschlag. Ein Mensch entscheidet, das System bereitet +> nur vor. --- -## Was das Projekt ist +## Produktwechsel (2026-08-28) -Drei Funktionen, mehr nicht: - -1. **Pre-Publish-Prüfung** — Caption, Standbild und Vertragslage rein, - Risikobewertung mit konkreten Korrekturvorschlägen raus. -2. **Beweissicherung** — jeder veröffentlichte Beitrag wird mit Hash und - Zeitstempel archiviert: Screenshot, Caption, sichtbare Kennzeichnung, - verknüpfter Vertrag, Briefing. -3. **Verantwortungsmatrix** — wer hat was vorgegeben, wer hat freigegeben, - wer schuldet welche Prüfung (Creator / Agentur / Marke). - -**Wer nutzt es, wer zahlt:** Creator nutzen es täglich, zahlen wenig -(Reichweite/Referenz). Agenturen und Marken zahlen richtig — sie haften -über § 8 Abs. 2 UWG mit und brauchen Freigabe-Workflow plus -Nachweisarchiv. Kanzleien lizenzieren Deklarix als Whitelabel für ihre -Mandanten (zahlen für Software, nicht für Mandantenzugang); das -Kanzlei-Verzeichnis im Produkt bleibt kostenlos. - -**Rollen von Anfang an.** Eine Submission gehört nicht einem Nutzer, -sondern hat Beteiligte mit Rollen (`creator`, `agentur`, `marke`, -`kanzlei`). Alle vier existieren im Datenmodell ab dem ersten Commit. - -**Kein Demo-Shortcut.** Es wird direkt produktionsreif entwickelt — -keine reduzierte Zwischenstufe mit manuell eingetragenen Daten oder ohne -Login als Selbstzweck. +Deklarix war ursprünglich eine Pre-Publish-Kennzeichnungsprüfung für +Werbe-Content (UWG/MStV) für Creator, Agenturen, Marken und Kanzleien. +Dieser Scope wurde **komplett verworfen** und durch das oben +beschriebene Produkt ersetzt — kein Feature-Zusatz, ein Produktwechsel. +Wiederverwendet wurden nur produktunabhängige Bausteine: Auth/Session/ +Account-Mandantentrennung, das Prinzip "Regelwerk als versionierte +YAML-Dateien, kein ML für die Bewertung", die apt/systemd/Gitea- +Deployment-Pipeline und das Design-System. Alles Werberecht-Spezifische +(Kennzeichnungsregeln, Beweisarchiv für Social-Media-Posts, Instagram/ +TikTok-OAuth, Insights-Erinnerung) wurde entfernt, nicht nur ersetzt — +siehe Migration `0008_pivot_ki_antragspruefung` für den genauen Schnitt. --- -## Kernprinzip: Fakten trennen von Urteil +## Grundregel -**Stufe 1 bestimmt Fakten (deterministisch, kein LLM). Das Regelwerk in -Go fällt das Urteil.** +**Das System entscheidet nicht. Es bereitet vor.** Der/die +Verantwortliche bestätigt, ändert oder überschreibt einen +Bewertungsvorschlag — mit Begründungsfeld bei Abweichung. Jede +Entscheidung wird als Registereintrag protokolliert. Die Bewertung wird +**nie** ohne ihre vollständige Herleitung angezeigt (welche Antwort +welche Anforderung ausgelöst hat) — ohne das ist ein Vorschlag im Audit +wertlos. -- **Stufe 1 — Extraktion (`internal/extract`, regelbasiert):** Ob eine - Gegenleistung vorlag, kann aus dem Text nicht zuverlässig erraten - werden — ein verschwiegener bezahlter Post sieht textlich identisch - aus wie ein echter organischer Post. Deshalb gibt der/die Einreichende - die Gegenleistung selbst an (Formularfeld). Was sich objektiv aus der - Caption bestimmen lässt, wird per Zeichenketten-Logik erkannt: ob ein - Kennzeichnungswort vorkommt, welcher Wortlaut, und ob es vor der - plattformspezifischen "mehr anzeigen"-Kürzung steht. Kein externer - Dienst, keine API-Kosten, kein Sprachmodell — daher auch keine - Interpretation kreativer/impliziter Formulierungen; was nicht über ein - bekanntes Kennzeichnungswort läuft, wird nicht erkannt. -- **Stufe 2 — Bewertung (Go, deterministisch):** Regelwerk als - versionierte YAML-Dateien (`rules/`). Jedes Finding referenziert - Regel-ID und Version (z. B. `WK-004 v3`). Regeln sind - plattformunabhängig — UWG/MStV gelten überall gleich, nur - Darstellungsdetails unterscheiden sich; die gehören in - Regelbedingungen/Extraktion, nie in separate Regelsätze pro Plattform. - -**Absolute Regel:** Fundstellen stehen ausschließlich in den kuratierten -YAML-Regeln, nie in Freitext oder Extraktionslogik erfunden. Eine -halluzinierte Fundstelle ist in diesem Markt ein Totalschaden. - -**Historie:** Ursprünglich war Stufe 1 als Claude-API-Aufruf geplant -(Kennzeichnung *und* Gegenleistung vom Modell erraten). Das wurde -bewusst verworfen — der Nutzer will keine laufende Anthropic-Abhängigkeit -für Deklarix selbst (getrennt von Claude Code, mit dem an Deklarix -programmiert wird). Die Idee "LLM sieht Kontext-Hinweise wie -Rabattcodes/Affiliate-Links, die reines Keyword-Matching übersieht" -bleibt ein späterer Ausbaupunkt, kein aktueller Blocker. +Regelbasiert, nicht ML: die Ableitungsregeln (Datenklasse, KI-VO- +Einstufung, Anforderungsprofil) liegen als versionierte YAML-Dateien +vor, nicht im Code — ein fachlich Verantwortlicher soll sie anpassen +können, ohne Go-Code anzufassen. Ein LLM ist v1 höchstens optional zur +Zweckerkennung aus dem Freitext vorgesehen (Textgenerierung/Übersetzung/ +Code/Bild/Transkription/Recherche erkennen), nie für die Einstufung +selbst — und auch dort nur als Vorschlag, den der Mensch bestätigt. --- -## Stack +## Fünf Ebenen, fünf Rollen -Bewusst langweilig, ein Binary, lokal lauffähig: +Die Oberfläche gliedert sich in fünf Ebenen mit steigender Sensibilität. +Vier Mandanten-Rollen sind eine Voreinstellung, kein fest verdrahtetes +Modell (spätere Ausbaustufe: konfigurierbare Rollen) — `betreiber` ist +technisch getrennt vom Mandantenbereich, keine Voreinstellung. -| Bereich | Wahl | -|---|---| -| Sprache | Go 1.26, Standard-Library wo möglich | -| Web | `net/http` + `html/template` + htmx, kein Frontend-Build, kein React | -| Datenbank | PostgreSQL über `pgx`, Migrationen über `golang-migrate` | -| PDF | serverseitig erzeugt | -| Zeitstempel | RFC-3161 von einer öffentlichen TSA | -| Extraktion | regelbasiert, `internal/extract`, keine externe Abhängigkeit | -| Design | `design/enterprise.css` (Enterprise Light Theme, enconf-Pattern, Primärfarbe `#1677ff`) | +| Ebene | Zugang | Rolle(n) | Zweck | +|---|---|---|---| +| 1 — Öffentlich | ohne Konto | — | Anmeldung, Passwort zurücksetzen, Einladung annehmen, Antrag über Mandantenlink | +| 2 — Mitarbeiter | Konto nötig | `mitarbeiter` | Antrag stellen, eigene Anträge verfolgen, Rückfragen beantworten, freigegebene Werkzeuge einsehen. Bewusst schlank — dauert der Antrag zu lange, wird er umgangen | +| 3 — Fachebene | Konto nötig | `verantwortlicher` (Entscheidungsrecht), `pruefer` (identische Sicht, kein Entscheidungsrecht) | Posteingang offener Anträge, Bewertungsvorschlag mit Herleitung, entscheiden, Register, Berichte/Exporte, Audit-Log lesend | +| 4 — Mandanten-Verwaltung | Konto nötig | `admin` | Nutzer/Rollen, Abteilungen, Anmeldeverfahren, eigene Werkzeug-Freigaben/-Sperrungen, Rechnungsdaten — bezogen auf GENAU EINEN Mandanten | +| 5 — Plattform | Konto nötig, technisch getrennt | `betreiber` | Werkzeugkatalog, Regelwerk-Versionierung, Mandantenverwaltung/Freischaltung, Nutzungssicht — Netcell-IT-Personal, plattformweit über alle Mandanten | -Kein Docker für die App selbst, kein React, keine Queue. Neue -Abhängigkeiten nur nach Rückfrage. +**Funktionstrennung bleibt bestehen**, auch wenn in kleinen Firmen +dieselbe Person Admin und Verantwortlicher ist — das sind zwei Rollen +auf demselben Menschen, keine verschmolzene Rolle. `admin` (Ebene 4, +Mandanten-Verwaltung für den eigenen Account) und `betreiber` (Ebene 5, +Plattform-Betrieb für Netcell-IT über alle Mandanten) sind nicht +dasselbe, auch wenn beide "Admin"-artige Rechte haben — nur `betreiber` +ist der Nachfolger dessen, was früher (vor dem Produktwechsel) +`admin` hieß. -**Warum Postgres statt SQLite:** Das Produkt läuft von Anfang an für -hunderte gleichzeitige Nutzer über mehrere Mandanten (Creator, Agentur, -Marke, Kanzlei). Ein revisionssicheres Beweisarchiv braucht robuste -Concurrent-Writes sowie Point-in-Time-Recovery/Replikation — das ist mit -SQLites Single-Writer-Modell nicht sauber zu bauen. Gehostet wird -Managed Postgres in der EU (DSGVO). +Rechte werden **als Prüfung an jeder Aktion** durchgesetzt (Middleware +je Handler), nicht als grob unterschiedene Seitenbereiche. -**Pfad:** `/var/www/deklarix` | **Git:** `https://git.netcell-it.de/projekte/deklarix` | **Branch:** `main` +**Mandantenfähigkeit:** jede Tabelle trägt `account_id`. Aktuell wird +Isolation in der Anwendungsschicht erzwungen (Handler vergleichen +`AccountID` gegen den angemeldeten Account, wie bisher) — die +Zielvorgabe "Isolation auf Datenbankebene erzwungen" (Postgres Row- +Level Security) ist eine bewusst noch nicht getroffene Entscheidung, +siehe Offene Punkte unten. + +--- + +## Fragebogen (Ebene 2, "Antrag stellen") + +Adaptiv: Folgefragen erscheinen nur, wenn die Antwort sie auslöst. +Alltagssprache, keine Fachbegriffe — die stehen nur in der Ableitung. + +**A — Vorhaben:** `titel`, `beschreibung` (Freitext: was soll die KI +tun?), `ergebnis` (Freitext: was soll herauskommen?), `haeufigkeit` +(einmalig/gelegentlich/täglich/automatisiert), `abteilung` (aus +Stammdaten). + +**B — Daten** (bestimmt die Datenklasse). Mehrfachauswahl Ja/Nein/ +Unsicher, "Unsicher" zählt wie "Ja": Namen/E-Mail/Personenangaben (B1), +Gesundheit/Religion/Herkunft/Gewerkschaft/Sexualleben (B2), Patienten-/ +Mandanten-/Versichertendaten (B3), Bewerbungs-/Personal-/ +Leistungsdaten Beschäftigter (B4), von Kunden anvertraute Daten (B5), +Konstruktions-/Kalkulations-/Vertragsunterlagen (B6), nur allgemein +zugängliche/erfundene Inhalte (B7). + +**C — Verwendung des Ergebnisses** (bestimmt die KI-VO-Einstufung): +geht das Ergebnis unverändert nach außen (C1)? beeinflusst es eine +Entscheidung über einen Menschen (C2, Folgefrage: Einstellung/ +Beförderung/Kündigung, Kreditwürdigkeit, Leistungsbewilligung, Zugang +zu Bildung, medizinische Beurteilung, keins davon)? erkennt/bewertet es +Emotionen, Verhalten oder biometrische Merkmale (C3)? läuft es ohne +menschliche Prüfung (C4)? merkt der Empfänger, dass es von einer KI +stammt (C5)? + +**D — Werkzeug:** ggf. schon ein Werkzeug im Sinn (optional, Freitext), +Firmen- oder privater Zugang, Firmen- oder Privatgerät. + +--- + +## Ableitungen (`rules/*.yaml`, `internal/rules`) + +**Datenklasse** (`rules/datenklasse.yaml`) — höchste zutreffende Stufe +gewinnt: `oeffentlich` (B7) < `intern` (B6) < `auftragsdaten` (B5) < +`personenbezogen` (B1, B4) < `berufsgeheimnis` (B3, § 203 StGB) < +`besondere_kategorie` (B2, Art. 9 DSGVO). **Die Rangfolge ist eine +Annahme** (Spezifikation nennt keine expliziten Ränge) — siehe +`rules/OPEN.md`, vor Produktiveinsatz mit dem/der Datenschutz- +beauftragten bestätigen. + +**KI-VO-Einstufung** (`rules/kivo_einstufung.yaml`) — Prüfreihenfolge, +erste zutreffende Stufe gewinnt: `verboten` (Art. 5 KI-VO: C3 mit +Emotionserkennung am Arbeitsplatz, Social Scoring oder biometrischer +Kategorisierung) → `hochrisiko` (C2 mit Beschäftigung/Kreditwürdigkeit/ +Leistungsbewilligung/Bildung/Gesundheit) → `transparenzpflicht` (C1 ja +und C5 nein) → `minimal` (alles übrige). Die genauen Fragebogen-Felder +für die drei "verboten"-Varianten sind noch nicht abschließend +spezifiziert — siehe `rules/OPEN.md`. + +**Anforderungsprofil** (`rules/anforderungen.yaml`) — aus Datenklasse +und/oder Einstufung abgeleitete Menge von Anforderungen an ein +Werkzeug: `avv_erforderlich`, `eu_verarbeitung`, +`kein_training_auf_eingabe`, `loeschfrist_max_tage`, +`menschliche_aufsicht`, `kennzeichnungspflicht`, `dsfa_erforderlich`. + +`internal/rules` lädt und validiert diese drei Dateien (eindeutige IDs, +eindeutige Ränge, jede Anforderung braucht mindestens einen Auslöser). +**Die Auswertung gegen echte Fragebogen-Antworten ist noch nicht +gebaut** — das ist Schritt 3 der Baureihenfolge (siehe unten), bewusst +erst, wenn der Fragebogen (Schritt 2) die exakten Fakten-Feldnamen +festlegt. + +--- + +## Werkzeugkatalog (`werkzeug`, `werkzeug_sperre`) + +Der eigentliche Wert des Produkts. Zentral gepflegt (Ebene 5, +`account_id IS NULL`), für alle Mandanten identisch sichtbar; jeder +Mandant kann zusätzlich eigene Einträge (`account_id` gesetzt) und +Sperrungen zentraler Einträge (`werkzeug_sperre`) führen, ohne den +zentralen Katalog zu verändern. + +**Pflicht:** `letzte_pruefung` und `quelle` sind NOT NULL — jede +Zusicherung im Katalog (AVV verfügbar, Verarbeitungsort, Training-Opt- +out, Zertifizierungen) muss belegbar sein, sonst haftet Deklarix für +eine Aussage, die nicht nachweisbar ist. Einträge älter als 180 Tage +sollen im Ergebnis als "nicht aktuell geprüft" markiert werden (noch +nicht in der UI umgesetzt, siehe Offene Punkte). + +`CurrentKatalogVersion` liefert eine reproduzierbare Kennung des +aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird in +jeder Bewertung/Entscheidung eingefroren, damit im Audit nachvollziehbar +bleibt, mit welchem Katalogstand ein Vorschlag erzeugt wurde. + +--- + +## Bewertungslogik (geplant, Schritt 3+) + +1. **K.-o.-Prüfung.** Einstufung `verboten` → sofort Ablehnung (Art. 5 + KI-VO), keine Werkzeugsuche. +2. **Harte Filter.** Werkzeuge aussortieren, die eine Pflichtanforderung + nicht erfüllen — Grund je Werkzeug festhalten, auch aussortierte + Werkzeuge werden im Ergebnis mit Begründung gezeigt. +3. **Zweckabgleich.** Aus `beschreibung`/`ergebnis` den Zweck ableiten + (Textgenerierung, Übersetzung, Code, Bild, Transkription, Recherche) + und gegen `geeignete_zwecke` filtern. +4. **Rangfolge.** Verbleibende Werkzeuge sortieren: EU-Verarbeitung, + Training standardmäßig aus, kurze Aufbewahrung, Aktualität der + Prüfung. +5. **Auflagen ableiten.** Statt Ablehnung Bedingungen erzeugen + (Pseudonymisierung, keine Klarnamen, Vier-Augen-Prüfung, + Kennzeichnung, Aufbewahrung begrenzen). +6. **Vorschlag erzeugen.** Empfehlung, Alternativen, Auflagen, + vollständige Herleitung. + +Jede Bewertung wird als eigene Zeile gespeichert (append-only, wie +`finding`/`extraction` im alten Produkt) — eine neue Bewertung ersetzt +nicht die alte, sie ergänzt sie, damit Nachvollziehbarkeit auch bei +mehrfacher Neubewertung eines Antrags erhalten bleibt. + +--- + +## Entscheidung, Register, Wiedervorlage (geplant, Schritt 5-7) + +Der/die Verantwortliche wählt: genehmigt / genehmigt mit Auflagen / +abgelehnt / Rückfrage. Begründungsfeld ist bei Abweichung vom Vorschlag +Pflicht. **Snapshot bei Entscheidung:** Regelwerk-Version, Katalog- +Version und der vollständige Werkzeugdatensatz werden eingefroren — ein +späterer Katalog-Wandel darf nicht rückwirkend verändern, worauf eine +Entscheidung beruhte. + +Genehmigungen erhalten ein Ablaufdatum (Vorschlag: 12 Monate, bei +`hochrisiko` 6). Ändert sich im Katalog eine Eigenschaft, auf der eine +aktive Genehmigung beruht, wird der/die Verantwortliche benachrichtigt +(Wiedervorlage). + +Jede Genehmigung erzeugt automatisch einen Registereintrag: Zweck, +Abteilung, Werkzeug, Datenklasse, Einstufung, Auflagen, Verantwortliche/ +r, Datum, Gültigkeit. Export als PDF und CSV. + +--- + +## Onboarding + +**Firma (Ebene 1, öffentlich, `POST /register`):** Registrierungsformular +mit Firmendaten → Testzugang sofort aktiv → Freischaltung des +Bezahlbetriebs nach Prüfung durch den Betreiber (letzteres noch nicht +gebaut). Beim Anlegen entstehen automatisch: Account, erster Nutzer mit +Rolle `admin` (siehe Rollentabelle — vereinfacht v1: nicht zusätzlich +separat als `verantwortlicher` angelegt, siehe Offene Punkte), +leere Abteilungsliste, Werkzeugkatalog aus der Plattform vorbelegt +(noch nicht automatisiert — der zentrale Katalog ist aktuell leer, bis +Ebene 5/Betreiber-Pflege gebaut ist). + +**Mitarbeiter, geplante Reihenfolge:** (1) Einladungslink +(Sammellink, Selbstanmeldung, Abteilung beim ersten Antrag), (2) CSV- +Import, (3) SSO (Entra ID/Google Workspace), (4) Antrag ganz ohne +vorheriges Konto über Mandantenlink + E-Mail-Bestätigungscode (Konto +entsteht nebenbei). Keins davon ist gebaut — aktuell nur Firma- +Registrierung. + +**Abrechnung:** nach vom Kunden angegebener Mitarbeiterzahl, **nicht** +nach angelegten Nutzern — sonst entsteht der Anreiz, Mitarbeitende +nicht anzulegen. Noch nicht gebaut (kein Abo-System). --- @@ -106,20 +246,15 @@ Managed Postgres in der EU (DSGVO). /var/www/deklarix/ ├── cmd/ │ └── deklarix/ -│ └── main.go # Entry Point, HTTP-Server +│ └── main.go # Entry Point: Migration, Regelwerk laden, HTTP-Server ├── internal/ -│ ├── extract/ # regelbasierte Fakten-Erkennung (Keywords, Kürzungs-Position) -│ ├── rules/ # YAML-Loader, Auswertung, Versionierung -│ ├── evidence/ # Hashing, Zeitstempel, Append-only-Log -│ ├── dossier/ # PDF-Erzeugung -│ ├── socialconnect/ # OAuth-Flow Instagram/TikTok (Plattform-Verbindung) +│ ├── auth/ # Passwort-Hashing, Session-Token (produktunabhängig) +│ ├── rules/ # Lädt/validiert Datenklasse-/Einstufungs-/Anforderungs-YAML │ ├── store/ # Postgres, Migrationen │ └── web/ # Handler, Templates -├── rules/ # YAML-Regeln, versioniert im Git -├── testdata/ -│ └── golden/ # Golden-Tests: Beispielbeiträge + erwartetes Ergebnis +├── rules/ # YAML-Regelwerk + OPEN.md (offene fachliche Fragen) ├── design/ -│ └── enterprise.css # Design-System (enconf-Basis) +│ └── enterprise.css # Design-System (enconf-Basis, ungenutzt seit Pivot) ├── packaging/ │ └── DEBIAN/ │ └── control.tmpl # .deb Package-Control-Template @@ -136,135 +271,58 @@ Managed Postgres in der EU (DSGVO). ## Datenmodell -Plus drei Tabellen für Auth/Mandantentrennung (`account`, `app_user`, -`session`), mehr braucht der MVP nicht: +- `account` — ein Mandant (ein Unternehmen). Jeder Antrag gehört genau + einem Account. +- `app_user` — ein Login innerhalb eines Accounts. Rolle ist eine von + `mitarbeiter`, `verantwortlicher`, `pruefer`, `admin` (alle vier + Mandanten-bezogen) oder `betreiber` (plattformweit, Ebene 5). +- `session` — eine angemeldete Sitzung (Token, Ablaufzeit); echte + Tabelle statt zustandsloser signierter Tokens, damit Logout eine + Sitzung wirklich beendet. +- `audit_log` — Protokoll (append-only): jede Antragsänderung, jede + Bewertung, jede Entscheidung, jede Katalogänderung mit Zeitstempel + und Akteur. +- `abteilung` — Stammdaten für Fragebogen-Feld A.abteilung, je Mandant. +- `werkzeug` — Katalogeintrag (siehe oben); `account_id` NULL = zentral, + gesetzt = mandantenspezifische Ergänzung. NICHT append-only — der + Katalog wird gepflegt, eine Entscheidung friert den zu ihrem + Zeitpunkt gültigen Datensatz separat ein (geplant). +- `werkzeug_sperre` — Sperrung eines (auch zentralen) Katalogeintrags + durch einen einzelnen Mandanten. +- `antrag` — das Vorhaben aus Fragebogen-Abschnitt A plus die + vollständigen Antworten aus B/C/D als JSON (`antworten`, JSONB) — ein + starres Spaltenschema könnte den adaptiven Fragebogen nicht abbilden. + NICHT append-only (normale Zustandsänderung `entwurf` → + `eingereicht` → `entschieden`, wie `submission` es im alten Produkt + war). +- `bewertung`, `entscheidung`, `registereintrag` — **noch nicht + gebaut**, geplant für Schritt 5/6 der Baureihenfolge. -- `account` — ein Mandant (Creator, Agentur, Marke oder Kanzlei als - eigene Organisation); jede Submission gehört genau einem Account. - `verified` markiert einen Kanzlei-Account als für das öffentliche, - kostenlose Kanzlei-Verzeichnis (`GET /kanzleien`) freigegeben — nur - vom Admin-Bereich aus setzbar, nie vom Mandanten selbst -- `app_user` — ein Login innerhalb eines Accounts (E-Mail, Passwort- - Hash, Rolle). Rolle ist eine von `creator`, `agentur`, `marke`, - `kanzlei` **oder `admin`**. `admin` ist Betreiber-Personal - (Netcell-IT), nicht an einen Mandanten-Geschäftszweck gebunden, - zuständig für den Admin-Bereich (`/admin/...`: Accounts-Übersicht, - Kanzlei-Verzeichnis-Freigabe, Audit-Log). Es gibt **keine** - Selbstregistrierung für `admin` über `POST /register` (das Formular - bietet die Rolle nicht an) — der erste Admin wird einmalig per SQL - angelegt: - ```sql - INSERT INTO account (name) VALUES ('Deklarix Admin') RETURNING id; - INSERT INTO app_user (account_id, email, password_hash, role) - VALUES ('', '', '', 'admin'); - ``` - (bcrypt-Hash z. B. über `internal/auth.HashPassword` in einem - Wegwerf-`cmd/`-Programm erzeugen, da `internal/` von außerhalb des - Moduls nicht importierbar ist) -- `session` — eine angemeldete Sitzung (Token, Ablaufzeit); bewusst - eine echte Tabelle statt zustandsloser signierter Tokens, damit - Logout eine Sitzung wirklich beendet -- `audit_log` — Protokoll der Admin-Aktionen (wer hat wann welchen - Account wie verändert); append-only aus demselben Grund wie - `finding`/`extraction`/`evidence_package` -- `submission` — ein eingereichter Beitrag, Status, Zeitpunkte -- `asset` — hochgeladenes Standbild, Pfad, SHA-256; append-only aus - demselben Grund wie `finding`/`extraction`/`evidence_package` — ein - Beweisstück wird nicht nachträglich ausgetauscht. `purpose` = `initial` - (das Beweisfoto beim Prüfen, optional) oder `insights` (siehe - „Insights-Erinnerung" unten; ein Beitrag kann mehrere `insights`-Assets - über die Zeit bekommen). `GetLatestAssetForSubmission` berücksichtigt - nur `initial`, damit ein späterer Insights-Upload nie den beim - Archivieren referenzierten Original-Screenshot verdrängt -- `extraction` — das JSON aus Stufe 1, Modellversion, Prompt-Version -- `finding` — Ergebnis pro Regel: Regel-ID, Regel-Version, Schwere, - Titel, Korrektur, Fundstellen (zum Zeitpunkt des Findings fixiert, - kein Verweis auf die aktuelle Regel-YAML — die kann sich inzwischen - geändert haben) -- `evidence_package` — Dossier, Hash, Zeitstempel-Token, erzeugt am -- `participant` — Beteiligter an einer Submission mit Rolle - (`creator`, `agentur`, `marke`, `kanzlei`) und Beitrag zur - Verantwortungsmatrix (wer hat vorgegeben, wer freigegeben) -- `platform_connection` — die per OAuth hergestellte Verbindung eines - Accounts zu seinem eigenen Instagram- oder TikTok-Account (siehe - Abschnitt „Plattform-Verbindung (OAuth)" unten). NICHT append-only — - Tokens laufen ab und werden erneuert, eine Verbindung kann getrennt - und neu hergestellt werden; höchstens eine Verbindung pro - Account+Plattform (`UNIQUE(account_id, platform)`) +**Append-only:** kein UPDATE auf `audit_log` (Trigger `forbid_update_delete`, +wiederverwendet aus dem alten Produkt). `bewertung`/`entscheidung`/ +`registereintrag` werden bei ihrer Einführung ebenfalls append-only. -**Append-only.** Kein UPDATE auf `finding`, `extraction`, `asset`, -`evidence_package` oder `audit_log`. Korrekturen sind neue Zeilen mit -Verweis auf die alte. Ein Beweisarchiv (bzw. Protokoll), in dem man -Zeilen ändern kann, ist keines mehr. +--- -**Beweiskette:** SHA-256 über jedes Asset und über die kanonisierte -JSON-Repräsentation der Metadaten, RFC-3161-Zeitstempel über diesen Hash. -Dossier-PDF enthält Beitrag, Caption, Kennzeichnung, Findings mit -Fundstellen, Vertrags- und Briefing-Bezug, Verantwortungsmatrix, alle -Hashes und das Zeitstempel-Token. Vollständiger Export muss für den -Nutzer jederzeit möglich sein — wer kündigt, bekommt sein Archiv. +## Baureihenfolge -**Auth/Mandantentrennung:** `POST /register` legt einen neuen Account -plus den ersten Nutzer darin an, `POST /login` meldet einen bestehenden -Nutzer an — beides setzt ein `deklarix_session`-Cookie (HttpOnly, -SameSite=Strict, Secure sobald über TLS erreicht). `GET /` verlangt eine -gültige Sitzung (sonst Redirect zu `/login`); `POST /pruefen`, -`POST /veroeffentlichen` und `GET /dossier/{id}` verlangen sie ebenfalls -(sonst 401). Ein Beitrag eines fremden Accounts wird wie ein nicht -existierender behandelt (404), nie mit einer expliziten 403 bestätigt — -sonst würde die Antwort selbst verraten, dass die ID existiert. +1. ~~Datenmodell, Regelwerk als YAML, Katalogstruktur~~ — **erledigt** + (dieser Stand: `account`/`app_user`/`session`/`audit_log`/ + `abteilung`/`werkzeug`/`werkzeug_sperre`/`antrag` in Postgres, + `internal/rules` lädt und validiert die drei YAML-Regelwerke, + Web-Layer kompiliert mit Auth + Plattform-Bereich-Gerüst). +2. Fragebogen mit adaptiver Logik +3. Ableitungen und harte Filter (Auswertung gegen echte Antworten) +4. Ergebnisdarstellung mit Herleitung +5. Entscheidung, Snapshot, Audit-Log +6. Registereintrag und Export +7. Wiedervorlage und Katalog-Benachrichtigung -**Plattform-Verbindung (OAuth):** Jeder Kunde kann optional seinen -eigenen Instagram- oder TikTok-Account verbinden (`GET /verbindungen`), -damit die Beweissicherung einen veröffentlichten Beitrag künftig direkt -per API abrufen kann, statt ihn manuell hochzuladen — reiner -Authorization-Code-Flow, jeder Kunde autorisiert nur seinen eigenen -Account (`internal/socialconnect`, Persistenz in `platform_connection`). -Der manuelle Standbild-Upload bleibt der primäre Weg und funktioniert -unabhängig davon weiter; OAuth reduziert nur Reibung, ist kein -Ersatz für die Pre-Publish-Prüfung (die läuft zwingend vor -Veröffentlichung, wenn auf der Plattform noch nichts existiert — dafür -kann OAuth nichts abrufen). - -Technisch ist der Flow fertig (Connector-Interface, CSRF-Schutz per -State-Cookie, Token-Speicherung), aber **ohne aktive Meta-/TikTok- -Freigabe nutzlos**: Instagram (`instagram_business_basic`) und TikTok -(Login Kit + Content Posting API) verlangen jeweils eine einmalige, -plattformseitige Prüfung des Deklarix-Betreiberkontos (Meta Business -Verification + App Review: ca. 2–4 Wochen; TikTok-Audit: ca. 1–2 -Wochen), bevor sich beliebige Kunden selbst verbinden können. Bis dahin -lässt sich mit bis zu 25 (Meta) bzw. 10 (TikTok) manuell eingetragenen -Testern trotzdem schon mit einem echten Piloten testen. Ohne gesetzte -Konfiguration (`INSTAGRAM_CLIENT_ID`/`_SECRET`, -`TIKTOK_CLIENT_KEY`/`_SECRET`, `PUBLIC_BASE_URL`) zeigt -`GET /verbindungen` beide Plattformen als „noch nicht konfiguriert" -ohne Verbinden-Button — kein Absturz, kein stiller Fallback. - -**Vorsicht bei künftigen Änderungen:** Instagram-/TikTok-Endpunkte, -Scopes und Token-Formate in `internal/socialconnect` wurden ohne echte -Zugangsdaten gegen die Entwicklerdokumentation gebaut, nie gegen die -echte API verifiziert — vor dem ersten echten Verbindungsversuch mit -realen Credentials die Konstanten in `internal/socialconnect/*.go` noch -einmal gegen die dann aktuelle Meta-/TikTok-Dokumentation prüfen. - -**Insights-Erinnerung:** Story-Insights hält Instagram nach eigener -Aussage nur 24 Stunden vor — danach sind sie auch über den offiziellen -Datenexport nicht mehr zu bekommen, und der ursprüngliche Standbild- -Screenshot beim Prüfen (der direkt beim Veröffentlichen entsteht, bevor -nennenswerte Kennzahlen existieren) kann sie naturgemäß nicht erfassen. -`GET /beitraege/{id}` zeigt deshalb bei veröffentlichten `story`- -Beiträgen eine Erinnerung, solange keine `insights`-Asset existiert -(`internal/web/insights_reminder.go`, `computeInsightsReminder` — -reine Produktentscheidung zum Erinnerungs-Timing, keine Rechtsnorm, -daher bewusst nicht in `rules/*.yaml`). `POST /beitraege/{id}/insights` -speichert einen zusätzlichen Screenshot als `asset` mit -`purpose='insights'`, gehasht wie jedes andere Beweisstück — aber -NICHT im Metadaten-Hash des ursprünglichen Dossiers enthalten (das -wird beim Archivieren einmalig fixiert). Bewusst nur ein In-App- -Banner in dieser ersten Ausbaustufe, kein Mail-/Push-Versand — dafür -fehlt aktuell ein SMTP-Relay/Versanddienst; vor einer echten -Benachrichtigung per E-Mail ist das eine offene Rückfrage (welcher -Versanddienst, welche Absenderdomain/SPF/DKIM). +Nicht bauen (v1): automatische Genehmigung ohne Mensch, Erkennung +tatsächlicher Werkzeug-Nutzung, Mitarbeiterüberwachung (nichts, was +Nutzung einzelner Personen auswertet), konfigurierbare Rollen mit +eigener Oberfläche, Konzernstrukturen mit Vererbung, mandantenspezifische +Regelwerke, Workflow-Designer, SAML, Schnittstellen zu Fremdsystemen. --- @@ -385,56 +443,23 @@ zieht dafür aber **kein** eigenes Postgres hoch (kein Docker-Zwang auf dem lokalen Dev-Rechner) — das ist Aufgabe des dedizierten Testsystems, dort muss `DATABASE_URL` vorkonfiguriert sein. -**Golden-Test-Suite ist das eigentliche Asset des Projekts, nicht die -UI.** 30 bis 50 Beispielbeiträge in `testdata/golden/`, abgeleitet aus -veröffentlichten Urteilen und Beanstandungen der Medienanstalten, jeweils -mit erwarteten Findings. Jede Regeländerung läuft gegen die komplette -Suite. Eine Regeländerung, die ein Golden-Ergebnis kippt, wird nicht -gemerged, ohne dass der erwartete Wert bewusst und dokumentiert angepasst -wird. Extraktion wird separat getestet: fixierte Eingaben, geprüfte -JSON-Ausgabe. - --- ## Leitplanken, die dauerhaft gelten -- **Keine Rechtsdienstleistung.** Deklarix ist ein Werkzeug, keine - Rechtsberatung. Keine Formulierung darf eine individuelle rechtliche - Bewertung suggerieren. Auf jedem Screen mit einem Ergebnis steht ein - entsprechender Hinweis. Keine Erfolgsaussichten-Aussagen. -- **Keine Versicherungssprache.** Nie „Abmahnschutz“, „abgesichert“, - „geschützt“, „Kostenübernahme“ — sonst erlaubnispflichtiges - Versicherungsgeschäft. Immer „Prüfung“, „Hinweis“, „Dokumentation“, - „Nachweis“. -- **Keine erfundenen Fundstellen.** Jede Norm und jedes Urteil steht in - einer Regel-YAML und wurde dort von Hand geprüft. -- **Nur eigene Inhalte.** Kein Scraping fremder Accounts, keine fremden - Posts als Demo-Material. Demo-Material ist synthetisch. Nutzerinhalte - kommen über eigenen OAuth-Zugang oder Datenexport. -- **DSGVO und Hosting in der EU.** Löschkonzept von Anfang an mitdenken, - aber Beweisarchiv und Löschpflicht sauber gegeneinander abgrenzen - (offener Punkt, siehe unten). -- **Berufsrecht der Anwälte (§ 49b Abs. 3 S. 1 BRAO).** Kein Routing - (kein Button, der einen Fall an eine Kanzlei übergibt — Nutzer wählt - selbst aus dem Verzeichnis), kein ergebnisabhängiger Preis, keine - Sachvorteile an gelistete Kanzleien, Verzeichnis bleibt kostenlos - (BGH IX ZR 89/23: entgeltliche Mandatsvermittlung ist nichtig). Vor dem - ersten Kanzlei-Vertrag: formlose Auskunft der zuständigen - Rechtsanwaltskammer einholen. - ---- - -## Domains - -| Domain | Verwendung | -|--------|-----------| -| deklarix.de | Primär (Marketing-Site) | -| app.deklarix.de | Die Anwendung selbst (dieser Go-Server) | -| deklarix.com | Redirect auf deklarix.de | -| get.deklarix.com | Installer-Endpoint (apt-Einrichtung, siehe Deployment) | - -Kein separates `app.deklarix.com` — der Root-Redirect von `.com` auf -`.de` deckt Fehlnavigation ab, keine zweite Produktions-Subdomain nötig. +- **Keine Rechtsdienstleistung.** Deklarix bereitet eine Einstufung vor, + entscheidet nicht. Keine Formulierung darf eine individuelle + rechtliche Bewertung suggerieren oder Erfolgsaussichten behaupten. +- **Keine erfundenen Einstufungen.** Jede Ableitungsregel steht in einer + Regel-YAML und wurde dort von Hand geprüft; offene fachliche Fragen + stehen in `rules/OPEN.md`, werden nicht geraten. +- **Werkzeugkatalog-Zusicherungen müssen belegbar sein.** `quelle` und + `letzte_pruefung` sind Pflichtfelder, keine Ausnahme. +- **Keine Mitarbeiterüberwachung.** Nichts im Produkt darf auswerten, + welche einzelne Person welches Werkzeug wie oft nutzt — das + widerspricht dem Vertrauensmodell, auf dem die freiwillige + Antragstellung beruht. +- **DSGVO und Hosting in der EU.** --- @@ -458,10 +483,7 @@ git push origin main sudo systemctl start deklarix sudo systemctl status deklarix -# Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, RULES_DIR, -# DOSSIER_DIR, ASSET_DIR, TSA_URL, PUBLIC_BASE_URL, -# INSTAGRAM_CLIENT_ID/_SECRET, TIKTOK_CLIENT_KEY/_SECRET — letztere vier -# optional, ohne sie zeigt /verbindungen nur "nicht konfiguriert") +# Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, RULES_DIR) # Logs prüfen journalctl -u deklarix -f @@ -480,16 +502,11 @@ journalctl -u deklarix -f ## Arbeitsweise für Claude Code - Kleine Commits, ein Thema pro Commit -- Erst Test oder Golden-Fall, dann Implementierung +- Erst Test, dann Implementierung - Keine neue Abhängigkeit ohne Rückfrage -- Keine Umbenennung von Regel-IDs; Regeln werden versioniert, nicht - ersetzt -- Fehler nicht verschlucken, keine stillen Fallbacks bei der Extraktion -- Bei Unsicherheit über eine Rechtsfrage: nicht raten, sondern als offene - Frage in `rules/OPEN.md` notieren -- Der Code-RAG dient der Orientierung im Bestand, nicht als Nachweis von - Korrektheit. Verifiziert wird durch Compiler, Tests und die - Golden-Suite. +- Fehler nicht verschlucken, keine stillen Fallbacks +- Bei fachlicher Unsicherheit: nicht raten, sondern als offene Frage in + `rules/OPEN.md` notieren ## Vor Änderungen @@ -508,13 +525,27 @@ journalctl -u deklarix -f ## Offene Punkte -- Umfang des Regelsatzes für den ersten Pilotkunden festlegen -- **TSA für die Zeitstempel:** interimsweise FreeTSA.org (frei, RFC-3161- - konform, siehe `internal/evidence.DefaultTSAURL`) — funktioniert - technisch, ist aber **nicht eIDAS-qualifiziert**. Vor echtem - Kundeneinsatz auf einen eIDAS-qualifizierten Zeitstempeldienst (z. B. - D-Trust, Bundesdruckerei) umstellen, der die gesetzliche - Vermutungswirkung nach eIDAS Art. 41 hat — dafür ist ein Vertrag/Account - nötig, kein anonymer HTTP-Call. -- Löschkonzept versus Beweisarchiv sauber ausformulieren -- Markenanmeldung Deklarix, sobald erster Kunde zahlt +- **Rangfolge der Datenklassen und Grenzfälle im Anforderungsprofil** + (siehe `rules/OPEN.md`) — vor Schritt 3 mit dem/der fachlich + Verantwortlichen (z. B. Datenschutzbeauftragte/r) bestätigen. +- **Genaue Fragebogen-Felder für die drei "verboten"-Varianten** (KI-VO + Art. 5) — Fragebogen-Spezifikation nennt nur eine C3-Frage ohne die + nötige Unterscheidung, siehe `rules/OPEN.md`. +- **Löschfristen je Datenklasse** (konkrete Tageswerte für + `loeschfrist_max_tage`) — noch nicht fachlich festgelegt. +- **Mandantenisolation auf Datenbankebene (Postgres Row-Level Security)** + — die Frontend-Spezifikation fordert das explizit ("nicht in der + Anwendungslogik"), aktuell ist Isolation ausschließlich + anwendungsseitig (Handler-Vergleich `AccountID`). Umstellung auf RLS + ist ein bewusst noch nicht getroffener Architektur-Entscheid — + Aufwand und Zeitpunkt mit dem Nutzer klären, bevor mehr Tabellen + entstehen, die sonst nachträglich migriert werden müssten. +- **"Admin und KI-Verantwortlicher" beim Firma-Onboarding** — die + Spezifikation will, dass der erste Nutzer beide Rollen gleichzeitig + hat; `app_user.role` ist aktuell ein einzelner Wert. Muss geklärt + werden: zwei Rollen pro Nutzer zulassen (Datenmodell-Änderung) oder + zwei `app_user`-Zeilen für dieselbe Person? +- **Zentraler Werkzeugkatalog ist leer** — "Werkzeugkatalog aus der + Plattform vorbelegt" beim Onboarding ist nicht automatisiert, es gibt + noch keine Ebene-5/Betreiber-UI zur Katalogpflege. +- Markenanmeldung Deklarix, sobald erster Kunde zahlt. diff --git a/cmd/deklarix/main.go b/cmd/deklarix/main.go index 24674cc..56e85d8 100644 --- a/cmd/deklarix/main.go +++ b/cmd/deklarix/main.go @@ -6,10 +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/rules" - "github.com/netcell-it/deklarix/internal/socialconnect" "github.com/netcell-it/deklarix/internal/store" "github.com/netcell-it/deklarix/internal/web" ) @@ -30,45 +27,31 @@ func main() { } defer db.Close() - extractor := extract.NewEngine() - rulesDir := os.Getenv("RULES_DIR") if rulesDir == "" { rulesDir = "rules" } - ruleSet, err := rules.Load(os.DirFS(rulesDir)) + rulesFS := os.DirFS(rulesDir) + // Beim Start laden und validieren, auch wenn die Auswertungslogik + // (Phase 3 der Baureihenfolge) sie noch nicht konsumiert — ein + // kaputtes Regelwerk soll den Dienst nicht erst beim ersten Antrag + // zum Absturz bringen, sondern gar nicht erst starten lassen. + datenklasse, err := rules.LoadDatenklasse(rulesFS, "datenklasse.yaml") if err != nil { - log.Fatalf("load rules from %s: %v", rulesDir, err) + log.Fatalf("load datenklasse-regelwerk from %s: %v", rulesDir, err) } - log.Printf("%d Regeln aus %s geladen", len(ruleSet), rulesDir) - - timestamper := evidence.NewHTTPTimestamper(os.Getenv("TSA_URL")) - - dossierDir := os.Getenv("DOSSIER_DIR") - if dossierDir == "" { - dossierDir = "dossiers" + einstufung, err := rules.LoadEinstufung(rulesFS, "kivo_einstufung.yaml") + if err != nil { + log.Fatalf("load ki-vo-einstufung-regelwerk from %s: %v", rulesDir, err) } - - assetDir := os.Getenv("ASSET_DIR") - if assetDir == "" { - assetDir = "assets" + 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) - connectors := map[string]socialconnect.Connector{} - publicBaseURL := os.Getenv("PUBLIC_BASE_URL") - if publicBaseURL != "" { - if id, secret := os.Getenv("INSTAGRAM_CLIENT_ID"), os.Getenv("INSTAGRAM_CLIENT_SECRET"); id != "" && secret != "" { - connectors["instagram"] = socialconnect.NewInstagramConnector(id, secret, publicBaseURL+"/oauth/instagram/callback") - } - if key, secret := os.Getenv("TIKTOK_CLIENT_KEY"), os.Getenv("TIKTOK_CLIENT_SECRET"); key != "" && secret != "" { - connectors["tiktok"] = socialconnect.NewTikTokConnector(key, secret, publicBaseURL+"/oauth/tiktok/callback") - } - } - if len(connectors) == 0 { - log.Print("keine Plattform-Verbindungen konfiguriert (INSTAGRAM_CLIENT_ID/TIKTOK_CLIENT_KEY/PUBLIC_BASE_URL fehlen) — /verbindungen zeigt nur manuelle Beweissicherung an") - } - - server, err := web.NewServer(extractor, ruleSet, db, timestamper, dossierDir, assetDir, connectors) + server, err := web.NewServer(db) if err != nil { log.Fatalf("web server: %v", err) } diff --git a/internal/dossier/assets/cp1252.map b/internal/dossier/assets/cp1252.map deleted file mode 100644 index dd490e5..0000000 --- a/internal/dossier/assets/cp1252.map +++ /dev/null @@ -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 diff --git a/internal/dossier/content.go b/internal/dossier/content.go deleted file mode 100644 index 8ff9986..0000000 --- a/internal/dossier/content.go +++ /dev/null @@ -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 -} diff --git a/internal/dossier/content_test.go b/internal/dossier/content_test.go deleted file mode 100644 index 5e48026..0000000 --- a/internal/dossier/content_test.go +++ /dev/null @@ -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") - } -} diff --git a/internal/dossier/render.go b/internal/dossier/render.go deleted file mode 100644 index 3b84bef..0000000 --- a/internal/dossier/render.go +++ /dev/null @@ -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)) -} diff --git a/internal/dossier/render_test.go b/internal/dossier/render_test.go deleted file mode 100644 index 8f16c3d..0000000 --- a/internal/dossier/render_test.go +++ /dev/null @@ -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") - } -} diff --git a/internal/evidence/hash.go b/internal/evidence/hash.go deleted file mode 100644 index 8a4bba6..0000000 --- a/internal/evidence/hash.go +++ /dev/null @@ -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 -} diff --git a/internal/evidence/hash_test.go b/internal/evidence/hash_test.go deleted file mode 100644 index eb9cac2..0000000 --- a/internal/evidence/hash_test.go +++ /dev/null @@ -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") - } -} diff --git a/internal/evidence/parse.go b/internal/evidence/parse.go deleted file mode 100644 index 10fe542..0000000 --- a/internal/evidence/parse.go +++ /dev/null @@ -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 -} diff --git a/internal/evidence/parse_test.go b/internal/evidence/parse_test.go deleted file mode 100644 index 8d66c4d..0000000 --- a/internal/evidence/parse_test.go +++ /dev/null @@ -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") - } -} diff --git a/internal/evidence/timestamp.go b/internal/evidence/timestamp.go deleted file mode 100644 index 144320d..0000000 --- a/internal/evidence/timestamp.go +++ /dev/null @@ -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) -} diff --git a/internal/evidence/tsa.go b/internal/evidence/tsa.go deleted file mode 100644 index 52609a6..0000000 --- a/internal/evidence/tsa.go +++ /dev/null @@ -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 -} diff --git a/internal/evidence/tsa_test.go b/internal/evidence/tsa_test.go deleted file mode 100644 index 435d656..0000000 --- a/internal/evidence/tsa_test.go +++ /dev/null @@ -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) - } -} diff --git a/internal/extract/engine.go b/internal/extract/engine.go deleted file mode 100644 index 77f282e..0000000 --- a/internal/extract/engine.go +++ /dev/null @@ -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) - } -} diff --git a/internal/extract/engine_test.go b/internal/extract/engine_test.go deleted file mode 100644 index 38c6954..0000000 --- a/internal/extract/engine_test.go +++ /dev/null @@ -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") - } -} diff --git a/internal/rules/evaluate.go b/internal/rules/evaluate.go deleted file mode 100644 index e19bd42..0000000 --- a/internal/rules/evaluate.go +++ /dev/null @@ -1,42 +0,0 @@ -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 -} - -// Evaluate prüft alle Regeln gegen f und liefert ein Finding für jede -// zutreffende Regel. Reihenfolge folgt der Reihenfolge von rules. -// -// Ist f.Consideration "unklar", wird KEINE Bewertung abgegeben — -// needsClarification ist dann true und findings ist immer leer. Das ist -// Absicht (Kernprinzip): eine unsichere Extraktion erzeugt eine -// Rückfrage an den Nutzer, niemals eine stille "keine Findings"- -// Bewertung, die wie "alles in Ordnung" aussähe. -func Evaluate(rules []Rule, f Facts) (findings []Finding, needsClarification bool) { - if f.Consideration == ConsiderationUnclear { - return nil, true - } - - for _, r := range rules { - if r.Jurisdiction != f.Jurisdiction { - continue - } - if r.Condition.Matches(f) { - findings = append(findings, Finding{ - RuleID: r.ID, - RuleVersion: r.Version, - Severity: r.Severity, - Title: r.Title, - Fix: r.Fix, - Sources: r.Sources, - }) - } - } - return findings, false -} diff --git a/internal/rules/facts.go b/internal/rules/facts.go deleted file mode 100644 index 6219e89..0000000 --- a/internal/rules/facts.go +++ /dev/null @@ -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"` -} diff --git a/internal/rules/golden_test.go b/internal/rules/golden_test.go deleted file mode 100644 index 55a8b21..0000000 --- a/internal/rules/golden_test.go +++ /dev/null @@ -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 -} diff --git a/internal/rules/loader.go b/internal/rules/loader.go index 7f2cc3c..6efc8a1 100644 --- a/internal/rules/loader.go +++ b/internal/rules/loader.go @@ -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 } diff --git a/internal/rules/loader_test.go b/internal/rules/loader_test.go new file mode 100644 index 0000000..940b81d --- /dev/null +++ b/internal/rules/loader_test.go @@ -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") + } +} diff --git a/internal/rules/regelwerk.go b/internal/rules/regelwerk.go new file mode 100644 index 0000000..72bf32b --- /dev/null +++ b/internal/rules/regelwerk.go @@ -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"` +} diff --git a/internal/rules/rule.go b/internal/rules/rule.go deleted file mode 100644 index 4fdb7a2..0000000 --- a/internal/rules/rule.go +++ /dev/null @@ -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 -} diff --git a/internal/rules/rule_test.go b/internal/rules/rule_test.go deleted file mode 100644 index 6a3e50b..0000000 --- a/internal/rules/rule_test.go +++ /dev/null @@ -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) - } -} diff --git a/internal/socialconnect/connector.go b/internal/socialconnect/connector.go deleted file mode 100644 index b0b5965..0000000 --- a/internal/socialconnect/connector.go +++ /dev/null @@ -1,82 +0,0 @@ -// Package socialconnect implementiert den OAuth-Authorization-Code-Flow, -// mit dem ein Kunde seinen eigenen Instagram- oder TikTok-Account mit -// Deklarix verbindet — Ziel ist, dass die Beweissicherung einen -// veröffentlichten Beitrag später direkt abrufen kann, statt ihn manuell -// hochladen zu müssen. Reine HTTP-Logik gegen die jeweilige Plattform- -// API, keine Datenbankzugriffe — Persistenz der Verbindung liegt in -// internal/store (platform_connection). -// -// WICHTIG: Instagram- und TikTok-Endpunkte, Scopes und Token-Formate -// ändern sich häufiger als andere APIs. Vor dem ersten echten -// Verbindungsversuch mit realen Client-Credentials die Konstanten hier -// gegen die aktuelle Meta-/TikTok-Entwicklerdokumentation prüfen — -// dieser Code wurde ohne echte Zugangsdaten gebaut und gegen die -// Dokumentation, nicht gegen die echte API, verifiziert. -package socialconnect - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "time" -) - -// Token ist das Ergebnis eines erfolgreichen Code-Tauschs. -type Token struct { - AccessToken string - RefreshToken string - ExpiresAt time.Time // Nullwert, wenn die Plattform keine Ablaufzeit liefert - PlatformUserID string -} - -// Connector kapselt den OAuth-Flow einer einzelnen Plattform. *InstagramConnector -// und *TikTokConnector erfüllen dieses Interface; internal/web hält eine -// Menge konfigurierter Connectors (nur die, für die echte Client- -// Credentials gesetzt sind — siehe cmd/deklarix/main.go). -type Connector interface { - // Platform ist der interne Bezeichner ("instagram" | "tiktok"), wie - // er auch in platform_connection.platform gespeichert wird. - Platform() string - AuthorizationURL(state string) string - Exchange(ctx context.Context, code string) (Token, error) -} - -func postForm(ctx context.Context, client *http.Client, endpoint string, form url.Values, out any) error { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - return doJSON(client, req, out) -} - -func getJSON(ctx context.Context, client *http.Client, endpoint string, query url.Values, out any) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint+"?"+query.Encode(), nil) - if err != nil { - return err - } - return doJSON(client, req, out) -} - -func doJSON(client *http.Client, req *http.Request, out any) error { - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("antwort lesen: %w", err) - } - if resp.StatusCode >= 300 { - return fmt.Errorf("unerwarteter Status %d: %s", resp.StatusCode, string(body)) - } - if err := json.Unmarshal(body, out); err != nil { - return fmt.Errorf("antwort parsen: %w", err) - } - return nil -} diff --git a/internal/socialconnect/instagram.go b/internal/socialconnect/instagram.go deleted file mode 100644 index 0622eec..0000000 --- a/internal/socialconnect/instagram.go +++ /dev/null @@ -1,103 +0,0 @@ -package socialconnect - -import ( - "context" - "fmt" - "net/http" - "net/url" - "time" -) - -const ( - instagramDefaultAuthorizeURL = "https://api.instagram.com/oauth/authorize" - instagramDefaultTokenURL = "https://api.instagram.com/oauth/access_token" - instagramDefaultLongLivedTokenURL = "https://graph.instagram.com/access_token" - - // instagram_business_basic ist die einzige Berechtigung, die wir - // brauchen (Profil + Medien lesen) — mehr zu verlangen verzögert nur - // den App-Review, siehe Paket-Kommentar. - instagramScope = "instagram_business_basic" -) - -// InstagramConnector implementiert Connector für Instagram (Instagram -// API with Instagram Login). Der Flow läuft zweistufig: der -// Autorisierungscode wird zuerst gegen ein 1 Stunde gültiges Token -// getauscht, das anschließend gegen ein 60 Tage gültiges langlebiges -// Token getauscht wird — ein einzelner API-Aufruf reicht dafür nicht. -type InstagramConnector struct { - ClientID string - ClientSecret string - RedirectURL string - - // Überschreibbar für Tests (Default: die echten Instagram-Endpunkte). - AuthorizeURL string - TokenURL string - LongLivedTokenURL string - HTTPClient *http.Client -} - -// NewInstagramConnector erstellt einen InstagramConnector mit den -// echten Instagram-Endpunkten. -func NewInstagramConnector(clientID, clientSecret, redirectURL string) *InstagramConnector { - return &InstagramConnector{ - ClientID: clientID, - ClientSecret: clientSecret, - RedirectURL: redirectURL, - AuthorizeURL: instagramDefaultAuthorizeURL, - TokenURL: instagramDefaultTokenURL, - LongLivedTokenURL: instagramDefaultLongLivedTokenURL, - HTTPClient: http.DefaultClient, - } -} - -func (c *InstagramConnector) Platform() string { return "instagram" } - -func (c *InstagramConnector) AuthorizationURL(state string) string { - v := url.Values{ - "client_id": {c.ClientID}, - "redirect_uri": {c.RedirectURL}, - "scope": {instagramScope}, - "response_type": {"code"}, - "state": {state}, - } - return c.AuthorizeURL + "?" + v.Encode() -} - -func (c *InstagramConnector) Exchange(ctx context.Context, code string) (Token, error) { - form := url.Values{ - "client_id": {c.ClientID}, - "client_secret": {c.ClientSecret}, - "grant_type": {"authorization_code"}, - "redirect_uri": {c.RedirectURL}, - "code": {code}, - } - var short struct { - AccessToken string `json:"access_token"` - UserID any `json:"user_id"` // liefert Instagram mal als Zahl, mal als String - } - if err := postForm(ctx, c.HTTPClient, c.TokenURL, form, &short); err != nil { - return Token{}, fmt.Errorf("socialconnect: instagram code exchange: %w", err) - } - if short.AccessToken == "" { - return Token{}, fmt.Errorf("socialconnect: instagram code exchange: kein access_token in der Antwort") - } - - long := url.Values{ - "grant_type": {"ig_exchange_token"}, - "client_secret": {c.ClientSecret}, - "access_token": {short.AccessToken}, - } - var longResp struct { - AccessToken string `json:"access_token"` - ExpiresIn int `json:"expires_in"` - } - if err := getJSON(ctx, c.HTTPClient, c.LongLivedTokenURL, long, &longResp); err != nil { - return Token{}, fmt.Errorf("socialconnect: instagram long-lived token exchange: %w", err) - } - - return Token{ - AccessToken: longResp.AccessToken, - ExpiresAt: time.Now().Add(time.Duration(longResp.ExpiresIn) * time.Second), - PlatformUserID: fmt.Sprint(short.UserID), - }, nil -} diff --git a/internal/socialconnect/instagram_test.go b/internal/socialconnect/instagram_test.go deleted file mode 100644 index 2b1276e..0000000 --- a/internal/socialconnect/instagram_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package socialconnect - -import ( - "context" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestInstagramAuthorizationURL(t *testing.T) { - c := NewInstagramConnector("client-123", "secret", "https://app.deklarix.de/oauth/instagram/callback") - u := c.AuthorizationURL("state-abc") - - for _, want := range []string{ - "https://api.instagram.com/oauth/authorize?", - "client_id=client-123", - "state=state-abc", - "scope=instagram_business_basic", - "response_type=code", - } { - if !strings.Contains(u, want) { - t.Errorf("AuthorizationURL = %q, want it to contain %q", u, want) - } - } -} - -func TestInstagramExchangeSuccess(t *testing.T) { - tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - t.Fatalf("ParseForm: %v", err) - } - if r.FormValue("code") != "der-code" { - t.Errorf("code = %q, want der-code", r.FormValue("code")) - } - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"access_token":"short-lived-token","user_id":"17841400000000000"}`)) - })) - defer tokenServer.Close() - - longLivedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("access_token") != "short-lived-token" { - t.Errorf("access_token query = %q, want short-lived-token", r.URL.Query().Get("access_token")) - } - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"access_token":"long-lived-token","expires_in":5184000}`)) - })) - defer longLivedServer.Close() - - c := NewInstagramConnector("client-123", "secret", "https://app.deklarix.de/oauth/instagram/callback") - c.TokenURL = tokenServer.URL - c.LongLivedTokenURL = longLivedServer.URL - - tok, err := c.Exchange(context.Background(), "der-code") - if err != nil { - t.Fatalf("Exchange: %v", err) - } - if tok.AccessToken != "long-lived-token" { - t.Errorf("AccessToken = %q, want long-lived-token", tok.AccessToken) - } - if tok.PlatformUserID != "17841400000000000" { - t.Errorf("PlatformUserID = %q, want 17841400000000000", tok.PlatformUserID) - } - if tok.ExpiresAt.IsZero() { - t.Error("expected a non-zero ExpiresAt") - } -} - -func TestInstagramExchangePropagatesTokenEndpointError(t *testing.T) { - tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte(`{"error_message":"ungueltiger code"}`)) - })) - defer tokenServer.Close() - - c := NewInstagramConnector("client-123", "secret", "https://app.deklarix.de/oauth/instagram/callback") - c.TokenURL = tokenServer.URL - - if _, err := c.Exchange(context.Background(), "falscher-code"); err == nil { - t.Fatal("expected an error when the token endpoint returns 400") - } -} diff --git a/internal/socialconnect/tiktok.go b/internal/socialconnect/tiktok.go deleted file mode 100644 index 0f466da..0000000 --- a/internal/socialconnect/tiktok.go +++ /dev/null @@ -1,95 +0,0 @@ -package socialconnect - -import ( - "context" - "fmt" - "net/http" - "net/url" - "time" -) - -const ( - tiktokDefaultAuthorizeURL = "https://www.tiktok.com/v2/auth/authorize/" - tiktokDefaultTokenURL = "https://open.tiktokapis.com/v2/oauth/token/" - - // user.info.basic reicht für Profil-Grunddaten; video.list für den - // späteren Abruf veröffentlichter Videos (Beweissicherung). Mehr - // Scopes verlangen als nötig verzögert nur den Audit, siehe - // Paket-Kommentar. - tiktokScope = "user.info.basic,video.list" -) - -// TikTokConnector implementiert Connector für TikTok Login Kit v2 (Web- -// Flow — PKCE ist bei TikTok nur für Desktop/Mobile-Apps Pflicht, beim -// Web-Flow schützt allein der state-Parameter gegen CSRF, siehe -// TikTok-Dokumentation "Web"). -type TikTokConnector struct { - ClientKey string - ClientSecret string - RedirectURL string - - // Überschreibbar für Tests (Default: die echten TikTok-Endpunkte). - AuthorizeURL string - TokenURL string - HTTPClient *http.Client -} - -// NewTikTokConnector erstellt einen TikTokConnector mit den echten -// TikTok-Endpunkten. -func NewTikTokConnector(clientKey, clientSecret, redirectURL string) *TikTokConnector { - return &TikTokConnector{ - ClientKey: clientKey, - ClientSecret: clientSecret, - RedirectURL: redirectURL, - AuthorizeURL: tiktokDefaultAuthorizeURL, - TokenURL: tiktokDefaultTokenURL, - HTTPClient: http.DefaultClient, - } -} - -func (c *TikTokConnector) Platform() string { return "tiktok" } - -func (c *TikTokConnector) AuthorizationURL(state string) string { - v := url.Values{ - "client_key": {c.ClientKey}, - "redirect_uri": {c.RedirectURL}, - "scope": {tiktokScope}, - "response_type": {"code"}, - "state": {state}, - } - return c.AuthorizeURL + "?" + v.Encode() -} - -func (c *TikTokConnector) Exchange(ctx context.Context, code string) (Token, error) { - form := url.Values{ - "client_key": {c.ClientKey}, - "client_secret": {c.ClientSecret}, - "code": {code}, - "grant_type": {"authorization_code"}, - "redirect_uri": {c.RedirectURL}, - } - var resp struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int `json:"expires_in"` - OpenID string `json:"open_id"` - Error string `json:"error"` - ErrorDescription string `json:"error_description"` - } - if err := postForm(ctx, c.HTTPClient, c.TokenURL, form, &resp); err != nil { - return Token{}, fmt.Errorf("socialconnect: tiktok code exchange: %w", err) - } - if resp.Error != "" { - return Token{}, fmt.Errorf("socialconnect: tiktok code exchange: %s: %s", resp.Error, resp.ErrorDescription) - } - if resp.AccessToken == "" { - return Token{}, fmt.Errorf("socialconnect: tiktok code exchange: kein access_token in der Antwort") - } - - return Token{ - AccessToken: resp.AccessToken, - RefreshToken: resp.RefreshToken, - ExpiresAt: time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second), - PlatformUserID: resp.OpenID, - }, nil -} diff --git a/internal/socialconnect/tiktok_test.go b/internal/socialconnect/tiktok_test.go deleted file mode 100644 index d8604d7..0000000 --- a/internal/socialconnect/tiktok_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package socialconnect - -import ( - "context" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestTikTokAuthorizationURL(t *testing.T) { - c := NewTikTokConnector("client-key-123", "secret", "https://app.deklarix.de/oauth/tiktok/callback") - u := c.AuthorizationURL("state-xyz") - - for _, want := range []string{ - "https://www.tiktok.com/v2/auth/authorize/?", - "client_key=client-key-123", - "state=state-xyz", - "response_type=code", - } { - if !strings.Contains(u, want) { - t.Errorf("AuthorizationURL = %q, want it to contain %q", u, want) - } - } -} - -func TestTikTokExchangeSuccess(t *testing.T) { - tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - t.Fatalf("ParseForm: %v", err) - } - if r.FormValue("client_key") != "client-key-123" { - t.Errorf("client_key = %q, want client-key-123", r.FormValue("client_key")) - } - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"access_token":"tt-access","refresh_token":"tt-refresh","expires_in":86400,"open_id":"tt-open-id-1"}`)) - })) - defer tokenServer.Close() - - c := NewTikTokConnector("client-key-123", "secret", "https://app.deklarix.de/oauth/tiktok/callback") - c.TokenURL = tokenServer.URL - - tok, err := c.Exchange(context.Background(), "der-code") - if err != nil { - t.Fatalf("Exchange: %v", err) - } - if tok.AccessToken != "tt-access" || tok.RefreshToken != "tt-refresh" || tok.PlatformUserID != "tt-open-id-1" { - t.Errorf("unexpected token: %+v", tok) - } -} - -func TestTikTokExchangePropagatesPlatformError(t *testing.T) { - tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // TikTok liefert Fehler oft mit Status 200, Fehlerfeldern im Body. - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"error":"invalid_grant","error_description":"code abgelaufen"}`)) - })) - defer tokenServer.Close() - - c := NewTikTokConnector("client-key-123", "secret", "https://app.deklarix.de/oauth/tiktok/callback") - c.TokenURL = tokenServer.URL - - _, err := c.Exchange(context.Background(), "abgelaufener-code") - if err == nil { - t.Fatal("expected an error when the platform response contains an error field") - } - if !strings.Contains(err.Error(), "invalid_grant") { - t.Errorf("error = %v, want it to mention invalid_grant", err) - } -} diff --git a/internal/store/abteilung.go b/internal/store/abteilung.go new file mode 100644 index 0000000..ab7c678 --- /dev/null +++ b/internal/store/abteilung.go @@ -0,0 +1,85 @@ +package store + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// Abteilung ist Stammdatum für den Fragebogen (Feld A.abteilung). +type Abteilung struct { + ID string + AccountID string + Name string + CreatedAt time.Time +} + +// CreateAbteilung legt eine Abteilung für einen Mandanten an. +func (s *Store) CreateAbteilung(ctx context.Context, accountID, name string) (Abteilung, error) { + var a Abteilung + err := s.Pool.QueryRow(ctx, ` + INSERT INTO abteilung (account_id, name) VALUES ($1, $2) + RETURNING id, account_id, name, created_at + `, accountID, name).Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt) + if err != nil { + return Abteilung{}, fmt.Errorf("store: create abteilung: %w", err) + } + return a, nil +} + +// ListAbteilungenForAccount liefert alle Abteilungen eines Mandanten, +// alphabetisch — als Auswahlliste für den Fragebogen. +func (s *Store) ListAbteilungenForAccount(ctx context.Context, accountID string) ([]Abteilung, error) { + rows, err := s.Pool.Query(ctx, ` + SELECT id, account_id, name, created_at FROM abteilung + WHERE account_id = $1 ORDER BY name + `, accountID) + if err != nil { + return nil, fmt.Errorf("store: list abteilungen: %w", err) + } + defer rows.Close() + + var out []Abteilung + for rows.Next() { + var a Abteilung + if err := rows.Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt); err != nil { + return nil, fmt.Errorf("store: scan abteilung: %w", err) + } + out = append(out, a) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: list abteilungen: %w", err) + } + return out, nil +} + +// GetAbteilung liest eine Abteilung anhand ihrer ID. +func (s *Store) GetAbteilung(ctx context.Context, id string) (Abteilung, error) { + var a Abteilung + err := s.Pool.QueryRow(ctx, ` + SELECT id, account_id, name, created_at FROM abteilung WHERE id = $1 + `, id).Scan(&a.ID, &a.AccountID, &a.Name, &a.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return Abteilung{}, ErrNotFound + } + if err != nil { + return Abteilung{}, fmt.Errorf("store: get abteilung: %w", err) + } + return a, nil +} + +// DeleteAbteilung entfernt eine Abteilung (z. B. versehentlich doppelt +// angelegt). +func (s *Store) DeleteAbteilung(ctx context.Context, id string) error { + tag, err := s.Pool.Exec(ctx, `DELETE FROM abteilung WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("store: delete abteilung: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} diff --git a/internal/store/abteilung_test.go b/internal/store/abteilung_test.go new file mode 100644 index 0000000..262b818 --- /dev/null +++ b/internal/store/abteilung_test.go @@ -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) + } +} diff --git a/internal/store/account.go b/internal/store/account.go index b2cc29b..728e23d 100644 --- a/internal/store/account.go +++ b/internal/store/account.go @@ -9,25 +9,21 @@ import ( "github.com/jackc/pgx/v5" ) -// Account ist ein Mandant (Creator, Agentur, Marke oder Kanzlei als -// eigene Organisation). Jeder Beitrag gehört genau einem Account. -// Verified ist nur für Kanzlei-Accounts relevant: ob sie im öffentlichen -// Kanzlei-Verzeichnis gelistet werden (siehe Migration 0004). +// Account ist ein Mandant (ein Unternehmen, das die Antragsprüfung +// nutzt). Jeder Antrag gehört genau einem Account. type Account struct { ID string Name string - Verified bool CreatedAt time.Time } -// CreateAccount legt einen neuen Mandanten an (verified startet false — -// jede Freigabe fürs Kanzlei-Verzeichnis ist eine bewusste Admin-Aktion). +// CreateAccount legt einen neuen Mandanten an. func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) { var a Account err := s.Pool.QueryRow(ctx, ` INSERT INTO account (name) VALUES ($1) - RETURNING id, name, verified, created_at - `, name).Scan(&a.ID, &a.Name, &a.Verified, &a.CreatedAt) + RETURNING id, name, created_at + `, name).Scan(&a.ID, &a.Name, &a.CreatedAt) if err != nil { return Account{}, fmt.Errorf("store: create account: %w", err) } @@ -38,8 +34,8 @@ func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) { var a Account err := s.Pool.QueryRow(ctx, ` - SELECT id, name, verified, created_at FROM account WHERE id = $1 - `, id).Scan(&a.ID, &a.Name, &a.Verified, &a.CreatedAt) + SELECT id, name, created_at FROM account WHERE id = $1 + `, id).Scan(&a.ID, &a.Name, &a.CreatedAt) if errors.Is(err, pgx.ErrNoRows) { return Account{}, ErrNotFound } @@ -53,7 +49,7 @@ func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) { // Admin-Bereich (Accounts-Verwaltung). func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) { rows, err := s.Pool.Query(ctx, ` - SELECT id, name, verified, created_at FROM account ORDER BY created_at DESC + SELECT id, name, created_at FROM account ORDER BY created_at DESC `) if err != nil { return nil, fmt.Errorf("store: list accounts: %w", err) @@ -63,7 +59,7 @@ func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) { var out []Account for rows.Next() { var a Account - if err := rows.Scan(&a.ID, &a.Name, &a.Verified, &a.CreatedAt); err != nil { + if err := rows.Scan(&a.ID, &a.Name, &a.CreatedAt); err != nil { return nil, fmt.Errorf("store: scan account: %w", err) } out = append(out, a) @@ -73,50 +69,3 @@ func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) { } return out, nil } - -// ListVerifiedKanzleien liefert alle Accounts, die fürs öffentliche -// Kanzlei-Verzeichnis freigegeben sind UND mindestens einen Nutzer der -// Rolle "kanzlei" haben — verified allein reicht nicht, falls ein Admin -// versehentlich einen Nicht-Kanzlei-Account markiert. -func (s *Store) ListVerifiedKanzleien(ctx context.Context) ([]Account, error) { - rows, err := s.Pool.Query(ctx, ` - SELECT DISTINCT a.id, a.name, a.verified, a.created_at - FROM account a - JOIN app_user u ON u.account_id = a.id - WHERE a.verified = true AND u.role = 'kanzlei' - ORDER BY a.name - `) - if err != nil { - return nil, fmt.Errorf("store: list verified kanzleien: %w", err) - } - defer rows.Close() - - var out []Account - for rows.Next() { - var a Account - if err := rows.Scan(&a.ID, &a.Name, &a.Verified, &a.CreatedAt); err != nil { - return nil, fmt.Errorf("store: scan account: %w", err) - } - out = append(out, a) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("store: list verified kanzleien: %w", err) - } - return out, nil -} - -// SetAccountVerified setzt die Freigabe fürs Kanzlei-Verzeichnis. -func (s *Store) SetAccountVerified(ctx context.Context, id string, verified bool) (Account, error) { - var a Account - err := s.Pool.QueryRow(ctx, ` - UPDATE account SET verified = $2 WHERE id = $1 - RETURNING id, name, verified, created_at - `, id, verified).Scan(&a.ID, &a.Name, &a.Verified, &a.CreatedAt) - if errors.Is(err, pgx.ErrNoRows) { - return Account{}, ErrNotFound - } - if err != nil { - return Account{}, fmt.Errorf("store: set account verified: %w", err) - } - return a, nil -} diff --git a/internal/store/admin_test.go b/internal/store/admin_test.go index e37a532..1505b39 100644 --- a/internal/store/admin_test.go +++ b/internal/store/admin_test.go @@ -5,54 +5,27 @@ import ( "testing" ) -func TestAppUserRoleAllowsAdmin(t *testing.T) { +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", "admin") + u, err := s.CreateUser(ctx, accID, "admin@example.com", "hash", "betreiber") if err != nil { - t.Fatalf("CreateUser mit role=admin: %v", err) + t.Fatalf("CreateUser mit role=betreiber: %v", err) } - if u.Role != "admin" { - t.Fatalf("Role = %q, want admin", u.Role) + if u.Role != "betreiber" { + t.Fatalf("Role = %q, want betreiber", u.Role) } } -func TestAccountVerifiedDefaultsFalseAndCanBeSet(t *testing.T) { +func TestAppUserRoleRejectsUnknownRole(t *testing.T) { s := openTestStore(t) ctx := context.Background() + accID := testAccountID(t, s) - acc, err := s.CreateAccount(ctx, "Kanzlei Musterfrau") - if err != nil { - t.Fatalf("CreateAccount: %v", err) - } - if acc.Verified { - t.Fatal("expected a new account to be unverified by default") - } - - updated, err := s.SetAccountVerified(ctx, acc.ID, true) - if err != nil { - t.Fatalf("SetAccountVerified: %v", err) - } - if !updated.Verified { - t.Fatal("expected the account to be verified after SetAccountVerified(true)") - } - - got, err := s.GetAccount(ctx, acc.ID) - if err != nil { - t.Fatalf("GetAccount: %v", err) - } - if !got.Verified { - t.Fatal("expected verified=true to persist") - } -} - -func TestSetAccountVerifiedNotFound(t *testing.T) { - s := openTestStore(t) - _, err := s.SetAccountVerified(context.Background(), "00000000-0000-0000-0000-000000000000", true) - if err == nil { - t.Fatal("expected an error for an unknown account") + 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") } } @@ -87,75 +60,19 @@ func TestListAccounts(t *testing.T) { } } -func TestListVerifiedKanzleienRequiresBothVerifiedAndKanzleiRole(t *testing.T) { - s := openTestStore(t) - ctx := context.Background() - - // Verifiziert, aber kein Kanzlei-Nutzer -> darf nicht auftauchen. - verifiedNonKanzlei, err := s.CreateAccount(ctx, "Verifizierte Marke") - if err != nil { - t.Fatalf("CreateAccount: %v", err) - } - if _, err := s.CreateUser(ctx, verifiedNonKanzlei.ID, "marke@example.com", "hash", "marke"); err != nil { - t.Fatalf("CreateUser: %v", err) - } - if _, err := s.SetAccountVerified(ctx, verifiedNonKanzlei.ID, true); err != nil { - t.Fatalf("SetAccountVerified: %v", err) - } - - // Kanzlei-Nutzer, aber nicht verifiziert -> darf nicht auftauchen. - unverifiedKanzlei, err := s.CreateAccount(ctx, "Unverifizierte Kanzlei") - if err != nil { - t.Fatalf("CreateAccount: %v", err) - } - if _, err := s.CreateUser(ctx, unverifiedKanzlei.ID, "unverifiziert@example.com", "hash", "kanzlei"); err != nil { - t.Fatalf("CreateUser: %v", err) - } - - // Beides erfuellt -> muss auftauchen. - verifiedKanzlei, err := s.CreateAccount(ctx, "Verifizierte Kanzlei") - if err != nil { - t.Fatalf("CreateAccount: %v", err) - } - if _, err := s.CreateUser(ctx, verifiedKanzlei.ID, "verifiziert@example.com", "hash", "kanzlei"); err != nil { - t.Fatalf("CreateUser: %v", err) - } - if _, err := s.SetAccountVerified(ctx, verifiedKanzlei.ID, true); err != nil { - t.Fatalf("SetAccountVerified: %v", err) - } - - list, err := s.ListVerifiedKanzleien(ctx) - if err != nil { - t.Fatalf("ListVerifiedKanzleien: %v", err) - } - byID := map[string]bool{} - for _, a := range list { - byID[a.ID] = true - } - if byID[verifiedNonKanzlei.ID] { - t.Error("verified non-kanzlei account should not appear in the directory") - } - if byID[unverifiedKanzlei.ID] { - t.Error("unverified kanzlei account should not appear in the directory") - } - if !byID[verifiedKanzlei.ID] { - t.Error("expected the verified kanzlei account in the directory") - } -} - 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", "creator"); err != nil { + 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", "agentur"); err != nil { + 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", "marke"); err != nil { + if _, err := s.CreateUser(ctx, otherAccID, "fremd@example.com", "hash", "mitarbeiter"); err != nil { t.Fatalf("CreateUser: %v", err) } @@ -172,12 +89,12 @@ 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", "admin") + 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, "account.verified", "account", accID, "manuell freigegeben") + entry, err := s.CreateAuditEntry(ctx, admin.ID, "werkzeug.aktualisiert", "werkzeug", accID, "manuell geprüft") if err != nil { t.Fatalf("CreateAuditEntry: %v", err) } @@ -201,11 +118,11 @@ 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", "admin") + 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, "account.verified", "account", accID, "") + entry, err := s.CreateAuditEntry(ctx, admin.ID, "werkzeug.aktualisiert", "werkzeug", accID, "") if err != nil { t.Fatalf("CreateAuditEntry: %v", err) } diff --git a/internal/store/antrag.go b/internal/store/antrag.go new file mode 100644 index 0000000..6278d53 --- /dev/null +++ b/internal/store/antrag.go @@ -0,0 +1,135 @@ +package store + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// Antrag ist ein Vorhaben aus dem Fragebogen (Abschnitt A) mit den +// vollständigen Antworten aus B/C/D als JSON — der Fragebogen ist +// adaptiv (Folgefragen hängen von vorherigen Antworten ab), ein starres +// Spaltenschema könnte das nicht abbilden. Antworten ist bewusst +// []byte (rohes JSON), nicht ein aufgelöster Go-Typ — die Struktur der +// Antworten ist Sache von internal/rules (Stufe 2), store speichert nur. +type Antrag struct { + ID string + AccountID string + ErstellerUserID string + AbteilungID *string + Titel string + Beschreibung string + Ergebnis string + Haeufigkeit string + Antworten []byte + Status string + CreatedAt time.Time + UpdatedAt time.Time +} + +const antragColumns = `id, account_id, ersteller_user_id, abteilung_id, titel, beschreibung, + ergebnis, haeufigkeit, antworten, status, created_at, updated_at` + +func scanAntrag(row interface { + Scan(dest ...any) error +}) (Antrag, error) { + var a Antrag + err := row.Scan( + &a.ID, &a.AccountID, &a.ErstellerUserID, &a.AbteilungID, &a.Titel, &a.Beschreibung, + &a.Ergebnis, &a.Haeufigkeit, &a.Antworten, &a.Status, &a.CreatedAt, &a.UpdatedAt, + ) + return a, err +} + +// CreateAntrag legt einen neuen Antrag im Status "entwurf" an. +func (s *Store) CreateAntrag(ctx context.Context, accountID, erstellerUserID string, abteilungID *string, titel string) (Antrag, error) { + row := s.Pool.QueryRow(ctx, ` + INSERT INTO antrag (account_id, ersteller_user_id, abteilung_id, titel) + VALUES ($1, $2, $3, $4) + RETURNING `+antragColumns, + accountID, erstellerUserID, abteilungID, titel, + ) + a, err := scanAntrag(row) + if err != nil { + return Antrag{}, fmt.Errorf("store: create antrag: %w", err) + } + return a, nil +} + +// GetAntrag liest einen Antrag anhand seiner ID — ohne Mandanten-Prüfung, +// das ist Sache des Aufrufers (siehe Antrag.AccountID). +func (s *Store) GetAntrag(ctx context.Context, id string) (Antrag, error) { + row := s.Pool.QueryRow(ctx, `SELECT `+antragColumns+` FROM antrag WHERE id = $1`, id) + a, err := scanAntrag(row) + if errors.Is(err, pgx.ErrNoRows) { + return Antrag{}, ErrNotFound + } + if err != nil { + return Antrag{}, fmt.Errorf("store: get antrag: %w", err) + } + return a, nil +} + +// UpdateAntragFelder aktualisiert die Fragebogen-Felder eines Antrags — +// solange er im Entwurf ist, kann der Fragebogen adaptiv weiter +// ausgefüllt werden (Sache der Anwendungsschicht, store erzwingt den +// Status hier nicht). +func (s *Store) UpdateAntragFelder(ctx context.Context, id, titel, beschreibung, ergebnis, haeufigkeit string, antworten []byte) (Antrag, error) { + row := s.Pool.QueryRow(ctx, ` + UPDATE antrag SET + titel = $2, beschreibung = $3, ergebnis = $4, haeufigkeit = $5, antworten = $6, updated_at = now() + WHERE id = $1 + RETURNING `+antragColumns, + id, titel, beschreibung, ergebnis, haeufigkeit, antworten, + ) + a, err := scanAntrag(row) + if errors.Is(err, pgx.ErrNoRows) { + return Antrag{}, ErrNotFound + } + if err != nil { + return Antrag{}, fmt.Errorf("store: update antrag felder: %w", err) + } + return a, nil +} + +// SetAntragStatus setzt den Status eines Antrags (entwurf -> eingereicht +// -> entschieden). Antrag ist, anders als bewertung/entscheidung, NICHT +// append-only — der Lebenszyklus ist eine normale Zustandsänderung. +func (s *Store) SetAntragStatus(ctx context.Context, id, status string) error { + tag, err := s.Pool.Exec(ctx, `UPDATE antrag SET status = $2, updated_at = now() WHERE id = $1`, id, status) + if err != nil { + return fmt.Errorf("store: set antrag status: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// ListAntraegeForAccount liefert alle Anträge eines Mandanten, neueste +// zuerst. +func (s *Store) ListAntraegeForAccount(ctx context.Context, accountID string) ([]Antrag, error) { + rows, err := s.Pool.Query(ctx, ` + SELECT `+antragColumns+` FROM antrag WHERE account_id = $1 ORDER BY created_at DESC + `, accountID) + if err != nil { + return nil, fmt.Errorf("store: list antraege for account: %w", err) + } + defer rows.Close() + + var out []Antrag + for rows.Next() { + a, err := scanAntrag(rows) + if err != nil { + return nil, fmt.Errorf("store: scan antrag: %w", err) + } + out = append(out, a) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: list antraege for account: %w", err) + } + return out, nil +} diff --git a/internal/store/antrag_test.go b/internal/store/antrag_test.go new file mode 100644 index 0000000..2177c11 --- /dev/null +++ b/internal/store/antrag_test.go @@ -0,0 +1,107 @@ +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 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) + } +} diff --git a/internal/store/asset.go b/internal/store/asset.go deleted file mode 100644 index 0546500..0000000 --- a/internal/store/asset.go +++ /dev/null @@ -1,98 +0,0 @@ -package store - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/jackc/pgx/v5" -) - -// Asset ist eine zu einem Beitrag hochgeladene Datei. Append-only wie -// extraction/finding/evidence_package — ein hochgeladenes Beweisstück -// wird nicht nachträglich ausgetauscht, siehe Migration. Purpose -// unterscheidet das ursprüngliche Beweisfoto beim Prüfen ("initial") -// von einem späteren Nachweis flüchtiger Kennzahlen ("insights") — -// siehe Migration 0007 und CLAUDE.md, Abschnitt Insights-Erinnerung. -type Asset struct { - ID string - SubmissionID string - Kind string - Purpose string - Path string - SHA256 string - CreatedAt time.Time -} - -// CreateAsset speichert ein Asset. sha256Hex ist der Hex-kodierte -// SHA-256-Digest der Datei (siehe evidence.HashBytes) — dieselbe Form, -// in der evidence_package.SHA256 seinen Hash speichert. -func (s *Store) CreateAsset(ctx context.Context, submissionID, kind, purpose, path, sha256Hex string) (Asset, error) { - var a Asset - err := s.Pool.QueryRow(ctx, ` - INSERT INTO asset (submission_id, kind, purpose, path, sha256) - VALUES ($1, $2, $3, $4, $5) - RETURNING id, submission_id, kind, purpose, path, sha256, created_at - `, submissionID, kind, purpose, path, sha256Hex).Scan( - &a.ID, &a.SubmissionID, &a.Kind, &a.Purpose, &a.Path, &a.SHA256, &a.CreatedAt, - ) - if err != nil { - return Asset{}, fmt.Errorf("store: create asset: %w", err) - } - return a, nil -} - -// GetLatestAssetForSubmission liefert das zuletzt hochgeladene Asset -// mit purpose="initial" eines Beitrags — bewusst ohne spätere -// "insights"-Assets, damit ein erneutes Archivieren immer denselben -// ursprünglichen Beweis referenziert, egal wie viele Insights- -// Screenshots danach noch hinzukommen. Liefert ErrNotFound, wenn keins -// hochgeladen wurde — das ist der Normalfall (ein Standbild ist -// optional), kein Fehler, den Aufrufer wie einen echten -// Datenbankfehler behandeln sollten. -func (s *Store) GetLatestAssetForSubmission(ctx context.Context, submissionID string) (Asset, error) { - var a Asset - err := s.Pool.QueryRow(ctx, ` - SELECT id, submission_id, kind, purpose, path, sha256, created_at - FROM asset - WHERE submission_id = $1 AND purpose = 'initial' - ORDER BY created_at DESC - LIMIT 1 - `, submissionID).Scan( - &a.ID, &a.SubmissionID, &a.Kind, &a.Purpose, &a.Path, &a.SHA256, &a.CreatedAt, - ) - if errors.Is(err, pgx.ErrNoRows) { - return Asset{}, ErrNotFound - } - if err != nil { - return Asset{}, fmt.Errorf("store: get latest asset: %w", err) - } - return a, nil -} - -// ListAssetsForSubmission liefert alle Assets eines Beitrags -// (initiales Standbild und alle Insights-Nachweise), älteste zuerst. -func (s *Store) ListAssetsForSubmission(ctx context.Context, submissionID string) ([]Asset, error) { - rows, err := s.Pool.Query(ctx, ` - SELECT id, submission_id, kind, purpose, path, sha256, created_at - FROM asset WHERE submission_id = $1 ORDER BY created_at - `, submissionID) - if err != nil { - return nil, fmt.Errorf("store: list assets for submission: %w", err) - } - defer rows.Close() - - var out []Asset - for rows.Next() { - var a Asset - if err := rows.Scan(&a.ID, &a.SubmissionID, &a.Kind, &a.Purpose, &a.Path, &a.SHA256, &a.CreatedAt); err != nil { - return nil, fmt.Errorf("store: scan asset: %w", err) - } - out = append(out, a) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("store: list assets for submission: %w", err) - } - return out, nil -} diff --git a/internal/store/asset_test.go b/internal/store/asset_test.go deleted file mode 100644 index 233aa85..0000000 --- a/internal/store/asset_test.go +++ /dev/null @@ -1,152 +0,0 @@ -package store_test - -import ( - "context" - "errors" - "testing" - - "github.com/netcell-it/deklarix/internal/store" -) - -func TestAssetCreateAndGetLatest(t *testing.T) { - s := openTestStore(t) - ctx := context.Background() - accID := testAccountID(t, s) - sub, err := s.CreateSubmission(ctx, accID, "instagram", "feed", "...") - if err != nil { - t.Fatalf("CreateSubmission: %v", err) - } - - a, err := s.CreateAsset(ctx, sub.ID, "image", "initial", "/var/lib/deklarix/assets/abc.jpg", "deadbeef") - if err != nil { - t.Fatalf("CreateAsset: %v", err) - } - if a.SubmissionID != sub.ID || a.Kind != "image" || a.Purpose != "initial" { - t.Fatalf("CreateAsset = %+v, unerwartete Werte", a) - } - - got, err := s.GetLatestAssetForSubmission(ctx, sub.ID) - if err != nil { - t.Fatalf("GetLatestAssetForSubmission: %v", err) - } - if got.ID != a.ID || got.SHA256 != "deadbeef" { - t.Fatalf("GetLatestAssetForSubmission = %+v, want %+v", got, a) - } -} - -func TestGetLatestAssetForSubmissionNotFoundWhenNoneUploaded(t *testing.T) { - s := openTestStore(t) - ctx := context.Background() - accID := testAccountID(t, s) - sub, err := s.CreateSubmission(ctx, accID, "instagram", "feed", "...") - if err != nil { - t.Fatalf("CreateSubmission: %v", err) - } - - _, err = s.GetLatestAssetForSubmission(ctx, sub.ID) - if !errors.Is(err, store.ErrNotFound) { - t.Fatalf("err = %v, want store.ErrNotFound", err) - } -} - -func TestGetLatestAssetForSubmissionReturnsNewestWhenMultiple(t *testing.T) { - s := openTestStore(t) - ctx := context.Background() - accID := testAccountID(t, s) - sub, err := s.CreateSubmission(ctx, accID, "instagram", "feed", "...") - if err != nil { - t.Fatalf("CreateSubmission: %v", err) - } - - if _, err := s.CreateAsset(ctx, sub.ID, "image", "initial", "/tmp/erstes.jpg", "erstehash"); err != nil { - t.Fatalf("CreateAsset (1): %v", err) - } - second, err := s.CreateAsset(ctx, sub.ID, "image", "initial", "/tmp/zweites.jpg", "zweitehash") - if err != nil { - t.Fatalf("CreateAsset (2): %v", err) - } - - got, err := s.GetLatestAssetForSubmission(ctx, sub.ID) - if err != nil { - t.Fatalf("GetLatestAssetForSubmission: %v", err) - } - if got.ID != second.ID { - t.Fatalf("expected the newest asset, got %+v", got) - } -} - -func TestGetLatestAssetForSubmissionIgnoresInsightsAssets(t *testing.T) { - s := openTestStore(t) - ctx := context.Background() - accID := testAccountID(t, s) - sub, err := s.CreateSubmission(ctx, accID, "instagram", "story", "...") - if err != nil { - t.Fatalf("CreateSubmission: %v", err) - } - - initial, err := s.CreateAsset(ctx, sub.ID, "image", "initial", "/tmp/initial.jpg", "initialhash") - if err != nil { - t.Fatalf("CreateAsset (initial): %v", err) - } - if _, err := s.CreateAsset(ctx, sub.ID, "image", "insights", "/tmp/insights.jpg", "insightshash"); err != nil { - t.Fatalf("CreateAsset (insights): %v", err) - } - - got, err := s.GetLatestAssetForSubmission(ctx, sub.ID) - if err != nil { - t.Fatalf("GetLatestAssetForSubmission: %v", err) - } - if got.ID != initial.ID { - t.Fatalf("expected GetLatestAssetForSubmission to keep returning the initial asset even after an insights asset was added later, got %+v", got) - } -} - -func TestListAssetsForSubmissionReturnsAllPurposes(t *testing.T) { - s := openTestStore(t) - ctx := context.Background() - accID := testAccountID(t, s) - sub, err := s.CreateSubmission(ctx, accID, "instagram", "story", "...") - if err != nil { - t.Fatalf("CreateSubmission: %v", err) - } - if _, err := s.CreateAsset(ctx, sub.ID, "image", "initial", "/tmp/initial.jpg", "h1"); err != nil { - t.Fatalf("CreateAsset (initial): %v", err) - } - if _, err := s.CreateAsset(ctx, sub.ID, "image", "insights", "/tmp/insights.jpg", "h2"); err != nil { - t.Fatalf("CreateAsset (insights): %v", err) - } - - list, err := s.ListAssetsForSubmission(ctx, sub.ID) - if err != nil { - t.Fatalf("ListAssetsForSubmission: %v", err) - } - if len(list) != 2 { - t.Fatalf("expected 2 assets, got %d: %+v", len(list), list) - } - if list[0].Purpose != "initial" || list[1].Purpose != "insights" { - t.Fatalf("expected initial before insights (created_at order), got %+v", list) - } -} - -func TestAssetIsAppendOnly(t *testing.T) { - s := openTestStore(t) - ctx := context.Background() - accID := testAccountID(t, s) - sub, err := s.CreateSubmission(ctx, accID, "instagram", "feed", "...") - if err != nil { - t.Fatalf("CreateSubmission: %v", err) - } - a, err := s.CreateAsset(ctx, sub.ID, "image", "initial", "/tmp/x.jpg", "hash") - if err != nil { - t.Fatalf("CreateAsset: %v", err) - } - - _, err = s.Pool.Exec(ctx, `UPDATE asset SET sha256 = 'geaendert' WHERE id = $1`, a.ID) - if err == nil { - t.Fatal("expected UPDATE on asset to be rejected by the append-only trigger") - } - _, err = s.Pool.Exec(ctx, `DELETE FROM asset WHERE id = $1`, a.ID) - if err == nil { - t.Fatal("expected DELETE on asset to be rejected by the append-only trigger") - } -} diff --git a/internal/store/auth_test.go b/internal/store/auth_test.go index 42876a3..8748b6d 100644 --- a/internal/store/auth_test.go +++ b/internal/store/auth_test.go @@ -32,7 +32,7 @@ func TestUserCRUD(t *testing.T) { ctx := context.Background() accID := testAccountID(t, s) - user, err := s.CreateUser(ctx, accID, "team@example.com", "bcrypt-hash", "agentur") + user, err := s.CreateUser(ctx, accID, "team@example.com", "bcrypt-hash", "mitarbeiter") if err != nil { t.Fatalf("CreateUser: %v", err) } @@ -67,10 +67,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 +80,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) } diff --git a/internal/store/crud_test.go b/internal/store/crud_test.go deleted file mode 100644 index ec535a1..0000000 --- a/internal/store/crud_test.go +++ /dev/null @@ -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") - } -} diff --git a/internal/store/evidence.go b/internal/store/evidence.go deleted file mode 100644 index 5c6fa78..0000000 --- a/internal/store/evidence.go +++ /dev/null @@ -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 -} diff --git a/internal/store/extraction.go b/internal/store/extraction.go deleted file mode 100644 index 1f539e9..0000000 --- a/internal/store/extraction.go +++ /dev/null @@ -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 -} diff --git a/internal/store/finding.go b/internal/store/finding.go deleted file mode 100644 index 733a651..0000000 --- a/internal/store/finding.go +++ /dev/null @@ -1,80 +0,0 @@ -package store - -import ( - "context" - "fmt" - "time" -) - -// Finding ist das Ergebnis einer Regel für einen Beitrag. Append-only: -// siehe Migration. ExtractionID ist optional (nil wenn ein Finding nicht -// direkt aus einer Extraktion, sondern z. B. manuell erzeugt wurde). -// Title/Fix/Sources sind zum Zeitpunkt der Regelauswertung fixiert -// gespeichert (nicht nur rule_id/rule_version referenziert), weil ein -// späteres Update der Regel-YAML eine ältere Version sonst nicht mehr -// nachträglich auflösen könnte — der Wortlaut zum Zeitpunkt des -// Findings ist der Beweis, kein Verweis darauf. -type Finding struct { - ID string - SubmissionID string - ExtractionID *string - RuleID string - RuleVersion int - Severity string - Title string - Fix string - Sources []string - CreatedAt time.Time -} - -// CreateFinding speichert ein Finding. sources ist NOT NULL in der DB -// (TEXT[]) — ein nil-Slice (z. B. eine Regel ohne fundstelle-Eintrag) -// würde als SQL-NULL ankommen und mit einer wenig hilfreichen Constraint- -// Fehlermeldung abgelehnt; hier stattdessen auf eine leere Liste normiert. -func (s *Store) CreateFinding(ctx context.Context, submissionID string, extractionID *string, ruleID string, ruleVersion int, severity, title, fix string, sources []string) (Finding, error) { - if sources == nil { - sources = []string{} - } - var f Finding - err := s.Pool.QueryRow(ctx, ` - INSERT INTO finding (submission_id, extraction_id, rule_id, rule_version, severity, title, fix, sources) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id, submission_id, extraction_id, rule_id, rule_version, severity, title, fix, sources, created_at - `, submissionID, extractionID, ruleID, ruleVersion, severity, title, fix, sources).Scan( - &f.ID, &f.SubmissionID, &f.ExtractionID, &f.RuleID, &f.RuleVersion, &f.Severity, &f.Title, &f.Fix, &f.Sources, &f.CreatedAt, - ) - if err != nil { - return Finding{}, fmt.Errorf("store: create finding: %w", err) - } - return f, nil -} - -// ListCurrentFindings liefert die aktuell gültigen Findings eines -// Beitrags — Zeilen, die von keiner anderen Zeile per supersedes -// ersetzt wurden (siehe Migrationskommentar zu finding.supersedes). -func (s *Store) ListCurrentFindings(ctx context.Context, submissionID string) ([]Finding, error) { - rows, err := s.Pool.Query(ctx, ` - SELECT f.id, f.submission_id, f.extraction_id, f.rule_id, f.rule_version, f.severity, f.title, f.fix, f.sources, f.created_at - FROM finding f - WHERE f.submission_id = $1 - AND NOT EXISTS (SELECT 1 FROM finding f2 WHERE f2.supersedes = f.id) - ORDER BY f.created_at - `, submissionID) - if err != nil { - return nil, fmt.Errorf("store: list current findings: %w", err) - } - defer rows.Close() - - var findings []Finding - for rows.Next() { - var f Finding - if err := rows.Scan(&f.ID, &f.SubmissionID, &f.ExtractionID, &f.RuleID, &f.RuleVersion, &f.Severity, &f.Title, &f.Fix, &f.Sources, &f.CreatedAt); err != nil { - return nil, fmt.Errorf("store: scan finding: %w", err) - } - findings = append(findings, f) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("store: list current findings: %w", err) - } - return findings, nil -} diff --git a/internal/store/migrations/0008_pivot_ki_antragspruefung.down.sql b/internal/store/migrations/0008_pivot_ki_antragspruefung.down.sql new file mode 100644 index 0000000..5b449ee --- /dev/null +++ b/internal/store/migrations/0008_pivot_ki_antragspruefung.down.sql @@ -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) +); diff --git a/internal/store/migrations/0008_pivot_ki_antragspruefung.up.sql b/internal/store/migrations/0008_pivot_ki_antragspruefung.up.sql new file mode 100644 index 0000000..ceb7267 --- /dev/null +++ b/internal/store/migrations/0008_pivot_ki_antragspruefung.up.sql @@ -0,0 +1,104 @@ +-- 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 alten Rollen (creator/agentur/marke/kanzlei) haben im neuen +-- Produkt keine Bedeutung mehr. +ALTER TABLE app_user DROP CONSTRAINT app_user_role_check; +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() +); diff --git a/internal/store/participant.go b/internal/store/participant.go deleted file mode 100644 index 15e1549..0000000 --- a/internal/store/participant.go +++ /dev/null @@ -1,121 +0,0 @@ -package store - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/jackc/pgx/v5" -) - -// Participant ist ein Beteiligter an einer Submission (Verantwortungs- -// matrix). Anders als finding/extraction/evidence_package ist participant -// NICHT append-only — wer vorgegeben/freigegeben hat, kann sich klären -// oder korrigieren, ohne dass das ein Beweis-Eintrag ist. -type Participant struct { - ID string - SubmissionID string - Role string - Name string - Vorgegeben bool - Freigegeben bool - ApprovedAt *time.Time - CreatedAt time.Time -} - -// CreateParticipant fügt einen Beteiligten zu einer Submission hinzu. -func (s *Store) CreateParticipant(ctx context.Context, submissionID, role, name string, vorgegeben, freigegeben bool) (Participant, error) { - var p Participant - err := s.Pool.QueryRow(ctx, ` - INSERT INTO participant (submission_id, role, name, vorgegeben, freigegeben, approved_at) - VALUES ($1, $2, $3, $4, $5, CASE WHEN $5 THEN now() ELSE NULL END) - RETURNING id, submission_id, role, name, vorgegeben, freigegeben, approved_at, created_at - `, submissionID, role, name, vorgegeben, freigegeben).Scan( - &p.ID, &p.SubmissionID, &p.Role, &p.Name, &p.Vorgegeben, &p.Freigegeben, &p.ApprovedAt, &p.CreatedAt, - ) - if err != nil { - return Participant{}, fmt.Errorf("store: create participant: %w", err) - } - return p, nil -} - -// ListParticipants liefert alle Beteiligten einer Submission. -func (s *Store) ListParticipants(ctx context.Context, submissionID string) ([]Participant, error) { - rows, err := s.Pool.Query(ctx, ` - SELECT id, submission_id, role, name, vorgegeben, freigegeben, approved_at, created_at - FROM participant WHERE submission_id = $1 ORDER BY created_at - `, submissionID) - if err != nil { - return nil, fmt.Errorf("store: list participants: %w", err) - } - defer rows.Close() - - var participants []Participant - for rows.Next() { - var p Participant - if err := rows.Scan(&p.ID, &p.SubmissionID, &p.Role, &p.Name, &p.Vorgegeben, &p.Freigegeben, &p.ApprovedAt, &p.CreatedAt); err != nil { - return nil, fmt.Errorf("store: scan participant: %w", err) - } - participants = append(participants, p) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("store: list participants: %w", err) - } - return participants, nil -} - -// GetParticipant liest einen Beteiligten anhand seiner ID — u. a. um vor -// einem Update/Delete zu prüfen, zu welcher Submission (und damit zu -// welchem Account) er gehört. -func (s *Store) GetParticipant(ctx context.Context, id string) (Participant, error) { - var p Participant - err := s.Pool.QueryRow(ctx, ` - SELECT id, submission_id, role, name, vorgegeben, freigegeben, approved_at, created_at - FROM participant WHERE id = $1 - `, id).Scan(&p.ID, &p.SubmissionID, &p.Role, &p.Name, &p.Vorgegeben, &p.Freigegeben, &p.ApprovedAt, &p.CreatedAt) - if errors.Is(err, pgx.ErrNoRows) { - return Participant{}, ErrNotFound - } - if err != nil { - return Participant{}, fmt.Errorf("store: get participant: %w", err) - } - return p, nil -} - -// UpdateParticipant setzt vorgegeben/freigegeben. approved_at wird beim -// ersten Wechsel zu freigegeben=true gesetzt und danach nicht mehr -// verändert (er hält fest, wann zuerst freigegeben wurde). -func (s *Store) UpdateParticipant(ctx context.Context, id string, vorgegeben, freigegeben bool) (Participant, error) { - var p Participant - err := s.Pool.QueryRow(ctx, ` - UPDATE participant - SET vorgegeben = $2, - freigegeben = $3, - approved_at = CASE WHEN $3 AND approved_at IS NULL THEN now() ELSE approved_at END - WHERE id = $1 - RETURNING id, submission_id, role, name, vorgegeben, freigegeben, approved_at, created_at - `, id, vorgegeben, freigegeben).Scan( - &p.ID, &p.SubmissionID, &p.Role, &p.Name, &p.Vorgegeben, &p.Freigegeben, &p.ApprovedAt, &p.CreatedAt, - ) - if errors.Is(err, pgx.ErrNoRows) { - return Participant{}, ErrNotFound - } - if err != nil { - return Participant{}, fmt.Errorf("store: update participant: %w", err) - } - return p, nil -} - -// DeleteParticipant entfernt einen Beteiligten (z. B. versehentlich -// falsch angelegt). -func (s *Store) DeleteParticipant(ctx context.Context, id string) error { - tag, err := s.Pool.Exec(ctx, `DELETE FROM participant WHERE id = $1`, id) - if err != nil { - return fmt.Errorf("store: delete participant: %w", err) - } - if tag.RowsAffected() == 0 { - return ErrNotFound - } - return nil -} diff --git a/internal/store/participant_test.go b/internal/store/participant_test.go deleted file mode 100644 index e77f2c0..0000000 --- a/internal/store/participant_test.go +++ /dev/null @@ -1,133 +0,0 @@ -package store_test - -import ( - "context" - "errors" - "testing" - - "github.com/netcell-it/deklarix/internal/store" -) - -func TestParticipantCRUD(t *testing.T) { - s := openTestStore(t) - ctx := context.Background() - accID := testAccountID(t, s) - sub, err := s.CreateSubmission(ctx, accID, "instagram", "reel", "...") - if err != nil { - t.Fatalf("CreateSubmission: %v", err) - } - - p, err := s.CreateParticipant(ctx, sub.ID, "creator", "Max Mustermann", false, false) - if err != nil { - t.Fatalf("CreateParticipant: %v", err) - } - if p.ApprovedAt != nil { - t.Fatalf("ApprovedAt should be nil when freigegeben=false, got %v", p.ApprovedAt) - } - - list, err := s.ListParticipants(ctx, sub.ID) - if err != nil { - t.Fatalf("ListParticipants: %v", err) - } - if len(list) != 1 || list[0].ID != p.ID { - t.Fatalf("ListParticipants = %+v, want exactly the created participant", list) - } - - got, err := s.GetParticipant(ctx, p.ID) - if err != nil { - t.Fatalf("GetParticipant: %v", err) - } - if got.SubmissionID != sub.ID { - t.Fatalf("GetParticipant.SubmissionID = %q, want %q", got.SubmissionID, sub.ID) - } - - updated, err := s.UpdateParticipant(ctx, p.ID, true, true) - if err != nil { - t.Fatalf("UpdateParticipant: %v", err) - } - if !updated.Vorgegeben || !updated.Freigegeben { - t.Fatalf("UpdateParticipant did not apply new flags: %+v", updated) - } - if updated.ApprovedAt == nil { - t.Fatal("expected ApprovedAt to be set once freigegeben became true") - } - firstApproval := *updated.ApprovedAt - - // Ein erneutes Update (weiterhin freigegeben) darf approved_at nicht - // verschieben — es haelt fest, wann ZUERST freigegeben wurde. - updated2, err := s.UpdateParticipant(ctx, p.ID, true, true) - if err != nil { - t.Fatalf("UpdateParticipant (2): %v", err) - } - if !updated2.ApprovedAt.Equal(firstApproval) { - t.Fatalf("ApprovedAt changed on a no-op update: %v -> %v", firstApproval, *updated2.ApprovedAt) - } - - if err := s.DeleteParticipant(ctx, p.ID); err != nil { - t.Fatalf("DeleteParticipant: %v", err) - } - if _, err := s.GetParticipant(ctx, p.ID); !errors.Is(err, store.ErrNotFound) { - t.Fatalf("err after delete = %v, want store.ErrNotFound", err) - } -} - -func TestUpdateParticipantNotFound(t *testing.T) { - s := openTestStore(t) - _, err := s.UpdateParticipant(context.Background(), "00000000-0000-0000-0000-000000000000", true, true) - if !errors.Is(err, store.ErrNotFound) { - t.Fatalf("err = %v, want store.ErrNotFound", err) - } -} - -func TestDeleteParticipantNotFound(t *testing.T) { - s := openTestStore(t) - err := s.DeleteParticipant(context.Background(), "00000000-0000-0000-0000-000000000000") - if !errors.Is(err, store.ErrNotFound) { - t.Fatalf("err = %v, want store.ErrNotFound", err) - } -} - -func TestListSubmissionsForAccount(t *testing.T) { - s := openTestStore(t) - ctx := context.Background() - accID := testAccountID(t, s) - - subNoFindings, err := s.CreateSubmission(ctx, accID, "instagram", "reel", "organic") - if err != nil { - t.Fatalf("CreateSubmission: %v", err) - } - subWithFinding, err := s.CreateSubmission(ctx, accID, "tiktok", "video", "unmarked ad") - if err != nil { - t.Fatalf("CreateSubmission: %v", err) - } - // sources bewusst nil statt []string{} — CreateFinding muss das - // selbst abfangen (siehe Kommentar dort), nicht der Aufrufer. - if _, err := s.CreateFinding(ctx, subWithFinding.ID, nil, "WK-001", 1, "hoch", "t", "f", nil); err != nil { - t.Fatalf("CreateFinding: %v", err) - } - - // Andere Mandanten duerfen nicht auftauchen. - otherAccID := testAccountID(t, s) - if _, err := s.CreateSubmission(ctx, otherAccID, "instagram", "reel", "anderer Mandant"); err != nil { - t.Fatalf("CreateSubmission (other account): %v", err) - } - - list, err := s.ListSubmissionsForAccount(ctx, accID) - if err != nil { - t.Fatalf("ListSubmissionsForAccount: %v", err) - } - if len(list) != 2 { - t.Fatalf("expected 2 submissions for this account, got %d: %+v", len(list), list) - } - - byID := map[string]store.SubmissionSummary{} - for _, s := range list { - byID[s.ID] = s - } - if byID[subNoFindings.ID].FindingCount != 0 || byID[subNoFindings.ID].HighestSeverity != "" { - t.Errorf("subNoFindings summary = %+v, want 0 findings and no severity", byID[subNoFindings.ID]) - } - if byID[subWithFinding.ID].FindingCount != 1 || byID[subWithFinding.ID].HighestSeverity != "hoch" { - t.Errorf("subWithFinding summary = %+v, want 1 finding, severity hoch", byID[subWithFinding.ID]) - } -} diff --git a/internal/store/platformconnection.go b/internal/store/platformconnection.go deleted file mode 100644 index 56e3713..0000000 --- a/internal/store/platformconnection.go +++ /dev/null @@ -1,108 +0,0 @@ -package store - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/jackc/pgx/v5" -) - -// PlatformConnection ist die per OAuth hergestellte Verbindung eines -// Accounts zu seinem eigenen Instagram- oder TikTok-Account (siehe -// internal/socialconnect). Anders als asset/finding/extraction NICHT -// append-only — ein abgelaufenes oder erneuertes Token ersetzt das alte, -// eine getrennte Verbindung wird wirklich gelöscht. -type PlatformConnection struct { - ID string - AccountID string - Platform string - PlatformUserID string - AccessToken string - RefreshToken string - ExpiresAt *time.Time - ConnectedAt time.Time -} - -// UpsertPlatformConnection legt eine Verbindung an oder ersetzt die -// bestehende für dasselbe Account+Plattform-Paar (z. B. bei erneutem -// Verbinden nach Trennen, oder wenn refresh_token erneuert wurde). -func (s *Store) UpsertPlatformConnection(ctx context.Context, accountID, platform, platformUserID, accessToken, refreshToken string, expiresAt *time.Time) (PlatformConnection, error) { - var c PlatformConnection - err := s.Pool.QueryRow(ctx, ` - INSERT INTO platform_connection (account_id, platform, platform_user_id, access_token, refresh_token, expires_at) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT (account_id, platform) DO UPDATE SET - platform_user_id = EXCLUDED.platform_user_id, - access_token = EXCLUDED.access_token, - refresh_token = EXCLUDED.refresh_token, - expires_at = EXCLUDED.expires_at, - connected_at = now() - RETURNING id, account_id, platform, platform_user_id, access_token, refresh_token, expires_at, connected_at - `, accountID, platform, platformUserID, accessToken, refreshToken, expiresAt).Scan( - &c.ID, &c.AccountID, &c.Platform, &c.PlatformUserID, &c.AccessToken, &c.RefreshToken, &c.ExpiresAt, &c.ConnectedAt, - ) - if err != nil { - return PlatformConnection{}, fmt.Errorf("store: upsert platform connection: %w", err) - } - return c, nil -} - -// ListPlatformConnectionsForAccount liefert alle Plattform-Verbindungen -// eines Accounts. -func (s *Store) ListPlatformConnectionsForAccount(ctx context.Context, accountID string) ([]PlatformConnection, error) { - rows, err := s.Pool.Query(ctx, ` - SELECT id, account_id, platform, platform_user_id, access_token, refresh_token, expires_at, connected_at - FROM platform_connection WHERE account_id = $1 ORDER BY platform - `, accountID) - if err != nil { - return nil, fmt.Errorf("store: list platform connections: %w", err) - } - defer rows.Close() - - var out []PlatformConnection - for rows.Next() { - var c PlatformConnection - if err := rows.Scan(&c.ID, &c.AccountID, &c.Platform, &c.PlatformUserID, &c.AccessToken, &c.RefreshToken, &c.ExpiresAt, &c.ConnectedAt); err != nil { - return nil, fmt.Errorf("store: scan platform connection: %w", err) - } - out = append(out, c) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("store: list platform connections: %w", err) - } - return out, nil -} - -// GetPlatformConnection liest die Verbindung eines Accounts zu einer -// bestimmten Plattform. Liefert ErrNotFound, wenn keine Verbindung -// besteht — der Normalfall, solange der Kunde nichts verbunden hat. -func (s *Store) GetPlatformConnection(ctx context.Context, accountID, platform string) (PlatformConnection, error) { - var c PlatformConnection - err := s.Pool.QueryRow(ctx, ` - SELECT id, account_id, platform, platform_user_id, access_token, refresh_token, expires_at, connected_at - FROM platform_connection WHERE account_id = $1 AND platform = $2 - `, accountID, platform).Scan( - &c.ID, &c.AccountID, &c.Platform, &c.PlatformUserID, &c.AccessToken, &c.RefreshToken, &c.ExpiresAt, &c.ConnectedAt, - ) - if errors.Is(err, pgx.ErrNoRows) { - return PlatformConnection{}, ErrNotFound - } - if err != nil { - return PlatformConnection{}, fmt.Errorf("store: get platform connection: %w", err) - } - return c, nil -} - -// DeletePlatformConnection trennt eine Plattform-Verbindung. -func (s *Store) DeletePlatformConnection(ctx context.Context, accountID, platform string) error { - tag, err := s.Pool.Exec(ctx, `DELETE FROM platform_connection WHERE account_id = $1 AND platform = $2`, accountID, platform) - if err != nil { - return fmt.Errorf("store: delete platform connection: %w", err) - } - if tag.RowsAffected() == 0 { - return ErrNotFound - } - return nil -} diff --git a/internal/store/platformconnection_test.go b/internal/store/platformconnection_test.go deleted file mode 100644 index 196514d..0000000 --- a/internal/store/platformconnection_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package store_test - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/netcell-it/deklarix/internal/store" -) - -func TestPlatformConnectionUpsertGetDelete(t *testing.T) { - s := openTestStore(t) - ctx := context.Background() - accID := testAccountID(t, s) - - expires := time.Now().Add(60 * 24 * time.Hour).Truncate(time.Millisecond) - c, err := s.UpsertPlatformConnection(ctx, accID, "instagram", "ig-user-1", "access-1", "", &expires) - if err != nil { - t.Fatalf("UpsertPlatformConnection: %v", err) - } - if c.Platform != "instagram" || c.PlatformUserID != "ig-user-1" { - t.Fatalf("UpsertPlatformConnection = %+v, unerwartete Werte", c) - } - - got, err := s.GetPlatformConnection(ctx, accID, "instagram") - if err != nil { - t.Fatalf("GetPlatformConnection: %v", err) - } - if got.AccessToken != "access-1" { - t.Fatalf("AccessToken = %q, want access-1", got.AccessToken) - } - - list, err := s.ListPlatformConnectionsForAccount(ctx, accID) - if err != nil { - t.Fatalf("ListPlatformConnectionsForAccount: %v", err) - } - if len(list) != 1 { - t.Fatalf("expected exactly 1 connection, got %d", len(list)) - } - - if err := s.DeletePlatformConnection(ctx, accID, "instagram"); err != nil { - t.Fatalf("DeletePlatformConnection: %v", err) - } - if _, err := s.GetPlatformConnection(ctx, accID, "instagram"); !errors.Is(err, store.ErrNotFound) { - t.Fatalf("err nach Delete = %v, want store.ErrNotFound", err) - } -} - -func TestPlatformConnectionUpsertReplacesExisting(t *testing.T) { - s := openTestStore(t) - ctx := context.Background() - accID := testAccountID(t, s) - - first, err := s.UpsertPlatformConnection(ctx, accID, "tiktok", "tt-user-1", "access-alt", "refresh-alt", nil) - if err != nil { - t.Fatalf("UpsertPlatformConnection (1): %v", err) - } - second, err := s.UpsertPlatformConnection(ctx, accID, "tiktok", "tt-user-1", "access-neu", "refresh-neu", nil) - if err != nil { - t.Fatalf("UpsertPlatformConnection (2): %v", err) - } - if second.ID != first.ID { - t.Fatalf("expected the same row to be updated (same account+platform), got a new ID") - } - if second.AccessToken != "access-neu" || second.RefreshToken != "refresh-neu" { - t.Fatalf("UpsertPlatformConnection (2) = %+v, tokens wurden nicht ersetzt", second) - } - - list, err := s.ListPlatformConnectionsForAccount(ctx, accID) - if err != nil { - t.Fatalf("ListPlatformConnectionsForAccount: %v", err) - } - if len(list) != 1 { - t.Fatalf("expected exactly 1 connection after upsert-replace, got %d", len(list)) - } -} - -func TestGetPlatformConnectionNotFound(t *testing.T) { - s := openTestStore(t) - ctx := context.Background() - accID := testAccountID(t, s) - - _, err := s.GetPlatformConnection(ctx, accID, "instagram") - if !errors.Is(err, store.ErrNotFound) { - t.Fatalf("err = %v, want store.ErrNotFound", err) - } -} - -func TestDeletePlatformConnectionNotFound(t *testing.T) { - s := openTestStore(t) - ctx := context.Background() - accID := testAccountID(t, s) - - err := s.DeletePlatformConnection(ctx, accID, "tiktok") - if !errors.Is(err, store.ErrNotFound) { - t.Fatalf("err = %v, want store.ErrNotFound", err) - } -} - -func TestPlatformConnectionIsolatedPerAccount(t *testing.T) { - s := openTestStore(t) - ctx := context.Background() - accA := testAccountID(t, s) - accB := testAccountID(t, s) - - if _, err := s.UpsertPlatformConnection(ctx, accA, "instagram", "ig-a", "token-a", "", nil); err != nil { - t.Fatalf("UpsertPlatformConnection (A): %v", err) - } - - if _, err := s.GetPlatformConnection(ctx, accB, "instagram"); !errors.Is(err, store.ErrNotFound) { - t.Fatalf("Mandant B sollte keine Verbindung von Mandant A sehen, err = %v", err) - } -} diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 7f9bd7e..3dcf4f6 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -19,6 +19,30 @@ func testDatabaseURL(t *testing.T) string { return url } +func openTestStore(t *testing.T) *store.Store { + t.Helper() + url := testDatabaseURL(t) + if err := store.Migrate(url); err != nil { + t.Fatalf("Migrate: %v", err) + } + s, err := store.Open(context.Background(), url) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(s.Close) + return s +} + +// testAccountID legt einen Mandanten an und liefert dessen ID. +func testAccountID(t *testing.T, s *store.Store) string { + t.Helper() + acc, err := s.CreateAccount(context.Background(), "Test-Mandant") + if err != nil { + t.Fatalf("CreateAccount: %v", err) + } + return acc.ID +} + func TestMigrateAndOpen(t *testing.T) { url := testDatabaseURL(t) @@ -36,56 +60,11 @@ func TestMigrateAndOpen(t *testing.T) { err = s.Pool.QueryRow(context.Background(), ` SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = ANY($1) - `, []string{"submission", "asset", "extraction", "finding", "evidence_package", "participant"}).Scan(&tableCount) + `, []string{"account", "app_user", "session", "audit_log", "abteilung", "werkzeug", "werkzeug_sperre", "antrag"}).Scan(&tableCount) if err != nil { t.Fatalf("query tables: %v", err) } - if tableCount != 6 { - t.Fatalf("expected 6 tables, got %d", tableCount) - } -} - -func TestFindingIsAppendOnly(t *testing.T) { - url := testDatabaseURL(t) - - if err := store.Migrate(url); err != nil { - t.Fatalf("Migrate: %v", err) - } - - s, err := store.Open(context.Background(), url) - if err != nil { - t.Fatalf("Open: %v", err) - } - defer s.Close() - - ctx := context.Background() - - acc, err := s.CreateAccount(ctx, "Test-Mandant") - if err != nil { - t.Fatalf("create account: %v", err) - } - - var submissionID string - err = s.Pool.QueryRow(ctx, ` - INSERT INTO submission (account_id, platform, post_type) VALUES ($1, 'instagram', 'reel') - RETURNING id - `, acc.ID).Scan(&submissionID) - if err != nil { - t.Fatalf("insert submission: %v", err) - } - - var findingID string - err = s.Pool.QueryRow(ctx, ` - INSERT INTO finding (submission_id, rule_id, rule_version, severity, title, fix, sources) - VALUES ($1, 'WK-004', 3, 'hoch', 'Testfeststellung', 'Testkorrektur', '{}') - RETURNING id - `, submissionID).Scan(&findingID) - if err != nil { - t.Fatalf("insert finding: %v", err) - } - - _, err = s.Pool.Exec(ctx, `UPDATE finding SET title = 'geändert' WHERE id = $1`, findingID) - if err == nil { - t.Fatal("expected UPDATE on finding to be rejected, but it succeeded") + if tableCount != 8 { + t.Fatalf("expected 8 tables, got %d", tableCount) } } diff --git a/internal/store/submission.go b/internal/store/submission.go deleted file mode 100644 index 1f3864a..0000000 --- a/internal/store/submission.go +++ /dev/null @@ -1,135 +0,0 @@ -package store - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/jackc/pgx/v5" -) - -// Submission ist ein eingereichter Beitrag. AccountID ist der Mandant, -// dem der Beitrag gehört (Mandantentrennung) — jede Abfrage, die einen -// Beitrag ausliefert, muss AccountID gegen den angemeldeten Account -// prüfen (siehe internal/web-Middleware), store selbst erzwingt das -// nicht auf Zeilenebene. -type Submission struct { - ID string - AccountID string - Platform string - PostType string - Caption string - Status string - CreatedAt time.Time - UpdatedAt time.Time -} - -// CreateSubmission legt einen neuen Beitrag für einen Mandanten an -// (Status "draft"). -func (s *Store) CreateSubmission(ctx context.Context, accountID, platform, postType, caption string) (Submission, error) { - var sub Submission - err := s.Pool.QueryRow(ctx, ` - INSERT INTO submission (account_id, platform, post_type, caption) - VALUES ($1, $2, $3, $4) - RETURNING id, account_id, platform, post_type, caption, status, created_at, updated_at - `, accountID, platform, postType, caption).Scan( - &sub.ID, &sub.AccountID, &sub.Platform, &sub.PostType, &sub.Caption, &sub.Status, &sub.CreatedAt, &sub.UpdatedAt, - ) - if err != nil { - return Submission{}, fmt.Errorf("store: create submission: %w", err) - } - return sub, nil -} - -// GetSubmission liest einen Beitrag anhand seiner ID — ohne -// Mandanten-Prüfung, das ist Sache des Aufrufers (siehe Submission.AccountID). -func (s *Store) GetSubmission(ctx context.Context, id string) (Submission, error) { - var sub Submission - err := s.Pool.QueryRow(ctx, ` - SELECT id, account_id, platform, post_type, caption, status, created_at, updated_at - FROM submission WHERE id = $1 - `, id).Scan( - &sub.ID, &sub.AccountID, &sub.Platform, &sub.PostType, &sub.Caption, &sub.Status, &sub.CreatedAt, &sub.UpdatedAt, - ) - if errors.Is(err, pgx.ErrNoRows) { - return Submission{}, ErrNotFound - } - if err != nil { - return Submission{}, fmt.Errorf("store: get submission: %w", err) - } - return sub, nil -} - -// SubmissionSummary ist eine Submission plus einer Kurzfassung ihrer -// aktuell gültigen Findings, wie sie eine Übersichtsliste braucht (ohne -// für jede Zeile extra ListCurrentFindings aufzurufen). -type SubmissionSummary struct { - Submission - FindingCount int - HighestSeverity string // "" wenn keine Findings -} - -// ListSubmissionsForAccount liefert alle Beiträge eines Mandanten, -// neueste zuerst, mit Findings-Kurzfassung. -func (s *Store) ListSubmissionsForAccount(ctx context.Context, accountID string) ([]SubmissionSummary, error) { - rows, err := s.Pool.Query(ctx, ` - SELECT - s.id, s.account_id, s.platform, s.post_type, s.caption, s.status, s.created_at, s.updated_at, - COUNT(f.id) AS finding_count, - COALESCE(MAX(CASE f.severity WHEN 'hoch' THEN 3 WHEN 'mittel' THEN 2 WHEN 'niedrig' THEN 1 ELSE 0 END), 0) AS severity_rank - FROM submission s - LEFT JOIN finding f - ON f.submission_id = s.id - AND NOT EXISTS (SELECT 1 FROM finding f2 WHERE f2.supersedes = f.id) - WHERE s.account_id = $1 - GROUP BY s.id - ORDER BY s.created_at DESC - `, accountID) - if err != nil { - return nil, fmt.Errorf("store: list submissions for account: %w", err) - } - defer rows.Close() - - var out []SubmissionSummary - for rows.Next() { - var sub SubmissionSummary - var severityRank int - if err := rows.Scan( - &sub.ID, &sub.AccountID, &sub.Platform, &sub.PostType, &sub.Caption, &sub.Status, &sub.CreatedAt, &sub.UpdatedAt, - &sub.FindingCount, &severityRank, - ); err != nil { - return nil, fmt.Errorf("store: scan submission summary: %w", err) - } - switch severityRank { - case 3: - sub.HighestSeverity = "hoch" - case 2: - sub.HighestSeverity = "mittel" - case 1: - sub.HighestSeverity = "niedrig" - } - out = append(out, sub) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("store: list submissions for account: %w", err) - } - return out, nil -} - -// SetSubmissionStatus setzt den Status eines Beitrags (submission ist, -// anders als extraction/finding/evidence_package, NICHT append-only — -// der Lebenszyklus draft → checked → published → archived ist eine -// normale Zustandsänderung, kein Beweis-Eintrag). -func (s *Store) SetSubmissionStatus(ctx context.Context, id, status string) error { - tag, err := s.Pool.Exec(ctx, ` - UPDATE submission SET status = $2, updated_at = now() WHERE id = $1 - `, id, status) - if err != nil { - return fmt.Errorf("store: set submission status: %w", err) - } - if tag.RowsAffected() == 0 { - return fmt.Errorf("store: set submission status: submission %s nicht gefunden", id) - } - return nil -} diff --git a/internal/store/werkzeug.go b/internal/store/werkzeug.go new file mode 100644 index 0000000..c69a252 --- /dev/null +++ b/internal/store/werkzeug.go @@ -0,0 +1,301 @@ +package store + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// Werkzeug ist ein Eintrag im KI-Werkzeugkatalog — der eigentliche Wert +// des Produkts (siehe CLAUDE.md). AccountID nil markiert einen zentral +// gepflegten, für alle Mandanten identischen Katalogeintrag; ein +// gesetzter AccountID ist eine mandantenspezifische Ergänzung. +// LetztePruefung und Quelle sind Pflicht — jede Zusicherung im Katalog +// muss belegbar sein. +type Werkzeug struct { + ID string + AccountID *string + Name string + Anbieter string + Verarbeitungsort string + AVVVerfuegbar bool + AVVURL string + TrainingOptOut bool + TrainingStandard bool + AufbewahrungTage int + Zertifizierungen []string + GeeigneteZwecke []string + Einschraenkungen []string + LetztePruefung time.Time + Quelle string + CreatedAt time.Time + UpdatedAt time.Time +} + +// WerkzeugInput bündelt die Felder eines Werkzeug-Eintrags für +// Create/Update — bei 14 Feldern lesbarer als eine positionale +// Parameterliste. +type WerkzeugInput struct { + AccountID *string + Name string + Anbieter string + Verarbeitungsort string + AVVVerfuegbar bool + AVVURL string + TrainingOptOut bool + TrainingStandard bool + AufbewahrungTage int + Zertifizierungen []string + GeeigneteZwecke []string + Einschraenkungen []string + LetztePruefung time.Time + Quelle string +} + +const werkzeugColumns = `id, account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url, + training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen, geeignete_zwecke, + einschraenkungen, letzte_pruefung, quelle, created_at, updated_at` + +func scanWerkzeug(row interface { + Scan(dest ...any) error +}) (Werkzeug, error) { + var w Werkzeug + err := row.Scan( + &w.ID, &w.AccountID, &w.Name, &w.Anbieter, &w.Verarbeitungsort, &w.AVVVerfuegbar, &w.AVVURL, + &w.TrainingOptOut, &w.TrainingStandard, &w.AufbewahrungTage, &w.Zertifizierungen, &w.GeeigneteZwecke, + &w.Einschraenkungen, &w.LetztePruefung, &w.Quelle, &w.CreatedAt, &w.UpdatedAt, + ) + return w, err +} + +// normalizeWerkzeugSlices ersetzt nil-Slices durch leere Slices — die +// Spalten sind TEXT[] NOT NULL, ein nil-Slice (z. B. wenn eine +// Einschränkung optional ist) käme sonst als SQL-NULL an und würde mit +// einer wenig hilfreichen Constraint-Fehlermeldung abgelehnt (derselbe +// Fall wie früher bei finding.sources). +func normalizeWerkzeugSlices(in *WerkzeugInput) { + if in.Zertifizierungen == nil { + in.Zertifizierungen = []string{} + } + if in.GeeigneteZwecke == nil { + in.GeeigneteZwecke = []string{} + } + if in.Einschraenkungen == nil { + in.Einschraenkungen = []string{} + } +} + +// CreateWerkzeug legt einen Katalogeintrag an. +func (s *Store) CreateWerkzeug(ctx context.Context, in WerkzeugInput) (Werkzeug, error) { + normalizeWerkzeugSlices(&in) + row := s.Pool.QueryRow(ctx, ` + INSERT INTO werkzeug ( + account_id, name, anbieter, verarbeitungsort, avv_verfuegbar, avv_url, + training_opt_out, training_standard, aufbewahrung_tage, zertifizierungen, + geeignete_zwecke, einschraenkungen, letzte_pruefung, quelle + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + RETURNING `+werkzeugColumns, + in.AccountID, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL, + in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen, + in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle, + ) + w, err := scanWerkzeug(row) + if err != nil { + return Werkzeug{}, fmt.Errorf("store: create werkzeug: %w", err) + } + return w, nil +} + +// UpdateWerkzeug ersetzt die Felder eines bestehenden Katalogeintrags +// (z. B. bei einer erneuten Prüfung der Zusicherungen). Werkzeug ist +// bewusst nicht append-only — anders als ein Beweisstück ist der +// Katalog ein gepflegter, sich änderender Datenbestand; eine Entscheidung +// friert den zu diesem Zeitpunkt gültigen Datensatz stattdessen separat ein. +func (s *Store) UpdateWerkzeug(ctx context.Context, id string, in WerkzeugInput) (Werkzeug, error) { + normalizeWerkzeugSlices(&in) + row := s.Pool.QueryRow(ctx, ` + UPDATE werkzeug SET + name = $2, anbieter = $3, verarbeitungsort = $4, avv_verfuegbar = $5, avv_url = $6, + training_opt_out = $7, training_standard = $8, aufbewahrung_tage = $9, + zertifizierungen = $10, geeignete_zwecke = $11, einschraenkungen = $12, + letzte_pruefung = $13, quelle = $14, updated_at = now() + WHERE id = $1 + RETURNING `+werkzeugColumns, + id, in.Name, in.Anbieter, in.Verarbeitungsort, in.AVVVerfuegbar, in.AVVURL, + in.TrainingOptOut, in.TrainingStandard, in.AufbewahrungTage, in.Zertifizierungen, + in.GeeigneteZwecke, in.Einschraenkungen, in.LetztePruefung, in.Quelle, + ) + w, err := scanWerkzeug(row) + if errors.Is(err, pgx.ErrNoRows) { + return Werkzeug{}, ErrNotFound + } + if err != nil { + return Werkzeug{}, fmt.Errorf("store: update werkzeug: %w", err) + } + return w, nil +} + +// GetWerkzeug liest einen Katalogeintrag anhand seiner ID. +func (s *Store) GetWerkzeug(ctx context.Context, id string) (Werkzeug, error) { + row := s.Pool.QueryRow(ctx, `SELECT `+werkzeugColumns+` FROM werkzeug WHERE id = $1`, id) + w, err := scanWerkzeug(row) + if errors.Is(err, pgx.ErrNoRows) { + return Werkzeug{}, ErrNotFound + } + if err != nil { + return Werkzeug{}, fmt.Errorf("store: get werkzeug: %w", err) + } + return w, nil +} + +// DeleteWerkzeug entfernt einen Katalogeintrag. +func (s *Store) DeleteWerkzeug(ctx context.Context, id string) error { + tag, err := s.Pool.Exec(ctx, `DELETE FROM werkzeug WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("store: delete werkzeug: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// ListWerkzeugeForAccount liefert den für einen Mandanten sichtbaren +// Katalog: alle zentralen Einträge, die dieser Mandant nicht gesperrt +// hat, plus seine eigenen mandantenspezifischen Ergänzungen. +func (s *Store) ListWerkzeugeForAccount(ctx context.Context, accountID string) ([]Werkzeug, error) { + rows, err := s.Pool.Query(ctx, ` + SELECT `+werkzeugColumns+` FROM werkzeug w + WHERE (w.account_id IS NULL AND NOT EXISTS ( + SELECT 1 FROM werkzeug_sperre ws WHERE ws.werkzeug_id = w.id AND ws.account_id = $1 + )) OR w.account_id = $1 + ORDER BY w.name + `, accountID) + if err != nil { + return nil, fmt.Errorf("store: list werkzeuge for account: %w", err) + } + defer rows.Close() + + var out []Werkzeug + for rows.Next() { + w, err := scanWerkzeug(rows) + if err != nil { + return nil, fmt.Errorf("store: scan werkzeug: %w", err) + } + out = append(out, w) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: list werkzeuge for account: %w", err) + } + return out, nil +} + +// ListZentraleWerkzeuge liefert den vollständigen zentralen Katalog +// (account_id IS NULL) — für den Admin-Bereich, unabhängig von +// Mandanten-Sperrungen. +func (s *Store) ListZentraleWerkzeuge(ctx context.Context) ([]Werkzeug, error) { + rows, err := s.Pool.Query(ctx, ` + SELECT `+werkzeugColumns+` FROM werkzeug WHERE account_id IS NULL ORDER BY name + `) + if err != nil { + return nil, fmt.Errorf("store: list zentrale werkzeuge: %w", err) + } + defer rows.Close() + + var out []Werkzeug + for rows.Next() { + w, err := scanWerkzeug(rows) + if err != nil { + return nil, fmt.Errorf("store: scan werkzeug: %w", err) + } + out = append(out, w) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: list zentrale werkzeuge: %w", err) + } + return out, nil +} + +// CurrentKatalogVersion liefert eine reproduzierbare Kennung des +// aktuellen Katalogzustands (Anzahl Einträge + letzte Änderung) — wird +// in jeder Bewertung/Entscheidung eingefroren, damit im Audit +// nachvollziehbar bleibt, mit welchem Katalogstand ein Vorschlag +// erzeugt wurde. +func (s *Store) CurrentKatalogVersion(ctx context.Context) (string, error) { + var count int + var lastUpdate time.Time + err := s.Pool.QueryRow(ctx, ` + SELECT count(*), COALESCE(MAX(updated_at), 'epoch'::timestamptz) FROM werkzeug + `).Scan(&count, &lastUpdate) + if err != nil { + return "", fmt.Errorf("store: current katalog version: %w", err) + } + return fmt.Sprintf("%d-%d", count, lastUpdate.Unix()), nil +} + +// WerkzeugSperre ist die Sperrung eines (auch zentralen) Katalogeintrags +// durch einen einzelnen Mandanten — der zentrale Katalog selbst bleibt +// dabei unverändert. +type WerkzeugSperre struct { + ID string + AccountID string + WerkzeugID string + Grund string + GesperrtAm time.Time +} + +// CreateWerkzeugSperre sperrt ein Werkzeug für einen Mandanten. +func (s *Store) CreateWerkzeugSperre(ctx context.Context, accountID, werkzeugID, grund string) (WerkzeugSperre, error) { + var sp WerkzeugSperre + err := s.Pool.QueryRow(ctx, ` + INSERT INTO werkzeug_sperre (account_id, werkzeug_id, grund) + VALUES ($1, $2, $3) + RETURNING id, account_id, werkzeug_id, grund, gesperrt_am + `, accountID, werkzeugID, grund).Scan(&sp.ID, &sp.AccountID, &sp.WerkzeugID, &sp.Grund, &sp.GesperrtAm) + if err != nil { + return WerkzeugSperre{}, fmt.Errorf("store: create werkzeug sperre: %w", err) + } + return sp, nil +} + +// DeleteWerkzeugSperre hebt eine Sperrung wieder auf. +func (s *Store) DeleteWerkzeugSperre(ctx context.Context, accountID, werkzeugID string) error { + tag, err := s.Pool.Exec(ctx, ` + DELETE FROM werkzeug_sperre WHERE account_id = $1 AND werkzeug_id = $2 + `, accountID, werkzeugID) + if err != nil { + return fmt.Errorf("store: delete werkzeug sperre: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// ListWerkzeugSperrenForAccount liefert alle Sperrungen eines Mandanten. +func (s *Store) ListWerkzeugSperrenForAccount(ctx context.Context, accountID string) ([]WerkzeugSperre, error) { + rows, err := s.Pool.Query(ctx, ` + SELECT id, account_id, werkzeug_id, grund, gesperrt_am + FROM werkzeug_sperre WHERE account_id = $1 ORDER BY gesperrt_am DESC + `, accountID) + if err != nil { + return nil, fmt.Errorf("store: list werkzeug sperren: %w", err) + } + defer rows.Close() + + var out []WerkzeugSperre + for rows.Next() { + var sp WerkzeugSperre + if err := rows.Scan(&sp.ID, &sp.AccountID, &sp.WerkzeugID, &sp.Grund, &sp.GesperrtAm); err != nil { + return nil, fmt.Errorf("store: scan werkzeug sperre: %w", err) + } + out = append(out, sp) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: list werkzeug sperren: %w", err) + } + return out, nil +} diff --git a/internal/store/werkzeug_test.go b/internal/store/werkzeug_test.go new file mode 100644 index 0000000..69d167a --- /dev/null +++ b/internal/store/werkzeug_test.go @@ -0,0 +1,219 @@ +package store_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/netcell-it/deklarix/internal/store" +) + +func testWerkzeugInput(accountID *string, name string) store.WerkzeugInput { + return store.WerkzeugInput{ + AccountID: accountID, + Name: name, + Anbieter: "Beispiel-Anbieter GmbH", + Verarbeitungsort: "EU", + AVVVerfuegbar: true, + AVVURL: "https://beispiel.example/avv", + TrainingOptOut: true, + TrainingStandard: true, + AufbewahrungTage: 30, + Zertifizierungen: []string{"ISO 27001"}, + GeeigneteZwecke: []string{"Textgenerierung"}, + Einschraenkungen: nil, + LetztePruefung: time.Now().Add(-24 * time.Hour).Truncate(time.Millisecond), + Quelle: "https://beispiel.example/beleg", + } +} + +func TestWerkzeugCRUD(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + w, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "Zentrales Werkzeug")) + if err != nil { + t.Fatalf("CreateWerkzeug: %v", err) + } + if w.AccountID != nil { + t.Fatalf("expected AccountID nil fuer zentralen Katalogeintrag, got %v", w.AccountID) + } + if len(w.Zertifizierungen) != 1 || w.Zertifizierungen[0] != "ISO 27001" { + t.Fatalf("Zertifizierungen = %v, want [ISO 27001]", w.Zertifizierungen) + } + + got, err := s.GetWerkzeug(ctx, w.ID) + if err != nil { + t.Fatalf("GetWerkzeug: %v", err) + } + if got.Name != "Zentrales Werkzeug" { + t.Fatalf("Name = %q, want Zentrales Werkzeug", got.Name) + } + + updateInput := testWerkzeugInput(nil, "Zentrales Werkzeug (aktualisiert)") + updateInput.AufbewahrungTage = 14 + updated, err := s.UpdateWerkzeug(ctx, w.ID, updateInput) + if err != nil { + t.Fatalf("UpdateWerkzeug: %v", err) + } + if updated.Name != "Zentrales Werkzeug (aktualisiert)" || updated.AufbewahrungTage != 14 { + t.Fatalf("UpdateWerkzeug = %+v, unerwartete Werte", updated) + } + + if err := s.DeleteWerkzeug(ctx, w.ID); err != nil { + t.Fatalf("DeleteWerkzeug: %v", err) + } + if _, err := s.GetWerkzeug(ctx, w.ID); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("err nach Delete = %v, want store.ErrNotFound", err) + } +} + +func TestUpdateWerkzeugNotFound(t *testing.T) { + s := openTestStore(t) + _, err := s.UpdateWerkzeug(context.Background(), "00000000-0000-0000-0000-000000000000", testWerkzeugInput(nil, "x")) + if !errors.Is(err, store.ErrNotFound) { + t.Fatalf("err = %v, want store.ErrNotFound", err) + } +} + +func TestListWerkzeugeForAccountIncludesCentralAndOwnEntries(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + accID := testAccountID(t, s) + + zentral, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "Zentral A")) + if err != nil { + t.Fatalf("CreateWerkzeug (zentral): %v", err) + } + eigenes, err := s.CreateWerkzeug(ctx, testWerkzeugInput(&accID, "Mandanten-eigenes Werkzeug")) + if err != nil { + t.Fatalf("CreateWerkzeug (eigenes): %v", err) + } + + // Anderer Mandant darf das eigene Werkzeug nicht sehen. + otherAcc := testAccountID(t, s) + otherList, err := s.ListWerkzeugeForAccount(ctx, otherAcc) + if err != nil { + t.Fatalf("ListWerkzeugeForAccount (other): %v", err) + } + for _, w := range otherList { + if w.ID == eigenes.ID { + t.Fatal("expected the other account's own werkzeug to stay isolated") + } + } + + list, err := s.ListWerkzeugeForAccount(ctx, accID) + if err != nil { + t.Fatalf("ListWerkzeugeForAccount: %v", err) + } + byID := map[string]bool{} + for _, w := range list { + byID[w.ID] = true + } + if !byID[zentral.ID] || !byID[eigenes.ID] { + t.Fatalf("expected both the central and the own werkzeug, got %+v", list) + } +} + +func TestWerkzeugSperreHidesCentralEntry(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + accID := testAccountID(t, s) + + zentral, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "Zu sperrendes Werkzeug")) + if err != nil { + t.Fatalf("CreateWerkzeug: %v", err) + } + + if _, err := s.CreateWerkzeugSperre(ctx, accID, zentral.ID, "vom Mandanten intern verboten"); err != nil { + t.Fatalf("CreateWerkzeugSperre: %v", err) + } + + list, err := s.ListWerkzeugeForAccount(ctx, accID) + if err != nil { + t.Fatalf("ListWerkzeugeForAccount: %v", err) + } + for _, w := range list { + if w.ID == zentral.ID { + t.Fatal("expected the sperred central werkzeug to be hidden for this account") + } + } + + // Der zentrale Katalog selbst bleibt fuer andere Mandanten sichtbar. + otherAcc := testAccountID(t, s) + otherList, err := s.ListWerkzeugeForAccount(ctx, otherAcc) + if err != nil { + t.Fatalf("ListWerkzeugeForAccount (other): %v", err) + } + found := false + for _, w := range otherList { + if w.ID == zentral.ID { + found = true + } + } + if !found { + t.Fatal("expected the central werkzeug to remain visible for a different account") + } + + if err := s.DeleteWerkzeugSperre(ctx, accID, zentral.ID); err != nil { + t.Fatalf("DeleteWerkzeugSperre: %v", err) + } + listAfter, err := s.ListWerkzeugeForAccount(ctx, accID) + if err != nil { + t.Fatalf("ListWerkzeugeForAccount nach Entsperren: %v", err) + } + found = false + for _, w := range listAfter { + if w.ID == zentral.ID { + found = true + } + } + if !found { + t.Fatal("expected the werkzeug to be visible again after DeleteWerkzeugSperre") + } +} + +func TestCurrentKatalogVersionChangesOnCreate(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + + before, err := s.CurrentKatalogVersion(ctx) + if err != nil { + t.Fatalf("CurrentKatalogVersion: %v", err) + } + if _, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "Version-Test-Werkzeug")); err != nil { + t.Fatalf("CreateWerkzeug: %v", err) + } + after, err := s.CurrentKatalogVersion(ctx) + if err != nil { + t.Fatalf("CurrentKatalogVersion (2): %v", err) + } + if before == after { + t.Fatalf("expected CurrentKatalogVersion to change after adding a werkzeug, got %q both times", before) + } +} + +func TestListZentraleWerkzeugeExcludesMandantenEigene(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + accID := testAccountID(t, s) + + if _, err := s.CreateWerkzeug(ctx, testWerkzeugInput(nil, "Zentral B")); err != nil { + t.Fatalf("CreateWerkzeug (zentral): %v", err) + } + eigenes, err := s.CreateWerkzeug(ctx, testWerkzeugInput(&accID, "Mandanten-eigenes B")) + if err != nil { + t.Fatalf("CreateWerkzeug (eigenes): %v", err) + } + + list, err := s.ListZentraleWerkzeuge(ctx) + if err != nil { + t.Fatalf("ListZentraleWerkzeuge: %v", err) + } + for _, w := range list { + if w.ID == eigenes.ID { + t.Fatal("expected ListZentraleWerkzeuge to exclude mandantenspezifische Eintraege") + } + } +} diff --git a/internal/web/admin_handlers.go b/internal/web/admin_handlers.go deleted file mode 100644 index a4cf1ff..0000000 --- a/internal/web/admin_handlers.go +++ /dev/null @@ -1,217 +0,0 @@ -package web - -import ( - "net/http" -) - -type kanzleiListItem struct { - Name string -} - -type kanzleiListData struct { - Title string - Kanzleien []kanzleiListItem -} - -// handlePublicKanzleiList zeigt das öffentliche, kostenlose Kanzlei- -// Verzeichnis — keine Anmeldung nötig. Bewusst nur Name, kein Kontakt- -// Button/Routing (siehe CLAUDE.md, § 49b Abs. 3 BRAO): Nutzer wählen -// selbst, es gibt keine Vermittlung. -func (s *Server) handlePublicKanzleiList(w http.ResponseWriter, r *http.Request) { - accounts, err := s.store.ListVerifiedKanzleien(r.Context()) - if err != nil { - http.Error(w, "Verzeichnis konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError) - return - } - data := kanzleiListData{Title: "Kanzlei-Verzeichnis"} - for _, a := range accounts { - data.Kanzleien = append(data.Kanzleien, kanzleiListItem{Name: a.Name}) - } - if err := s.templates.ExecuteTemplate(w, "kanzleien", data); err != nil { - http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) - } -} - -type adminDashboardData struct { - Title string - Nav navData - AccountCount int - UnverifiedCount int - RecentAuditCount int -} - -// handleAdminDashboard zeigt eine kurze Übersicht als Einstieg in den -// Admin-Bereich. -func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - accounts, err := s.store.ListAccounts(ctx) - if err != nil { - http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) - return - } - unverified := 0 - for _, a := range accounts { - if !a.Verified { - unverified++ - } - } - auditLog, err := s.store.ListAuditLog(ctx, 5) - if err != nil { - http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError) - return - } - - data := adminDashboardData{ - Title: "Admin", Nav: navFor(r), AccountCount: len(accounts), UnverifiedCount: unverified, RecentAuditCount: len(auditLog), - } - if err := s.templates.ExecuteTemplate(w, "admin-dashboard", data); err != nil { - http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) - } -} - -type adminAccountListItem struct { - ID string - Name string - Verified bool - CreatedAt string -} - -type adminAccountListData struct { - Title string - Nav navData - Accounts []adminAccountListItem -} - -// handleAdminAccountList listet alle Mandanten der Plattform. -func (s *Server) handleAdminAccountList(w http.ResponseWriter, r *http.Request) { - accounts, err := s.store.ListAccounts(r.Context()) - if err != nil { - http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) - return - } - data := adminAccountListData{Title: "Accounts", Nav: navFor(r)} - for _, a := range accounts { - data.Accounts = append(data.Accounts, adminAccountListItem{ - ID: a.ID, Name: a.Name, Verified: a.Verified, CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"), - }) - } - if err := s.templates.ExecuteTemplate(w, "admin-accounts", data); err != nil { - http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) - } -} - -type adminUserView struct { - Email string - Role string -} - -type adminAccountDetailData struct { - Title string - Nav navData - AccountID string - Name string - Verified bool - HasKanzlei bool - Users []adminUserView - CreatedAt string -} - -// handleAdminAccountDetail zeigt einen Mandanten mit seinen Logins und — -// falls mindestens ein Login die Rolle "kanzlei" hat — der Möglichkeit, -// die Freigabe fürs öffentliche Kanzlei-Verzeichnis zu setzen. -func (s *Server) handleAdminAccountDetail(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - acc, err := s.store.GetAccount(ctx, r.PathValue("id")) - if err != nil { - http.Error(w, "Account nicht gefunden", http.StatusNotFound) - return - } - users, err := s.store.ListUsersForAccount(ctx, acc.ID) - if err != nil { - http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) - return - } - - data := adminAccountDetailData{ - Title: "Account", Nav: navFor(r), AccountID: acc.ID, Name: acc.Name, Verified: acc.Verified, - CreatedAt: acc.CreatedAt.Format("02.01.2006 15:04"), - } - for _, u := range users { - data.Users = append(data.Users, adminUserView{Email: u.Email, Role: u.Role}) - if u.Role == "kanzlei" { - data.HasKanzlei = true - } - } - if err := s.templates.ExecuteTemplate(w, "admin-account-detail", data); err != nil { - http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) - } -} - -// handleAdminSetVerified schaltet die Freigabe fürs Kanzlei-Verzeichnis -// um und protokolliert die Aktion im Audit-Log. -func (s *Server) handleAdminSetVerified(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - http.Error(w, "ungültiges Formular", http.StatusBadRequest) - return - } - accountID := r.PathValue("id") - ctx := r.Context() - - if _, err := s.store.GetAccount(ctx, accountID); err != nil { - http.Error(w, "Account nicht gefunden", http.StatusNotFound) - return - } - verified := r.FormValue("verified") == "true" - - updated, err := s.store.SetAccountVerified(ctx, accountID, verified) - if err != nil { - http.Error(w, "Freigabe konnte nicht gesetzt werden: "+err.Error(), http.StatusInternalServerError) - return - } - - action := "account.unverified" - details := "Kanzlei-Verzeichnis: Freigabe entzogen" - if updated.Verified { - action = "account.verified" - details = "Kanzlei-Verzeichnis: Freigabe erteilt" - } - if _, err := s.store.CreateAuditEntry(ctx, currentUser(r).ID, action, "account", accountID, details); err != nil { - http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError) - return - } - - http.Redirect(w, r, "/admin/accounts/"+accountID, http.StatusSeeOther) -} - -type adminAuditEntryView struct { - CreatedAt string - Action string - TargetType string - TargetID string - Details string -} - -type adminAuditLogData struct { - Title string - Nav navData - Entries []adminAuditEntryView -} - -// handleAdminAuditLog zeigt das Protokoll der Admin-Aktionen. -func (s *Server) handleAdminAuditLog(w http.ResponseWriter, r *http.Request) { - entries, err := s.store.ListAuditLog(r.Context(), 200) - if err != nil { - http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError) - return - } - data := adminAuditLogData{Title: "Audit-Log", Nav: navFor(r)} - for _, e := range entries { - data.Entries = append(data.Entries, adminAuditEntryView{ - CreatedAt: e.CreatedAt.Format("02.01.2006 15:04:05"), Action: e.Action, - TargetType: e.TargetType, TargetID: e.TargetID, Details: e.Details, - }) - } - if err := s.templates.ExecuteTemplate(w, "admin-audit-log", data); err != nil { - http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) - } -} diff --git a/internal/web/admin_handlers_test.go b/internal/web/admin_handlers_test.go deleted file mode 100644 index b831d20..0000000 --- a/internal/web/admin_handlers_test.go +++ /dev/null @@ -1,198 +0,0 @@ -package web_test - -import ( - "net/http" - "net/url" - "strings" - "testing" - - "github.com/netcell-it/deklarix/internal/rules" -) - -func TestAdminRoutesRejectNonAdminWith404(t *testing.T) { - s, _, cookie := newAuthedTestServer(t, fakeExtractor{}) - - for _, path := range []string{"/admin", "/admin/accounts", "/admin/audit-log"} { - resp := getWithCookie(t, s, cookie, path) - if resp.Code != http.StatusNotFound { - t.Errorf("GET %s status = %d, want 404 for a non-admin user", path, resp.Code) - } - } -} - -func TestAdminRoutesRedirectToLoginWithoutSession(t *testing.T) { - s, _, _ := newAuthedTestServer(t, fakeExtractor{}) - - resp := getWithCookie(t, s, nil, "/admin") - if resp.Code != http.StatusSeeOther { - t.Fatalf("status = %d, want 303 redirect to /login", resp.Code) - } -} - -func TestAdminDashboardAccessibleForAdmin(t *testing.T) { - fs := newFakeStore() - s := newServer(t, fakeExtractor{}, fs) - adminCookie := seedAccountWithRole(t, fs, "Deklarix Admin", "admin@example.com", "admin") - - resp := getWithCookie(t, s, adminCookie, "/admin") - if resp.Code != http.StatusOK { - t.Fatalf("status = %d, want 200, body: %s", resp.Code, resp.Body.String()) - } -} - -// TestNavShowsAdminLinkOnlyForAdmins deckt genau den gemeldeten Fall ab: -// nach der Anmeldung als Admin landet man auf der normalen Startseite -// (jeder Nutzer hat einen Account+Login, auch ein Admin) — ohne einen -// sichtbaren Weg zu /admin wäre der Admin-Bereich für einen Admin, der -// die URL nicht auswendig kennt, praktisch unerreichbar. -func TestNavShowsAdminLinkOnlyForAdmins(t *testing.T) { - fs := newFakeStore() - s := newServer(t, fakeExtractor{}, fs) - adminCookie := seedAccountWithRole(t, fs, "Deklarix Admin", "admin@example.com", "admin") - tenantCookie := seedAccount(t, fs, "Mandant", "mandant@example.com") - - adminResp := getWithCookie(t, s, adminCookie, "/") - if !strings.Contains(adminResp.Body.String(), `href="/admin"`) { - t.Errorf("expected an /admin nav link for an admin user, got: %s", adminResp.Body.String()) - } - - tenantResp := getWithCookie(t, s, tenantCookie, "/") - if strings.Contains(tenantResp.Body.String(), `href="/admin"`) { - t.Errorf("expected no /admin nav link for a non-admin user, got: %s", tenantResp.Body.String()) - } -} - -func TestAdminAccountListShowsAllAccountsAcrossTenants(t *testing.T) { - fs := newFakeStore() - s := newServer(t, fakeExtractor{}, fs) - adminCookie := seedAccountWithRole(t, fs, "Deklarix Admin", "admin@example.com", "admin") - seedAccount(t, fs, "Mandant A", "a@example.com") - seedAccount(t, fs, "Mandant B", "b@example.com") - - resp := getWithCookie(t, s, adminCookie, "/admin/accounts") - if resp.Code != http.StatusOK { - t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) - } - body := resp.Body.String() - for _, want := range []string{"Mandant A", "Mandant B", "Deklarix Admin"} { - if !strings.Contains(body, want) { - t.Errorf("expected %q in the admin account list, got: %s", want, body) - } - } -} - -func TestAdminAccountDetailShowsUsersAndVerifyToggleOnlyForKanzlei(t *testing.T) { - fs := newFakeStore() - s := newServer(t, fakeExtractor{}, fs) - adminCookie := seedAccountWithRole(t, fs, "Deklarix Admin", "admin@example.com", "admin") - - creatorCookie := seedAccount(t, fs, "Nur Creator", "creator@example.com") - _ = creatorCookie - var creatorAccID string - for id, acc := range fs.accounts { - if acc.Name == "Nur Creator" { - creatorAccID = id - } - } - - kanzleiCookie := seedAccountWithRole(t, fs, "Kanzlei Musterfrau", "kanzlei@example.com", "kanzlei") - _ = kanzleiCookie - var kanzleiAccID string - for id, acc := range fs.accounts { - if acc.Name == "Kanzlei Musterfrau" { - kanzleiAccID = id - } - } - - creatorResp := getWithCookie(t, s, adminCookie, "/admin/accounts/"+creatorAccID) - if creatorResp.Code != http.StatusOK { - t.Fatalf("status = %d", creatorResp.Code) - } - if strings.Contains(creatorResp.Body.String(), "verifizieren") { - t.Errorf("expected no verify action for a non-kanzlei account, got: %s", creatorResp.Body.String()) - } - - kanzleiResp := getWithCookie(t, s, adminCookie, "/admin/accounts/"+kanzleiAccID) - if kanzleiResp.Code != http.StatusOK { - t.Fatalf("status = %d", kanzleiResp.Code) - } - if !strings.Contains(kanzleiResp.Body.String(), "kanzlei@example.com") { - t.Errorf("expected the kanzlei user's email on the account detail page, got: %s", kanzleiResp.Body.String()) - } - if !strings.Contains(kanzleiResp.Body.String(), "/admin/accounts/"+kanzleiAccID+"/verifizieren") { - t.Errorf("expected a verify action for a kanzlei account, got: %s", kanzleiResp.Body.String()) - } -} - -func TestAdminVerifyAddsAccountToPublicDirectoryAndAuditLog(t *testing.T) { - fs := newFakeStore() - s := newServer(t, fakeExtractor{}, fs) - adminCookie := seedAccountWithRole(t, fs, "Deklarix Admin", "admin@example.com", "admin") - seedAccountWithRole(t, fs, "Kanzlei Musterfrau", "kanzlei@example.com", "kanzlei") - - var kanzleiAccID string - for id, acc := range fs.accounts { - if acc.Name == "Kanzlei Musterfrau" { - kanzleiAccID = id - } - } - - // Vor der Freigabe taucht die Kanzlei nicht im oeffentlichen - // Verzeichnis auf. - before := getWithCookie(t, s, nil, "/kanzleien") - if strings.Contains(before.Body.String(), "Kanzlei Musterfrau") { - t.Fatalf("kanzlei should not be public before verification, got: %s", before.Body.String()) - } - - verifyResp := postForm(t, s, adminCookie, "/admin/accounts/"+kanzleiAccID+"/verifizieren", url.Values{"verified": {"true"}}) - if verifyResp.Code != http.StatusSeeOther { - t.Fatalf("verify status = %d, want 303, body: %s", verifyResp.Code, verifyResp.Body.String()) - } - - after := getWithCookie(t, s, nil, "/kanzleien") - if !strings.Contains(after.Body.String(), "Kanzlei Musterfrau") { - t.Fatalf("expected the kanzlei to be listed publicly after verification, got: %s", after.Body.String()) - } - - if len(fs.auditLog) != 1 { - t.Fatalf("expected exactly one audit entry, got %d", len(fs.auditLog)) - } - if fs.auditLog[0].Action != "account.verified" || fs.auditLog[0].TargetID != kanzleiAccID { - t.Errorf("unexpected audit entry: %+v", fs.auditLog[0]) - } - - auditPageResp := getWithCookie(t, s, adminCookie, "/admin/audit-log") - if !strings.Contains(auditPageResp.Body.String(), "account.verified") { - t.Errorf("expected the audit entry on the audit log page, got: %s", auditPageResp.Body.String()) - } -} - -func TestAdminVerifyRejectsNonAdmin(t *testing.T) { - fs := newFakeStore() - s := newServer(t, fakeExtractor{}, fs) - tenantCookie := seedAccountWithRole(t, fs, "Kanzlei Musterfrau", "kanzlei@example.com", "kanzlei") - - var kanzleiAccID string - for id, acc := range fs.accounts { - if acc.Name == "Kanzlei Musterfrau" { - kanzleiAccID = id - } - } - - resp := postForm(t, s, tenantCookie, "/admin/accounts/"+kanzleiAccID+"/verifizieren", url.Values{"verified": {"true"}}) - if resp.Code != http.StatusNotFound { - t.Fatalf("status = %d, want 404 for a non-admin actor", resp.Code) - } - if fs.accounts[kanzleiAccID].Verified { - t.Fatal("account should not have been verified by a non-admin request") - } -} - -func TestPublicKanzleiDirectoryRequiresNoLogin(t *testing.T) { - s, _, _ := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{Platform: "instagram"}}) - - req := getWithCookie(t, s, nil, "/kanzleien") - if req.Code != http.StatusOK { - t.Fatalf("status = %d, want 200 without any session", req.Code) - } -} diff --git a/internal/web/archive_handlers.go b/internal/web/archive_handlers.go deleted file mode 100644 index dba0cab..0000000 --- a/internal/web/archive_handlers.go +++ /dev/null @@ -1,306 +0,0 @@ -package web - -import ( - "net/http" - "time" - - "github.com/netcell-it/deklarix/internal/store" -) - -type submissionListItem struct { - ID string - Platform string - PostType string - Status string - CreatedAt string - FindingCount int - HighestSeverity string -} - -type submissionListData struct { - Title string - Nav navData - Submissions []submissionListItem -} - -// handleSubmissionList zeigt die Archiv-Übersicht: alle Beiträge des -// angemeldeten Mandanten mit Kurzfassung der Findings. -func (s *Server) handleSubmissionList(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - summaries, err := s.store.ListSubmissionsForAccount(ctx, currentUser(r).AccountID) - if err != nil { - http.Error(w, "Beiträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) - return - } - - data := submissionListData{Title: "Beiträge", Nav: navFor(r)} - for _, sum := range summaries { - data.Submissions = append(data.Submissions, submissionListItem{ - ID: sum.ID, Platform: sum.Platform, PostType: sum.PostType, Status: sum.Status, - CreatedAt: sum.CreatedAt.Format("02.01.2006 15:04"), - FindingCount: sum.FindingCount, HighestSeverity: sum.HighestSeverity, - }) - } - - if err := s.templates.ExecuteTemplate(w, "beitraege", data); err != nil { - http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) - } -} - -type participantView struct { - ID string - Role string - Name string - Vorgegeben bool - Freigegeben bool - ApprovedAt string // leer, wenn noch nicht freigegeben -} - -type insightsAssetView struct { - CreatedAt string - SHA256 string -} - -type submissionDetailData struct { - Title string - Nav navData - SubmissionID string - Platform string - PostType string - Caption string - Status string - CreatedAt string - CanArchive bool - IsPublished bool - DossierURL string - Findings []findingView - Participants []participantView - InsightsReminder insightsReminder - InsightsAssets []insightsAssetView -} - -// loadOwnSubmission lädt eine Submission und prüft die Mandantenzugehörigkeit. -// Wie in handleArchive/handleDossierDownload (siehe handlers.go): ein Beitrag -// eines anderen Accounts wird wie ein nicht existierender behandelt. -func (s *Server) loadOwnSubmission(r *http.Request, id string) (store.Submission, error) { - sub, err := s.store.GetSubmission(r.Context(), id) - if err != nil { - return store.Submission{}, err - } - if sub.AccountID != currentUser(r).AccountID { - return store.Submission{}, store.ErrNotFound - } - return sub, nil -} - -func toParticipantViews(participants []store.Participant) []participantView { - views := make([]participantView, len(participants)) - for i, p := range participants { - v := participantView{ID: p.ID, Role: p.Role, Name: p.Name, Vorgegeben: p.Vorgegeben, Freigegeben: p.Freigegeben} - if p.ApprovedAt != nil { - v.ApprovedAt = p.ApprovedAt.Format("02.01.2006 15:04") - } - views[i] = v - } - return views -} - -// handleSubmissionDetail zeigt einen einzelnen Beitrag: Fakten, Findings, -// Verantwortungsmatrix (Beteiligte) inklusive Verwaltung. -func (s *Server) handleSubmissionDetail(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - sub, err := s.loadOwnSubmission(r, r.PathValue("id")) - if err != nil { - http.Error(w, "Beitrag nicht gefunden", http.StatusNotFound) - return - } - - storeFindings, err := s.store.ListCurrentFindings(ctx, sub.ID) - if err != nil { - http.Error(w, "Findings konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) - return - } - findings := make([]findingView, len(storeFindings)) - for i, f := range storeFindings { - findings[i] = findingView{ - RuleID: f.RuleID, Version: f.RuleVersion, Severity: f.Severity, - Title: f.Title, Fix: f.Fix, Sources: f.Sources, - } - } - - participants, err := s.store.ListParticipants(ctx, sub.ID) - if err != nil { - http.Error(w, "Beteiligte konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) - return - } - - assets, err := s.store.ListAssetsForSubmission(ctx, sub.ID) - if err != nil { - http.Error(w, "Assets konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) - return - } - var insightsAssets []insightsAssetView - hasInsightsAsset := false - for _, a := range assets { - if a.Purpose == "insights" { - hasInsightsAsset = true - insightsAssets = append(insightsAssets, insightsAssetView{ - CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"), SHA256: a.SHA256, - }) - } - } - - var reminder insightsReminder - if sub.Status == "published" { - if pkg, err := s.store.GetLatestEvidencePackage(ctx, sub.ID); err == nil { - reminder = computeInsightsReminder(sub.PostType, pkg.CreatedAt, time.Now(), hasInsightsAsset) - } - } - - data := submissionDetailData{ - Title: "Beitrag", Nav: navFor(r), SubmissionID: sub.ID, Platform: sub.Platform, PostType: sub.PostType, - Caption: sub.Caption, Status: sub.Status, CreatedAt: sub.CreatedAt.Format("02.01.2006 15:04"), - CanArchive: sub.Status == "checked", IsPublished: sub.Status == "published", - DossierURL: "/dossier/" + sub.ID, Findings: findings, Participants: toParticipantViews(participants), - InsightsReminder: reminder, InsightsAssets: insightsAssets, - } - if err := s.templates.ExecuteTemplate(w, "beitrag", data); err != nil { - http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) - } -} - -// handleAddInsightsAsset speichert einen zusätzlichen Screenshot der -// Kennzahlen (Insights) eines bereits veröffentlichten Beitrags — siehe -// insightsReminder. Nutzt dieselbe Validierung wie das initiale -// Standbild beim Prüfen (readUploadedAsset/storeAsset), nur mit -// purpose="insights" statt "initial" und als eigener, jederzeit -// wiederholbarer Upload statt einmalig beim Anlegen der Submission. -func (s *Server) handleAddInsightsAsset(w http.ResponseWriter, r *http.Request) { - sub, err := s.loadOwnSubmission(r, r.PathValue("id")) - if err != nil { - http.Error(w, "Beitrag nicht gefunden", http.StatusNotFound) - return - } - - r.Body = http.MaxBytesReader(w, r.Body, maxAssetSize+(1<<20)) - if err := r.ParseMultipartForm(1 << 20); err != nil { - http.Error(w, "ungültiges Formular (evtl. zu groß)", http.StatusBadRequest) - return - } - asset, err := s.readUploadedAsset(r, "insights_standbild") - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if asset == nil { - http.Error(w, "kein Standbild hochgeladen", http.StatusBadRequest) - return - } - if err := s.storeAsset(r.Context(), sub.ID, "insights", asset); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - http.Redirect(w, r, "/beitraege/"+sub.ID, http.StatusSeeOther) -} - -type participantListData struct { - SubmissionID string - Participants []participantView -} - -// renderParticipantList rendert das Beteiligten-Fragment neu — Ziel für -// htmx-Swaps nach Hinzufügen/Ändern/Löschen, damit die Seite nicht neu -// geladen werden muss. -func (s *Server) renderParticipantList(w http.ResponseWriter, r *http.Request, submissionID string) { - participants, err := s.store.ListParticipants(r.Context(), submissionID) - if err != nil { - http.Error(w, "Beteiligte konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) - return - } - data := participantListData{SubmissionID: submissionID, Participants: toParticipantViews(participants)} - if err := s.templates.ExecuteTemplate(w, "beteiligte-liste", data); err != nil { - http.Error(w, "Liste konnte nicht gerendert werden", http.StatusInternalServerError) - } -} - -// handleAddParticipant fügt einen Beteiligten zu einem Beitrag hinzu. -func (s *Server) handleAddParticipant(w http.ResponseWriter, r *http.Request) { - sub, err := s.loadOwnSubmission(r, r.PathValue("id")) - if err != nil { - http.Error(w, "Beitrag nicht gefunden", http.StatusNotFound) - return - } - if err := r.ParseForm(); err != nil { - http.Error(w, "ungültiges Formular", http.StatusBadRequest) - return - } - role := r.FormValue("role") - name := r.FormValue("name") - if role == "" || name == "" { - http.Error(w, "Rolle und Name sind Pflichtfelder", http.StatusBadRequest) - return - } - - if _, err := s.store.CreateParticipant(r.Context(), sub.ID, role, name, false, false); err != nil { - http.Error(w, "Beteiligter konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError) - return - } - s.renderParticipantList(w, r, sub.ID) -} - -// participantBelongsToOwnSubmission prüft, dass der Beteiligte tatsächlich -// zu einem Beitrag des angemeldeten Mandanten gehört — sonst könnte ein -// fremder Mandant über eine erratene participant_id einen Beteiligten -// eines anderen Accounts ändern oder löschen. -func (s *Server) participantBelongsToOwnSubmission(r *http.Request, submissionIDFromURL, participantID string) (store.Participant, error) { - p, err := s.store.GetParticipant(r.Context(), participantID) - if err != nil { - return store.Participant{}, err - } - if p.SubmissionID != submissionIDFromURL { - return store.Participant{}, store.ErrNotFound - } - if _, err := s.loadOwnSubmission(r, p.SubmissionID); err != nil { - return store.Participant{}, err - } - return p, nil -} - -// handleUpdateParticipant setzt vorgegeben/freigegeben für einen Beteiligten. -func (s *Server) handleUpdateParticipant(w http.ResponseWriter, r *http.Request) { - submissionID := r.PathValue("id") - p, err := s.participantBelongsToOwnSubmission(r, submissionID, r.PathValue("pid")) - if err != nil { - http.Error(w, "Beteiligter nicht gefunden", http.StatusNotFound) - return - } - if err := r.ParseForm(); err != nil { - http.Error(w, "ungültiges Formular", http.StatusBadRequest) - return - } - vorgegeben := r.FormValue("vorgegeben") == "on" - freigegeben := r.FormValue("freigegeben") == "on" - - if _, err := s.store.UpdateParticipant(r.Context(), p.ID, vorgegeben, freigegeben); err != nil { - http.Error(w, "Beteiligter konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError) - return - } - s.renderParticipantList(w, r, submissionID) -} - -// handleDeleteParticipant entfernt einen Beteiligten. -func (s *Server) handleDeleteParticipant(w http.ResponseWriter, r *http.Request) { - submissionID := r.PathValue("id") - p, err := s.participantBelongsToOwnSubmission(r, submissionID, r.PathValue("pid")) - if err != nil { - http.Error(w, "Beteiligter nicht gefunden", http.StatusNotFound) - return - } - - if err := s.store.DeleteParticipant(r.Context(), p.ID); err != nil { - http.Error(w, "Beteiligter konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError) - return - } - s.renderParticipantList(w, r, submissionID) -} diff --git a/internal/web/archive_handlers_test.go b/internal/web/archive_handlers_test.go deleted file mode 100644 index 56e7a10..0000000 --- a/internal/web/archive_handlers_test.go +++ /dev/null @@ -1,202 +0,0 @@ -package web_test - -import ( - "context" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - - "github.com/netcell-it/deklarix/internal/rules" -) - -// checkAndReturnSubID führt eine Pre-Publish-Prüfung durch und liefert die -// dabei angelegte submission_id — Hilfsfunktion für Tests, die einen -// bereits existierenden Beitrag brauchen, ohne den ganzen Ablauf jedes Mal -// auszuschreiben. -func checkAndReturnSubID(t *testing.T, s interface { - ServeHTTP(w http.ResponseWriter, r *http.Request) -}, cookie *http.Cookie) string { - t.Helper() - form := checkForm() - req := httptest.NewRequest(http.MethodPost, "/pruefen", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.AddCookie(cookie) - w := httptest.NewRecorder() - s.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("check status = %d, body: %s", w.Code, w.Body.String()) - } - body := w.Body.String() - const marker = `name="submission_id" value="` - idx := strings.Index(body, marker) - if idx == -1 { - t.Fatalf("expected a submission_id field in the result, got: %s", body) - } - rest := body[idx+len(marker):] - return rest[:strings.Index(rest, `"`)] -} - -func TestSubmissionListShowsOwnSubmissionsOnly(t *testing.T) { - fs := newFakeStore() - s := newServer(t, fakeExtractor{facts: rules.Facts{ - Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone, - }}, fs) - - cookieA := seedAccount(t, fs, "Mandant A", "a@example.com") - cookieB := seedAccount(t, fs, "Mandant B", "b@example.com") - - subA := checkAndReturnSubID(t, s, cookieA) - _ = checkAndReturnSubID(t, s, cookieB) - - resp := getWithCookie(t, s, cookieA, "/beitraege") - if resp.Code != http.StatusOK { - t.Fatalf("status = %d, want 200, body: %s", resp.Code, resp.Body.String()) - } - body := resp.Body.String() - if !strings.Contains(body, "/beitraege/"+subA) { - t.Errorf("expected Mandant A's own submission link, got: %s", body) - } - - // Mandant B hat genau einen eigenen Beitrag, keinen von A. - respB := getWithCookie(t, s, cookieB, "/beitraege") - if strings.Contains(respB.Body.String(), "/beitraege/"+subA) { - t.Errorf("Mandant B should not see Mandant A's submission, got: %s", respB.Body.String()) - } -} - -func TestSubmissionListRequiresSession(t *testing.T) { - s, _, _ := newAuthedTestServer(t, fakeExtractor{}) - req := httptest.NewRequest(http.MethodGet, "/beitraege", nil) - w := httptest.NewRecorder() - s.ServeHTTP(w, req) - if w.Code != http.StatusSeeOther { - t.Fatalf("status = %d, want 303 redirect to /login", w.Code) - } -} - -func TestSubmissionDetailShowsFactsAndFindings(t *testing.T) { - s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{ - Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationPaid, - DisclosurePresent: true, DisclosureWording: "Werbung", DisclosureBeforeCut: false, - }}) - subID := checkAndReturnSubID(t, s, cookie) - _ = fs - - resp := getWithCookie(t, s, cookie, "/beitraege/"+subID) - if resp.Code != http.StatusOK { - t.Fatalf("status = %d, want 200, body: %s", resp.Code, resp.Body.String()) - } - body := resp.Body.String() - if !strings.Contains(body, "WK-004") { - t.Errorf("expected the finding to be shown, got: %s", body) - } - if !strings.Contains(body, "/veroeffentlichen") { - t.Errorf("expected an archive option for a checked submission, got: %s", body) - } -} - -func TestSubmissionDetailTenantIsolation(t *testing.T) { - fs := newFakeStore() - s := newServer(t, fakeExtractor{facts: rules.Facts{ - Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone, - }}, fs) - cookieA := seedAccount(t, fs, "Mandant A", "a@example.com") - cookieB := seedAccount(t, fs, "Mandant B", "b@example.com") - subA := checkAndReturnSubID(t, s, cookieA) - - resp := getWithCookie(t, s, cookieB, "/beitraege/"+subA) - if resp.Code != http.StatusNotFound { - t.Fatalf("cross-tenant detail status = %d, want 404", resp.Code) - } -} - -func TestParticipantAddUpdateDeleteFlow(t *testing.T) { - s, _, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{ - Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone, - }}) - subID := checkAndReturnSubID(t, s, cookie) - - addResp := postForm(t, s, cookie, "/beitraege/"+subID+"/beteiligte", url.Values{ - "role": {"creator"}, "name": {"Max Mustermann"}, - }) - if addResp.Code != http.StatusOK { - t.Fatalf("add participant status = %d, body: %s", addResp.Code, addResp.Body.String()) - } - if !strings.Contains(addResp.Body.String(), "Max Mustermann") { - t.Fatalf("expected the new participant in the response, got: %s", addResp.Body.String()) - } - - detailResp := getWithCookie(t, s, cookie, "/beitraege/"+subID) - if !strings.Contains(detailResp.Body.String(), "Max Mustermann") { - t.Fatalf("expected the participant on the detail page, got: %s", detailResp.Body.String()) - } - - // Beteiligten-ID aus dem Update-Formular extrahieren. - body := addResp.Body.String() - const marker = "/beteiligte/" - idx := strings.Index(body, marker) - if idx == -1 { - t.Fatalf("expected a participant action URL, got: %s", body) - } - rest := body[idx+len(marker):] - pID := rest[:strings.Index(rest, "/")] - - updateResp := postForm(t, s, cookie, "/beitraege/"+subID+"/beteiligte/"+pID+"/aktualisieren", url.Values{ - "vorgegeben": {"on"}, "freigegeben": {"on"}, - }) - if updateResp.Code != http.StatusOK { - t.Fatalf("update participant status = %d, body: %s", updateResp.Code, updateResp.Body.String()) - } - if !strings.Contains(updateResp.Body.String(), "freigegeben am") { - t.Fatalf("expected an approval timestamp after freigegeben=true, got: %s", updateResp.Body.String()) - } - - deleteResp := postForm(t, s, cookie, "/beitraege/"+subID+"/beteiligte/"+pID+"/loeschen", url.Values{}) - if deleteResp.Code != http.StatusOK { - t.Fatalf("delete participant status = %d, body: %s", deleteResp.Code, deleteResp.Body.String()) - } - if strings.Contains(deleteResp.Body.String(), "Max Mustermann") { - t.Fatalf("expected the participant to be gone after delete, got: %s", deleteResp.Body.String()) - } -} - -func TestParticipantActionsRejectCrossTenantAccess(t *testing.T) { - fs := newFakeStore() - s := newServer(t, fakeExtractor{facts: rules.Facts{ - Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone, - }}, fs) - cookieA := seedAccount(t, fs, "Mandant A", "a@example.com") - cookieB := seedAccount(t, fs, "Mandant B", "b@example.com") - subA := checkAndReturnSubID(t, s, cookieA) - - // Mandant B darf für As Beitrag gar keinen Beteiligten anlegen. - addResp := postForm(t, s, cookieB, "/beitraege/"+subA+"/beteiligte", url.Values{ - "role": {"creator"}, "name": {"Fremd"}, - }) - if addResp.Code != http.StatusNotFound { - t.Fatalf("cross-tenant add status = %d, want 404", addResp.Code) - } - - p, err := fs.CreateParticipant(context.Background(), subA, "creator", "Eigener Beteiligter", false, false) - if err != nil { - t.Fatalf("CreateParticipant: %v", err) - } - - updateResp := postForm(t, s, cookieB, "/beitraege/"+subA+"/beteiligte/"+p.ID+"/aktualisieren", url.Values{ - "vorgegeben": {"on"}, - }) - if updateResp.Code != http.StatusNotFound { - t.Fatalf("cross-tenant update status = %d, want 404", updateResp.Code) - } - - deleteResp := postForm(t, s, cookieB, "/beitraege/"+subA+"/beteiligte/"+p.ID+"/loeschen", url.Values{}) - if deleteResp.Code != http.StatusNotFound { - t.Fatalf("cross-tenant delete status = %d, want 404", deleteResp.Code) - } - - if _, err := fs.GetParticipant(context.Background(), p.ID); err != nil { - t.Fatalf("participant should still exist after rejected cross-tenant delete: %v", err) - } -} diff --git a/internal/web/asset_handlers_test.go b/internal/web/asset_handlers_test.go deleted file mode 100644 index e0279cb..0000000 --- a/internal/web/asset_handlers_test.go +++ /dev/null @@ -1,191 +0,0 @@ -package web_test - -import ( - "bytes" - "context" - "mime/multipart" - "net/http" - "net/http/httptest" - "net/url" - "testing" - - "github.com/netcell-it/deklarix/internal/rules" - "github.com/netcell-it/deklarix/internal/web" -) - -// tinyPNG ist das kleinstmögliche gültige PNG (1x1 transparent) — genug, -// um einen echten Datei-Upload zu simulieren, ohne eine Bilddatei aus -// dem Repo laden zu müssen. -var tinyPNG = []byte{ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, - 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, - 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, - 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, - 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, - 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, -} - -// postCheckWithImage stellt eine echte multipart/form-data-Anfrage wie -// der Browser sie schickt (im Gegensatz zu postForm, das urlencoded -// postet) — checkForm()-Felder plus ein optionales "standbild". -func postCheckWithImage(t *testing.T, s *web.Server, cookie *http.Cookie, imageBytes []byte, contentType string) *httptest.ResponseRecorder { - t.Helper() - var buf bytes.Buffer - mw := multipart.NewWriter(&buf) - for key, val := range checkForm() { - if err := mw.WriteField(key, val[0]); err != nil { - t.Fatalf("WriteField(%s): %v", key, err) - } - } - if imageBytes != nil { - part, err := mw.CreatePart(map[string][]string{ - "Content-Disposition": {`form-data; name="standbild"; filename="screenshot.png"`}, - "Content-Type": {contentType}, - }) - if err != nil { - t.Fatalf("CreatePart: %v", err) - } - if _, err := part.Write(imageBytes); err != nil { - t.Fatalf("Write image bytes: %v", err) - } - } - if err := mw.Close(); err != nil { - t.Fatalf("multipart Close: %v", err) - } - - req := httptest.NewRequest(http.MethodPost, "/pruefen", &buf) - req.Header.Set("Content-Type", mw.FormDataContentType()) - if cookie != nil { - req.AddCookie(cookie) - } - w := httptest.NewRecorder() - s.ServeHTTP(w, req) - return w -} - -func TestCheckWithImageUploadStoresAsset(t *testing.T) { - s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{ - Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone, - }}) - - resp := postCheckWithImage(t, s, cookie, tinyPNG, "image/png") - if resp.Code != http.StatusOK { - t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) - } - - var subID string - for id := range fs.submissions { - subID = id - } - if subID == "" { - t.Fatal("expected a submission to have been created") - } - asset, err := fs.GetLatestAssetForSubmission(context.Background(), subID) - if err != nil { - t.Fatalf("expected an asset to be stored, got err: %v", err) - } - if asset.Kind != "image" || asset.SHA256 == "" { - t.Errorf("unexpected asset: %+v", asset) - } -} - -func TestCheckWithoutImageStoresNoAsset(t *testing.T) { - s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{ - Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone, - }}) - - resp := postForm(t, s, cookie, "/pruefen", checkForm()) - if resp.Code != http.StatusOK { - t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) - } - - var subID string - for id := range fs.submissions { - subID = id - } - if _, err := fs.GetLatestAssetForSubmission(context.Background(), subID); err == nil { - t.Fatal("expected no asset when none was uploaded") - } -} - -func TestCheckRejectsNonImageUpload(t *testing.T) { - s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{ - Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone, - }}) - - resp := postCheckWithImage(t, s, cookie, []byte("kein bild, nur text"), "text/plain") - if resp.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want 400 for a non-image upload, body: %s", resp.Code, resp.Body.String()) - } - if len(fs.submissions) != 0 { - t.Error("expected no submission to be created when the upload is rejected") - } -} - -func TestCheckRejectsOversizedUpload(t *testing.T) { - s, _, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{ - Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone, - }}) - - tooLarge := bytes.Repeat([]byte{0xff}, 9<<20) // 9 MiB > 8 MiB Limit - resp := postCheckWithImage(t, s, cookie, tooLarge, "image/png") - if resp.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want 400 for an oversized upload, body: %s", resp.Code, resp.Body.String()) - } -} - -// TestArchiveMetadataHashDiffersWhenAssetPresent prüft schwarz-verpackt -// (ohne PDF-Interna zu kennen — der Dossier-Content wird komprimiert, -// ein hex-Hash taucht daher nicht als durchsuchbarer String in den -// PDF-Rohbytes auf, siehe internal/dossier/content_test.go für die -// Prüfung auf Ebene der PDF-Inhaltsstruktur), dass ein hochgeladenes -// Standbild tatsächlich in den archivierten Metadaten-Hash einfließt: -// zwei sonst identische Beiträge, einer mit, einer ohne Bild, müssen -// unterschiedliche evidence_package.SHA256 ergeben. -func TestArchiveMetadataHashDiffersWhenAssetPresent(t *testing.T) { - fakeEx := fakeExtractor{ - facts: rules.Facts{Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone}, - raw: []byte(`{"gegenleistung":"keine","kennzeichnung_vorhanden":false,"kennzeichnung_wortlaut":"","kennzeichnung_vor_kuerzung":false}`), - } - s, fs, cookie := newAuthedTestServer(t, fakeEx) - - withImageResp := postCheckWithImage(t, s, cookie, tinyPNG, "image/png") - if withImageResp.Code != http.StatusOK { - t.Fatalf("check (mit Bild) status = %d, body: %s", withImageResp.Code, withImageResp.Body.String()) - } - var withImageSubID string - for id := range fs.submissions { - withImageSubID = id - } - archiveWithImage := postForm(t, s, cookie, "/veroeffentlichen", url.Values{"submission_id": {withImageSubID}}) - if archiveWithImage.Code != http.StatusOK { - t.Fatalf("archive (mit Bild) status = %d, body: %s", archiveWithImage.Code, archiveWithImage.Body.String()) - } - pkgWithImage, err := fs.GetLatestEvidencePackage(context.Background(), withImageSubID) - if err != nil { - t.Fatalf("GetLatestEvidencePackage (mit Bild): %v", err) - } - - withoutImageResp := postForm(t, s, cookie, "/pruefen", checkForm()) - if withoutImageResp.Code != http.StatusOK { - t.Fatalf("check (ohne Bild) status = %d, body: %s", withoutImageResp.Code, withoutImageResp.Body.String()) - } - var withoutImageSubID string - for id := range fs.submissions { - if id != withImageSubID { - withoutImageSubID = id - } - } - archiveWithoutImage := postForm(t, s, cookie, "/veroeffentlichen", url.Values{"submission_id": {withoutImageSubID}}) - if archiveWithoutImage.Code != http.StatusOK { - t.Fatalf("archive (ohne Bild) status = %d, body: %s", archiveWithoutImage.Code, archiveWithoutImage.Body.String()) - } - pkgWithoutImage, err := fs.GetLatestEvidencePackage(context.Background(), withoutImageSubID) - if err != nil { - t.Fatalf("GetLatestEvidencePackage (ohne Bild): %v", err) - } - - if pkgWithImage.SHA256 == pkgWithoutImage.SHA256 { - t.Fatal("expected different metadata hashes for an archived submission with vs. without an uploaded asset") - } -} diff --git a/internal/web/auth_handlers.go b/internal/web/auth_handlers.go index d676e72..2733e3f 100644 --- a/internal/web/auth_handlers.go +++ b/internal/web/auth_handlers.go @@ -26,10 +26,13 @@ func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) { s.renderAuthPage(w, "login", authPageData{Title: "Anmelden"}) } -// handleRegister legt einen neuen Mandanten (Account) und den ersten -// Nutzer darin an. Es gibt bewusst keinen separaten "Account beitreten"- -// Flow — das wäre Schritt 4 (Agentur-/Markensicht mit mehreren Nutzern -// pro Account), hier reicht ein Nutzer pro neuem Account. +// handleRegister ist die "Firma"-Registrierung (Ebene 1 der Frontend- +// Spezifikation): legt einen neuen Mandanten (Account) und den ersten +// Nutzer als admin an. Testzugang ist sofort aktiv, der Bezahlbetrieb +// wird separat vom Betreiber freigeschaltet (nicht Teil dieses Flows). +// Es gibt bewusst KEINE offene Selbstregistrierung für einzelne +// Mitarbeiter — die kommen über Einladungslink/CSV-Import/Mandantenlink +// (spätere Ausbaustufe), nicht über dieses Formular. func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "ungültiges Formular"}) @@ -39,8 +42,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { accountName := r.FormValue("account_name") email := r.FormValue("email") password := r.FormValue("password") - role := r.FormValue("role") - if accountName == "" || email == "" || password == "" || role == "" { + if accountName == "" || email == "" || password == "" { s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Alle Felder sind Pflicht"}) return } @@ -57,7 +59,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Konto konnte nicht angelegt werden"}) return } - user, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, role) + user, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, "admin") if err != nil { s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"}) return @@ -96,11 +98,11 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "Sitzung konnte nicht gestartet werden"}) return } - // Ein Admin-Login hat keinen eigenen Beitrag zu prüfen — die - // Pre-Publish-Prüfung ("/") ist die Startseite für Mandanten, für - // einen Admin ist der Admin-Bereich der sinnvolle Einstieg. - if user.Role == "admin" { - http.Redirect(w, r, "/admin", http.StatusSeeOther) + // Ein Betreiber-Login hat keinen eigenen Antrag zu stellen — der + // Plattform-Bereich ist der sinnvolle Einstieg (Ebene 5, technisch + // getrennt vom Mandantenbereich). + if user.Role == "betreiber" { + http.Redirect(w, r, "/betreiber", http.StatusSeeOther) return } http.Redirect(w, r, "/", http.StatusSeeOther) diff --git a/internal/web/betreiber_handlers.go b/internal/web/betreiber_handlers.go new file mode 100644 index 0000000..a9a9c5c --- /dev/null +++ b/internal/web/betreiber_handlers.go @@ -0,0 +1,139 @@ +package web + +import ( + "net/http" +) + +type betreiberDashboardData struct { + Title string + Nav navData + AccountCount int + RecentAuditCount int +} + +// handleBetreiberDashboard zeigt eine kurze Übersicht als Einstieg in +// den Plattform-Bereich (Ebene 5 — nur Betreiber, technisch getrennt +// vom Mandantenbereich). +func (s *Server) handleBetreiberDashboard(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + accounts, err := s.store.ListAccounts(ctx) + if err != nil { + http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) + return + } + auditLog, err := s.store.ListAuditLog(ctx, 5) + if err != nil { + http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError) + return + } + + data := betreiberDashboardData{ + Title: "Plattform", Nav: navFor(r), AccountCount: len(accounts), RecentAuditCount: len(auditLog), + } + if err := s.templates.ExecuteTemplate(w, "betreiber-dashboard", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} + +type betreiberAccountListItem struct { + ID string + Name string + CreatedAt string +} + +type betreiberAccountListData struct { + Title string + Nav navData + Accounts []betreiberAccountListItem +} + +// handleBetreiberAccountList listet alle Mandanten der Plattform. +func (s *Server) handleBetreiberAccountList(w http.ResponseWriter, r *http.Request) { + accounts, err := s.store.ListAccounts(r.Context()) + if err != nil { + http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) + return + } + data := betreiberAccountListData{Title: "Accounts", Nav: navFor(r)} + for _, a := range accounts { + data.Accounts = append(data.Accounts, betreiberAccountListItem{ + ID: a.ID, Name: a.Name, CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"), + }) + } + if err := s.templates.ExecuteTemplate(w, "betreiber-accounts", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} + +type betreiberUserView struct { + Email string + Role string +} + +type betreiberAccountDetailData struct { + Title string + Nav navData + AccountID string + Name string + Users []betreiberUserView + CreatedAt string +} + +// handleBetreiberAccountDetail zeigt einen Mandanten mit seinen Logins. +func (s *Server) handleBetreiberAccountDetail(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + acc, err := s.store.GetAccount(ctx, r.PathValue("id")) + if err != nil { + http.Error(w, "Account nicht gefunden", http.StatusNotFound) + return + } + users, err := s.store.ListUsersForAccount(ctx, acc.ID) + if err != nil { + http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) + return + } + + data := betreiberAccountDetailData{ + Title: "Account", Nav: navFor(r), AccountID: acc.ID, Name: acc.Name, + CreatedAt: acc.CreatedAt.Format("02.01.2006 15:04"), + } + for _, u := range users { + data.Users = append(data.Users, betreiberUserView{Email: u.Email, Role: u.Role}) + } + if err := s.templates.ExecuteTemplate(w, "betreiber-account-detail", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} + +type betreiberAuditEntryView struct { + CreatedAt string + Action string + TargetType string + TargetID string + Details string +} + +type betreiberAuditLogData struct { + Title string + Nav navData + Entries []betreiberAuditEntryView +} + +// handleBetreiberAuditLog zeigt das plattformweite Audit-Log. +func (s *Server) handleBetreiberAuditLog(w http.ResponseWriter, r *http.Request) { + entries, err := s.store.ListAuditLog(r.Context(), 200) + if err != nil { + http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError) + return + } + data := betreiberAuditLogData{Title: "Audit-Log", Nav: navFor(r)} + for _, e := range entries { + data.Entries = append(data.Entries, betreiberAuditEntryView{ + CreatedAt: e.CreatedAt.Format("02.01.2006 15:04:05"), Action: e.Action, + TargetType: e.TargetType, TargetID: e.TargetID, Details: e.Details, + }) + } + if err := s.templates.ExecuteTemplate(w, "betreiber-audit-log", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 1a83dae..afbeca9 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -1,96 +1,10 @@ package web import ( - "context" - "encoding/hex" - "errors" "fmt" - "io" "net/http" - "os" - "path/filepath" - "strings" - "time" - - "github.com/netcell-it/deklarix/internal/dossier" - "github.com/netcell-it/deklarix/internal/evidence" - "github.com/netcell-it/deklarix/internal/extract" - "github.com/netcell-it/deklarix/internal/rules" - "github.com/netcell-it/deklarix/internal/store" ) -// maxAssetSize begrenzt ein hochgeladenes Standbild auf 8 MiB — genug für -// einen Screenshot, nicht genug, um den Server mit Uploads zu fluten. -const maxAssetSize = 8 << 20 - -// uploadedAsset ist ein bereits gelesenes und geprüftes Standbild, das -// nach dem Anlegen der Submission (die submission_id als Fremdschlüssel -// braucht) tatsächlich gespeichert wird. Getrennt von storeAsset, damit -// ein ungültiger Upload (falscher Typ, zu groß) *vor* dem Anlegen der -// Submission scheitert, statt eine Beitrags-Zeile ohne Asset zu hinterlassen. -type uploadedAsset struct { - data []byte - extension string - sha256Hex string -} - -// readUploadedAsset liest das optionale Datei-Feld fieldName. Liefert -// (nil, nil), wenn kein Bild hochgeladen wurde — das ist der Normalfall, -// ein Standbild ist keine Pflichtangabe. -func (s *Server) readUploadedAsset(r *http.Request, fieldName string) (*uploadedAsset, error) { - file, header, err := r.FormFile(fieldName) - // ErrNotMultipart: die Anfrage war gar kein multipart/form-data (z. B. - // ältere Clients oder Tests mit urlencoded-Formular) — dann kann auch - // kein Standbild dabei sein, das ist derselbe Fall wie ErrMissingFile. - if errors.Is(err, http.ErrMissingFile) || errors.Is(err, http.ErrNotMultipart) { - return nil, nil - } - if err != nil { - return nil, fmt.Errorf("Standbild konnte nicht gelesen werden: %w", err) - } - defer file.Close() - - if !strings.HasPrefix(header.Header.Get("Content-Type"), "image/") { - return nil, fmt.Errorf("nur Bilddateien sind als Standbild erlaubt") - } - - data, err := io.ReadAll(io.LimitReader(file, maxAssetSize+1)) - if err != nil { - return nil, fmt.Errorf("Standbild konnte nicht gelesen werden: %w", err) - } - if len(data) > maxAssetSize { - return nil, fmt.Errorf("Standbild ist zu groß (max. %d MB)", maxAssetSize/(1<<20)) - } - - ext := filepath.Ext(header.Filename) - if ext == "" { - ext = ".bin" - } - return &uploadedAsset{data: data, extension: ext, sha256Hex: hex.EncodeToString(evidence.HashBytes(data))}, nil -} - -// storeAsset schreibt ein zuvor gelesenes Standbild auf die Platte und -// speichert die Asset-Zeile. purpose ist "initial" (das Beweisfoto beim -// Prüfen, ein Dateiname pro Submission reicht) oder "insights" (kann -// mehrfach vorkommen, braucht daher einen eindeutigen Dateinamen). -func (s *Server) storeAsset(ctx context.Context, submissionID, purpose string, ua *uploadedAsset) error { - if err := os.MkdirAll(s.assetDir, 0o750); err != nil { - return fmt.Errorf("Asset-Verzeichnis konnte nicht angelegt werden: %w", err) - } - filename := submissionID + ua.extension - if purpose != "initial" { - filename = fmt.Sprintf("%s-%s-%d%s", submissionID, purpose, time.Now().UnixNano(), ua.extension) - } - path := filepath.Join(s.assetDir, filename) - if err := os.WriteFile(path, ua.data, 0o640); err != nil { - return fmt.Errorf("Standbild konnte nicht gespeichert werden: %w", err) - } - if _, err := s.store.CreateAsset(ctx, submissionID, "image", purpose, path, ua.sha256Hex); err != nil { - return fmt.Errorf("Asset konnte nicht gespeichert werden: %w", err) - } - return nil -} - func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `{"ok":true}`) @@ -101,292 +15,12 @@ type indexData struct { Nav navData } +// handleIndex ist die Startseite nach der Anmeldung. Platzhalter für +// Phase 1 (Datenmodell/Regelwerk/Katalogstruktur) — der geführte +// Fragebogen (Ebene 2, "Antrag stellen") ist Phase 2 der Baureihenfolge. func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { - data := indexData{Title: "Pre-Publish-Prüfung", Nav: navFor(r)} + data := indexData{Title: "Start", Nav: navFor(r)} if err := s.templates.ExecuteTemplate(w, "index", data); err != nil { http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) } } - -type findingView struct { - RuleID string - Version int - Severity string - Title string - Fix string - Sources []string -} - -type resultData struct { - SubmissionID string - NeedsClarification bool - CanArchive bool - Findings []findingView -} - -// handleCheck führt die Pre-Publish-Prüfung aus: Extraktion (Stufe 1), -// Regelauswertung (Stufe 2), und speichert Submission + Extraction + -// Findings. Die eigentliche Archivierung (Hash, Zeitstempel, Dossier) -// passiert erst in handleArchive, wenn der Beitrag tatsächlich -// veröffentlicht wurde — das sind unterschiedliche Zeitpunkte im -// Lebenszyklus (siehe CLAUDE.md, Funktion 1 vs. 2). -func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, maxAssetSize+(1<<20)) - // ErrNotMultipart ist kein Fehlerfall: ParseMultipartForm ruft intern - // zuerst ParseForm auf, das Formularfelder auch aus einem klassischen - // urlencoded-Body liest (kein Standbild dabei, aber alle anderen - // Felder sind trotzdem gültig) — nur ein wirklich kaputter oder zu - // großer Body soll hier abbrechen. - if err := r.ParseMultipartForm(1 << 20); err != nil && !errors.Is(err, http.ErrNotMultipart) { - http.Error(w, "ungültiges Formular (evtl. zu groß)", http.StatusBadRequest) - return - } - - platform := r.FormValue("platform") - postType := r.FormValue("post_type") - caption := r.FormValue("caption") - consideration := r.FormValue("consideration") - if platform == "" || postType == "" || caption == "" || consideration == "" { - http.Error(w, "Plattform, Beitragstyp, Gegenleistung und Caption sind Pflichtfelder", http.StatusBadRequest) - return - } - - asset, err := s.readUploadedAsset(r, "standbild") - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - ctx := r.Context() - accountID := currentUser(r).AccountID - - result, err := s.extractor.Extract(ctx, extract.Input{ - Platform: platform, - Jurisdiction: "DE", - Consideration: consideration, - Caption: caption, - }) - if err != nil { - http.Error(w, "Extraktion fehlgeschlagen: "+err.Error(), http.StatusBadGateway) - return - } - - sub, err := s.store.CreateSubmission(ctx, accountID, platform, postType, caption) - if err != nil { - http.Error(w, "Beitrag konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError) - return - } - - if asset != nil { - if err := s.storeAsset(ctx, sub.ID, "initial", asset); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - } - - ext, err := s.store.CreateExtraction(ctx, sub.ID, result.RawJSON, s.extractor.ModelVersion(), extract.PromptVersion) - if err != nil { - http.Error(w, "Extraktion konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError) - return - } - - findings, needsClarification := rules.Evaluate(s.ruleSet, result.Facts) - - data := resultData{SubmissionID: sub.ID, NeedsClarification: needsClarification} - if !needsClarification { - extractionID := ext.ID - for _, f := range findings { - if _, err := s.store.CreateFinding(ctx, sub.ID, &extractionID, f.RuleID, f.RuleVersion, string(f.Severity), f.Title, f.Fix, f.Sources); err != nil { - http.Error(w, "Finding konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError) - return - } - data.Findings = append(data.Findings, findingView{ - RuleID: f.RuleID, Version: f.RuleVersion, Severity: string(f.Severity), - Title: f.Title, Fix: f.Fix, Sources: f.Sources, - }) - } - if err := s.store.SetSubmissionStatus(ctx, sub.ID, "checked"); err != nil { - http.Error(w, "Status konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError) - return - } - data.CanArchive = true - } - - if err := s.templates.ExecuteTemplate(w, "result", data); err != nil { - http.Error(w, "Ergebnis konnte nicht gerendert werden", http.StatusInternalServerError) - } -} - -type archivedData struct { - SubmissionID string - DossierURL string - TimestampedAt string -} - -// handleArchive markiert einen geprüften Beitrag als veröffentlicht und -// archiviert ihn: Hash der Metadaten, RFC-3161-Zeitstempel, PDF-Dossier. -// Setzt eine vorherige Prüfung voraus (Submission + Extraction + -// Findings müssen bereits existieren). -func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - http.Error(w, "ungültiges Formular", http.StatusBadRequest) - return - } - submissionID := r.FormValue("submission_id") - if submissionID == "" { - http.Error(w, "submission_id ist Pflicht", http.StatusBadRequest) - return - } - - ctx := r.Context() - - sub, err := s.store.GetSubmission(ctx, submissionID) - if err != nil { - http.Error(w, "Beitrag nicht gefunden: "+err.Error(), http.StatusNotFound) - return - } - // Mandantentrennung: ein Beitrag eines anderen Accounts wird wie ein - // nicht existierender behandelt, nicht mit einer 403 bestätigt — - // eine 403 würde einem anderen Mandanten verraten, dass die ID - // existiert. - if sub.AccountID != currentUser(r).AccountID { - http.Error(w, "Beitrag nicht gefunden", http.StatusNotFound) - return - } - - ext, err := s.store.GetLatestExtraction(ctx, submissionID) - if err != nil { - http.Error(w, "Extraktion nicht gefunden: "+err.Error(), http.StatusNotFound) - return - } - facts, err := extract.ParsePayload(ext.Payload, sub.Platform, "DE") - if err != nil { - http.Error(w, "gespeicherte Extraktion konnte nicht gelesen werden: "+err.Error(), http.StatusInternalServerError) - return - } - - storeFindings, err := s.store.ListCurrentFindings(ctx, submissionID) - if err != nil { - http.Error(w, "Findings konnten nicht gelesen werden: "+err.Error(), http.StatusInternalServerError) - return - } - dossierFindings := make([]rules.Finding, len(storeFindings)) - for i, f := range storeFindings { - dossierFindings[i] = rules.Finding{ - RuleID: f.RuleID, RuleVersion: f.RuleVersion, Severity: rules.Severity(f.Severity), - Title: f.Title, Fix: f.Fix, Sources: f.Sources, - } - } - - var assetHash []byte - var assetSHA256Hex string - switch asset, assetErr := s.store.GetLatestAssetForSubmission(ctx, submissionID); { - case assetErr == nil: - assetSHA256Hex = asset.SHA256 - assetHash, err = hex.DecodeString(asset.SHA256) - if err != nil { - http.Error(w, "gespeicherter Asset-Hash konnte nicht gelesen werden: "+err.Error(), http.StatusInternalServerError) - return - } - case errors.Is(assetErr, store.ErrNotFound): - // Kein Standbild hochgeladen — das ist erlaubt, siehe CLAUDE.md - // (Standbild ist kein Pflichtfeld der Prüfung). - default: - http.Error(w, "Asset konnte nicht geladen werden: "+assetErr.Error(), http.StatusInternalServerError) - return - } - - metadataHash, err := evidence.HashMetadata(struct { - SubmissionID string - Platform string - PostType string - Caption string - Facts rules.Facts - Findings []rules.Finding - AssetSHA256Hex string - }{sub.ID, sub.Platform, sub.PostType, sub.Caption, facts, dossierFindings, assetSHA256Hex}) - if err != nil { - http.Error(w, "Metadaten-Hash fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) - return - } - - timestampToken, err := s.timestamper.Timestamp(ctx, metadataHash) - if err != nil { - http.Error(w, "Zeitstempel fehlgeschlagen: "+err.Error(), http.StatusBadGateway) - return - } - - if err := os.MkdirAll(s.dossierDir, 0o750); err != nil { - http.Error(w, "Dossier-Verzeichnis konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError) - return - } - dossierPath := filepath.Join(s.dossierDir, submissionID+".pdf") - dossierFile, err := os.Create(dossierPath) - if err != nil { - http.Error(w, "Dossier-Datei konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError) - return - } - defer dossierFile.Close() - - genErr := dossier.Generate(dossierFile, dossier.Data{ - Submission: dossier.Submission{ - Platform: sub.Platform, PostType: sub.PostType, Caption: sub.Caption, CreatedAt: sub.CreatedAt, - }, - Facts: facts, - Findings: dossierFindings, - AssetHash: assetHash, - MetadataHash: metadataHash, - TimestampToken: timestampToken, - GeneratedAt: time.Now(), - }) - if genErr != nil { - http.Error(w, "Dossier konnte nicht erzeugt werden: "+genErr.Error(), http.StatusInternalServerError) - return - } - - if _, err := s.store.CreateEvidencePackage(ctx, submissionID, dossierPath, hex.EncodeToString(metadataHash), timestampToken); err != nil { - http.Error(w, "Evidence-Package konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError) - return - } - if err := s.store.SetSubmissionStatus(ctx, submissionID, "published"); err != nil { - http.Error(w, "Status konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError) - return - } - - timestampedAt, err := evidence.TimestampTime(timestampToken) - if err != nil { - http.Error(w, "Zeitstempel konnte nicht gelesen werden: "+err.Error(), http.StatusInternalServerError) - return - } - - data := archivedData{ - SubmissionID: submissionID, - DossierURL: "/dossier/" + submissionID, - TimestampedAt: timestampedAt.Format("02.01.2006 15:04:05 MST"), - } - if err := s.templates.ExecuteTemplate(w, "archived", data); err != nil { - http.Error(w, "Ergebnis konnte nicht gerendert werden", http.StatusInternalServerError) - } -} - -// handleDossierDownload liefert das erzeugte PDF-Dossier eines -// archivierten Beitrags aus. -func (s *Server) handleDossierDownload(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - ctx := r.Context() - - sub, err := s.store.GetSubmission(ctx, id) - if err != nil || sub.AccountID != currentUser(r).AccountID { - http.Error(w, "Kein Dossier für diesen Beitrag gefunden", http.StatusNotFound) - return - } - - pkg, err := s.store.GetLatestEvidencePackage(ctx, id) - if err != nil { - http.Error(w, "Kein Dossier für diesen Beitrag gefunden", http.StatusNotFound) - return - } - - w.Header().Set("Content-Type", "application/pdf") - http.ServeFile(w, r, pkg.DossierPath) -} diff --git a/internal/web/insights_handlers_test.go b/internal/web/insights_handlers_test.go deleted file mode 100644 index 088f176..0000000 --- a/internal/web/insights_handlers_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package web_test - -import ( - "bytes" - "context" - "mime/multipart" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - - "github.com/netcell-it/deklarix/internal/rules" - "github.com/netcell-it/deklarix/internal/web" -) - -// postInsightsUpload lädt einen Insights-Screenshot für einen Beitrag hoch. -func postInsightsUpload(t *testing.T, s *web.Server, cookie *http.Cookie, submissionID string, imageBytes []byte) *httptest.ResponseRecorder { - t.Helper() - var buf bytes.Buffer - mw := multipart.NewWriter(&buf) - part, err := mw.CreatePart(map[string][]string{ - "Content-Disposition": {`form-data; name="insights_standbild"; filename="insights.png"`}, - "Content-Type": {"image/png"}, - }) - if err != nil { - t.Fatalf("CreatePart: %v", err) - } - if _, err := part.Write(imageBytes); err != nil { - t.Fatalf("Write: %v", err) - } - if err := mw.Close(); err != nil { - t.Fatalf("multipart Close: %v", err) - } - - req := httptest.NewRequest(http.MethodPost, "/beitraege/"+submissionID+"/insights", &buf) - req.Header.Set("Content-Type", mw.FormDataContentType()) - req.AddCookie(cookie) - w := httptest.NewRecorder() - s.ServeHTTP(w, req) - return w -} - -func TestStorySubmissionShowsInsightsReminderAfterArchiving(t *testing.T) { - s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{ - Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone, - }, raw: []byte(`{"gegenleistung":"keine","kennzeichnung_vorhanden":false,"kennzeichnung_wortlaut":"","kennzeichnung_vor_kuerzung":false}`)}) - - subID := checkAndReturnSubID2(t, s, cookie, "story") - archiveResp := postForm(t, s, cookie, "/veroeffentlichen", url.Values{"submission_id": {subID}}) - if archiveResp.Code != http.StatusOK { - t.Fatalf("archive status = %d, body: %s", archiveResp.Code, archiveResp.Body.String()) - } - - detailResp := getWithCookie(t, s, cookie, "/beitraege/"+subID) - if detailResp.Code != http.StatusOK { - t.Fatalf("detail status = %d, body: %s", detailResp.Code, detailResp.Body.String()) - } - body := detailResp.Body.String() - if !strings.Contains(body, "Insights jetzt sichern") { - t.Errorf("expected an insights reminder for a freshly archived story, got: %s", body) - } - _ = fs -} - -func TestInsightsUploadClearsReminder(t *testing.T) { - s, _, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{ - Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone, - }, raw: []byte(`{"gegenleistung":"keine","kennzeichnung_vorhanden":false,"kennzeichnung_wortlaut":"","kennzeichnung_vor_kuerzung":false}`)}) - - subID := checkAndReturnSubID2(t, s, cookie, "story") - if resp := postForm(t, s, cookie, "/veroeffentlichen", url.Values{"submission_id": {subID}}); resp.Code != http.StatusOK { - t.Fatalf("archive status = %d, body: %s", resp.Code, resp.Body.String()) - } - - uploadResp := postInsightsUpload(t, s, cookie, subID, tinyPNG) - if uploadResp.Code != http.StatusSeeOther { - t.Fatalf("insights upload status = %d, want 303, body: %s", uploadResp.Code, uploadResp.Body.String()) - } - - detailResp := getWithCookie(t, s, cookie, "/beitraege/"+subID) - body := detailResp.Body.String() - if strings.Contains(body, "Insights jetzt sichern") { - t.Errorf("expected the reminder to be gone after securing insights, got: %s", body) - } - if !strings.Contains(body, "Gesicherte Insights-Nachweise") { - t.Errorf("expected the secured insights section, got: %s", body) - } -} - -func TestInsightsUploadRejectsForeignSubmission(t *testing.T) { - fs := newFakeStore() - s := newServer(t, fakeExtractor{facts: rules.Facts{ - Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone, - }}, fs) - cookieA := seedAccount(t, fs, "Mandant A", "a@example.com") - cookieB := seedAccount(t, fs, "Mandant B", "b@example.com") - - subID := checkAndReturnSubID2(t, s, cookieA, "story") - - resp := postInsightsUpload(t, s, cookieB, subID, tinyPNG) - if resp.Code != http.StatusNotFound { - t.Fatalf("cross-tenant insights upload status = %d, want 404", resp.Code) - } - if _, err := fs.GetLatestAssetForSubmission(context.Background(), subID); err == nil { - t.Fatal("expected no asset from a rejected cross-tenant upload") - } -} - -// checkAndReturnSubID2 ist checkAndReturnSubID mit ueberschreibbarem post_type. -func checkAndReturnSubID2(t *testing.T, s interface { - ServeHTTP(w http.ResponseWriter, r *http.Request) -}, cookie *http.Cookie, postType string) string { - t.Helper() - form := checkForm("post_type", postType) - req := httptest.NewRequest(http.MethodPost, "/pruefen", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.AddCookie(cookie) - w := httptest.NewRecorder() - s.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("check status = %d, body: %s", w.Code, w.Body.String()) - } - body := w.Body.String() - const marker = `name="submission_id" value="` - idx := strings.Index(body, marker) - if idx == -1 { - t.Fatalf("expected a submission_id field in the result, got: %s", body) - } - rest := body[idx+len(marker):] - return rest[:strings.Index(rest, `"`)] -} diff --git a/internal/web/insights_reminder.go b/internal/web/insights_reminder.go deleted file mode 100644 index c8d99ed..0000000 --- a/internal/web/insights_reminder.go +++ /dev/null @@ -1,47 +0,0 @@ -package web - -import ( - "fmt" - "time" -) - -// storyInsightsWindow ist das Zeitfenster, in dem Instagram nach -// eigener Aussage Story-Insights überhaupt vorhält — danach sind sie -// auch über den offiziellen Datenexport nicht mehr zu bekommen. Reine -// Produktentscheidung (Erinnerungs-Timing), keine Rechtsnorm — bewusst -// hier als Konstante und nicht in rules/*.yaml, das ist ausschließlich -// für die Kennzeichnungsprüfung reserviert (siehe CLAUDE.md). -const storyInsightsWindow = 24 * time.Hour - -// insightsReminder beschreibt, ob und wie dringend eine Erinnerung -// angezeigt werden soll, die Kennzahlen (Insights) eines veröffentlichten -// Beitrags per Screenshot zu sichern, bevor sie unwiederbringlich -// verschwinden. -type insightsReminder struct { - Show bool - Urgent bool // Fenster läuft noch - Expired bool // Fenster ist wahrscheinlich schon vorbei - Message string -} - -// computeInsightsReminder berechnet den Erinnerungsstatus. Nur für -// "story"-Beiträge relevant (siehe storyInsightsWindow); alle anderen -// Beitragstypen bekommen keine Erinnerung, da ihre Kennzahlen nicht auf -// dieselbe Art flüchtig sind. -func computeInsightsReminder(postType string, publishedAt time.Time, now time.Time, hasInsightsAsset bool) insightsReminder { - if postType != "story" || hasInsightsAsset || publishedAt.IsZero() { - return insightsReminder{} - } - elapsed := now.Sub(publishedAt) - if elapsed >= storyInsightsWindow { - return insightsReminder{ - Show: true, Expired: true, - Message: "Das Zeitfenster für Story-Insights ist bei Instagram nach eigener Aussage abgelaufen (24 Stunden) — die Kennzahlen sind wahrscheinlich nicht mehr abrufbar.", - } - } - remainingHours := int((storyInsightsWindow - elapsed).Hours()) + 1 - return insightsReminder{ - Show: true, Urgent: true, - Message: fmt.Sprintf("Noch ca. %d Stunde(n), um die Story-Insights zu sichern, bevor sie bei Instagram verschwinden.", remainingHours), - } -} diff --git a/internal/web/insights_reminder_test.go b/internal/web/insights_reminder_test.go deleted file mode 100644 index 6bb7259..0000000 --- a/internal/web/insights_reminder_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package web - -import ( - "testing" - "time" -) - -func TestComputeInsightsReminderOnlyForStory(t *testing.T) { - now := time.Now() - r := computeInsightsReminder("feed", now.Add(-time.Hour), now, false) - if r.Show { - t.Errorf("expected no reminder for a non-story post type, got %+v", r) - } -} - -func TestComputeInsightsReminderSkippedWhenAlreadySecured(t *testing.T) { - now := time.Now() - r := computeInsightsReminder("story", now.Add(-time.Hour), now, true) - if r.Show { - t.Errorf("expected no reminder once an insights asset already exists, got %+v", r) - } -} - -func TestComputeInsightsReminderUrgentWithinWindow(t *testing.T) { - now := time.Now() - r := computeInsightsReminder("story", now.Add(-2*time.Hour), now, false) - if !r.Show || !r.Urgent || r.Expired { - t.Fatalf("expected an urgent, non-expired reminder, got %+v", r) - } -} - -func TestComputeInsightsReminderExpiredAfterWindow(t *testing.T) { - now := time.Now() - r := computeInsightsReminder("story", now.Add(-25*time.Hour), now, false) - if !r.Show || !r.Expired || r.Urgent { - t.Fatalf("expected an expired reminder, got %+v", r) - } -} - -func TestComputeInsightsReminderSkippedWithoutPublishTime(t *testing.T) { - r := computeInsightsReminder("story", time.Time{}, time.Now(), false) - if r.Show { - t.Errorf("expected no reminder without a known publish time, got %+v", r) - } -} diff --git a/internal/web/middleware.go b/internal/web/middleware.go index 7119461..f3cac7b 100644 --- a/internal/web/middleware.go +++ b/internal/web/middleware.go @@ -68,19 +68,20 @@ func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc { } } -// requireAdmin schützt den Admin-Bereich. Ohne Sitzung geht es wie bei -// requirePage zu /login; mit einer Sitzung, aber ohne Admin-Rolle, gibt -// es 404 statt 403 — sonst würde eine 403 einem angemeldeten, aber -// unprivilegierten Nutzer verraten, dass unter dieser URL überhaupt -// etwas existiert. -func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc { +// requireBetreiber schützt den Plattform-Bereich (Ebene 5 — "nur +// Betreiber", technisch getrennt vom Mandantenbereich, siehe CLAUDE.md). +// Ohne Sitzung geht es wie bei requirePage zu /login; mit einer +// Sitzung, aber ohne Betreiber-Rolle, gibt es 404 statt 403 — sonst +// würde eine 403 einem angemeldeten, aber unprivilegierten Nutzer +// verraten, dass unter dieser URL überhaupt etwas existiert. +func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user, ok := s.authenticate(r) if !ok { http.Redirect(w, r, "/login", http.StatusSeeOther) return } - if user.Role != "admin" { + if user.Role != "betreiber" { http.Error(w, "nicht gefunden", http.StatusNotFound) return } @@ -90,17 +91,17 @@ func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc { // navData steuert die gemeinsame Navigation (layout.html, "nav"-Block). // Eigenes, kleines Struct statt jeder Seite Zugriff auf den vollen -// currentUser zu geben — die Navigation braucht nur, ob ein Admin-Link -// gezeigt werden soll. +// currentUser zu geben — die Navigation braucht nur, ob ein +// Plattform-Link gezeigt werden soll. type navData struct { - IsAdmin bool + IsBetreiber bool } // navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage. -// Nur für Seiten hinter requirePage/requireAdmin aufrufbar (braucht +// Nur für Seiten hinter requirePage/requireBetreiber aufrufbar (braucht // currentUser). func navFor(r *http.Request) navData { - return navData{IsAdmin: currentUser(r).Role == "admin"} + return navData{IsBetreiber: currentUser(r).Role == "betreiber"} } // currentUser liest den Nutzer, den requirePage/requireAPI in den diff --git a/internal/web/oauth_handlers.go b/internal/web/oauth_handlers.go deleted file mode 100644 index 84feb5f..0000000 --- a/internal/web/oauth_handlers.go +++ /dev/null @@ -1,142 +0,0 @@ -package web - -import ( - "errors" - "net/http" - "time" - - "github.com/netcell-it/deklarix/internal/auth" - "github.com/netcell-it/deklarix/internal/store" -) - -const oauthStateCookieName = "deklarix_oauth_state" - -// knownPlatforms sind alle Plattformen, die die Verbindungs-Übersicht -// anzeigt — unabhängig davon, ob dafür schon ein Connector konfiguriert -// ist (siehe cmd/deklarix/main.go). Eine unkonfigurierte Plattform zeigt -// "nicht konfiguriert" statt eines Verbinden-Buttons. -var knownPlatforms = []string{"instagram", "tiktok"} - -type connectionView struct { - Platform string - Configured bool - Connected bool - ConnectedAt string -} - -type connectionsData struct { - Title string - Nav navData - Connections []connectionView -} - -// handleConnectionsList zeigt, welche Plattformen der Account verbunden -// hat — Grundlage für die spätere automatische Beweissicherung -// (Post per API statt manuellem Screenshot-Upload abrufen). -func (s *Server) handleConnectionsList(w http.ResponseWriter, r *http.Request) { - accountID := currentUser(r).AccountID - existing, err := s.store.ListPlatformConnectionsForAccount(r.Context(), accountID) - if err != nil { - http.Error(w, "Verbindungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) - return - } - connectedAt := map[string]string{} - for _, c := range existing { - connectedAt[c.Platform] = c.ConnectedAt.Format("02.01.2006 15:04") - } - - data := connectionsData{Title: "Verbindungen", Nav: navFor(r)} - for _, platform := range knownPlatforms { - _, configured := s.connectors[platform] - at, connected := connectedAt[platform] - data.Connections = append(data.Connections, connectionView{ - Platform: platform, Configured: configured, Connected: connected, ConnectedAt: at, - }) - } - if err := s.templates.ExecuteTemplate(w, "verbindungen", data); err != nil { - http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) - } -} - -// handleOAuthStart leitet zum Consent-Screen der Plattform weiter. Der -// state-Wert wird in einem kurzlebigen Cookie gehalten und beim -// Callback gegengeprüft — Schutz gegen CSRF (ein Angreifer könnte sonst -// einen fremden Autorisierungscode gegen das Konto des Opfers -// einschleusen). -func (s *Server) handleOAuthStart(w http.ResponseWriter, r *http.Request) { - connector, ok := s.connectors[r.PathValue("platform")] - if !ok { - http.Error(w, "Plattform nicht konfiguriert", http.StatusNotFound) - return - } - - state, err := auth.NewSessionToken() - if err != nil { - http.Error(w, "Anfrage konnte nicht vorbereitet werden: "+err.Error(), http.StatusInternalServerError) - return - } - http.SetCookie(w, &http.Cookie{ - Name: oauthStateCookieName, Value: state, Path: "/oauth", - HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteLaxMode, - Expires: time.Now().Add(10 * time.Minute), - }) - http.Redirect(w, r, connector.AuthorizationURL(state), http.StatusSeeOther) -} - -// handleOAuthCallback verarbeitet die Rückleitung von der Plattform: -// state prüfen, Code gegen ein Token tauschen, Verbindung speichern. -func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) { - connector, ok := s.connectors[r.PathValue("platform")] - if !ok { - http.Error(w, "Plattform nicht konfiguriert", http.StatusNotFound) - return - } - - // Der Nutzer hat die Autorisierung abgelehnt — kein Fehler unsererseits. - if errParam := r.URL.Query().Get("error"); errParam != "" { - http.Redirect(w, r, "/verbindungen", http.StatusSeeOther) - return - } - - stateCookie, err := r.Cookie(oauthStateCookieName) - if err != nil || stateCookie.Value == "" || stateCookie.Value != r.URL.Query().Get("state") { - http.Error(w, "ungültiger oder abgelaufener State-Parameter", http.StatusBadRequest) - return - } - http.SetCookie(w, &http.Cookie{Name: oauthStateCookieName, Value: "", Path: "/oauth", MaxAge: -1}) - - code := r.URL.Query().Get("code") - if code == "" { - http.Error(w, "kein Autorisierungscode erhalten", http.StatusBadRequest) - return - } - - token, err := connector.Exchange(r.Context(), code) - if err != nil { - http.Error(w, "Verbindung fehlgeschlagen: "+err.Error(), http.StatusBadGateway) - return - } - - var expiresAt *time.Time - if !token.ExpiresAt.IsZero() { - expiresAt = &token.ExpiresAt - } - accountID := currentUser(r).AccountID - if _, err := s.store.UpsertPlatformConnection(r.Context(), accountID, connector.Platform(), token.PlatformUserID, token.AccessToken, token.RefreshToken, expiresAt); err != nil { - http.Error(w, "Verbindung konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError) - return - } - - http.Redirect(w, r, "/verbindungen", http.StatusSeeOther) -} - -// handleDisconnect trennt eine Plattform-Verbindung. -func (s *Server) handleDisconnect(w http.ResponseWriter, r *http.Request) { - accountID := currentUser(r).AccountID - platform := r.PathValue("platform") - if err := s.store.DeletePlatformConnection(r.Context(), accountID, platform); err != nil && !errors.Is(err, store.ErrNotFound) { - http.Error(w, "Verbindung konnte nicht getrennt werden: "+err.Error(), http.StatusInternalServerError) - return - } - http.Redirect(w, r, "/verbindungen", http.StatusSeeOther) -} diff --git a/internal/web/oauth_handlers_test.go b/internal/web/oauth_handlers_test.go deleted file mode 100644 index 3c39a9f..0000000 --- a/internal/web/oauth_handlers_test.go +++ /dev/null @@ -1,225 +0,0 @@ -package web_test - -import ( - "context" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - "time" - - "github.com/netcell-it/deklarix/internal/socialconnect" -) - -// fakeConnector ist ein socialconnect.Connector-Fake für Tests — kein -// echter HTTP-Aufruf gegen Instagram/TikTok nötig. -type fakeConnector struct { - platform string - token socialconnect.Token - err error -} - -func (f fakeConnector) Platform() string { return f.platform } -func (f fakeConnector) AuthorizationURL(state string) string { - return "https://provider.example/authorize?state=" + state + "&platform=" + f.platform -} -func (f fakeConnector) Exchange(ctx context.Context, code string) (socialconnect.Token, error) { - if f.err != nil { - return socialconnect.Token{}, f.err - } - return f.token, nil -} - -func TestConnectionsListShowsUnconfiguredPlatformsWithoutConnectButton(t *testing.T) { - s, _, cookie := newAuthedTestServer(t, fakeExtractor{}) - - resp := getWithCookie(t, s, cookie, "/verbindungen") - if resp.Code != http.StatusOK { - t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) - } - body := resp.Body.String() - if strings.Contains(body, "/oauth/instagram/start") || strings.Contains(body, "/oauth/tiktok/start") { - t.Errorf("expected no connect links when no connector is configured, got: %s", body) - } - if !strings.Contains(body, "instagram") || !strings.Contains(body, "tiktok") { - t.Errorf("expected both platforms to be listed regardless of configuration, got: %s", body) - } -} - -func TestConnectionsListShowsConnectButtonWhenConfigured(t *testing.T) { - fs := newFakeStore() - connectors := map[string]socialconnect.Connector{"instagram": fakeConnector{platform: "instagram"}} - s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors) - cookie := seedAccount(t, fs, "Mandant", "mandant@example.com") - - resp := getWithCookie(t, s, cookie, "/verbindungen") - body := resp.Body.String() - if !strings.Contains(body, "/oauth/instagram/start") { - t.Errorf("expected an Instagram connect link, got: %s", body) - } - if strings.Contains(body, "/oauth/tiktok/start") { - t.Errorf("expected no TikTok connect link (not configured), got: %s", body) - } -} - -func TestOAuthStartRedirectsToProviderAndSetsStateCookie(t *testing.T) { - fs := newFakeStore() - connectors := map[string]socialconnect.Connector{"instagram": fakeConnector{platform: "instagram"}} - s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors) - cookie := seedAccount(t, fs, "Mandant", "mandant@example.com") - - req := httptest.NewRequest(http.MethodGet, "/oauth/instagram/start", nil) - req.AddCookie(cookie) - w := httptest.NewRecorder() - s.ServeHTTP(w, req) - - if w.Code != http.StatusSeeOther { - t.Fatalf("status = %d, want 303", w.Code) - } - loc := w.Header().Get("Location") - if !strings.HasPrefix(loc, "https://provider.example/authorize?") { - t.Fatalf("Location = %q, want a redirect to the provider", loc) - } - var stateCookie *http.Cookie - for _, c := range w.Result().Cookies() { - if c.Name == "deklarix_oauth_state" { - stateCookie = c - } - } - if stateCookie == nil || stateCookie.Value == "" { - t.Fatal("expected a non-empty deklarix_oauth_state cookie to be set") - } -} - -func TestOAuthStartRejectsUnconfiguredPlatform(t *testing.T) { - s, _, cookie := newAuthedTestServer(t, fakeExtractor{}) - - resp := getWithCookie(t, s, cookie, "/oauth/instagram/start") - if resp.Code != http.StatusNotFound { - t.Fatalf("status = %d, want 404 for an unconfigured platform", resp.Code) - } -} - -func TestOAuthCallbackStoresConnectionOnValidState(t *testing.T) { - fs := newFakeStore() - expires := time.Now().Add(60 * 24 * time.Hour) - connectors := map[string]socialconnect.Connector{ - "instagram": fakeConnector{platform: "instagram", token: socialconnect.Token{ - AccessToken: "ig-token", PlatformUserID: "ig-user-1", ExpiresAt: expires, - }}, - } - s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors) - cookie := seedAccount(t, fs, "Mandant", "mandant@example.com") - - req := httptest.NewRequest(http.MethodGet, "/oauth/instagram/callback?code=der-code&state=gueltiger-state", nil) - req.AddCookie(cookie) - req.AddCookie(&http.Cookie{Name: "deklarix_oauth_state", Value: "gueltiger-state"}) - w := httptest.NewRecorder() - s.ServeHTTP(w, req) - - if w.Code != http.StatusSeeOther { - t.Fatalf("status = %d, want 303, body: %s", w.Code, w.Body.String()) - } - if loc := w.Header().Get("Location"); loc != "/verbindungen" { - t.Fatalf("Location = %q, want /verbindungen", loc) - } - - var accID string - for id := range fs.accounts { - accID = id - } - conn, err := fs.GetPlatformConnection(context.Background(), accID, "instagram") - if err != nil { - t.Fatalf("expected a stored connection, got err: %v", err) - } - if conn.AccessToken != "ig-token" || conn.PlatformUserID != "ig-user-1" { - t.Errorf("unexpected connection: %+v", conn) - } -} - -func TestOAuthCallbackRejectsMismatchedState(t *testing.T) { - fs := newFakeStore() - connectors := map[string]socialconnect.Connector{ - "instagram": fakeConnector{platform: "instagram", token: socialconnect.Token{AccessToken: "x", PlatformUserID: "y"}}, - } - s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors) - cookie := seedAccount(t, fs, "Mandant", "mandant@example.com") - - req := httptest.NewRequest(http.MethodGet, "/oauth/instagram/callback?code=der-code&state=falscher-state", nil) - req.AddCookie(cookie) - req.AddCookie(&http.Cookie{Name: "deklarix_oauth_state", Value: "anderer-state"}) - w := httptest.NewRecorder() - s.ServeHTTP(w, req) - - if w.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want 400 for a state mismatch", w.Code) - } -} - -func TestOAuthCallbackHandlesUserDenial(t *testing.T) { - fs := newFakeStore() - connectors := map[string]socialconnect.Connector{ - "instagram": fakeConnector{platform: "instagram"}, - } - s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors) - cookie := seedAccount(t, fs, "Mandant", "mandant@example.com") - - req := httptest.NewRequest(http.MethodGet, "/oauth/instagram/callback?error=access_denied", nil) - req.AddCookie(cookie) - w := httptest.NewRecorder() - s.ServeHTTP(w, req) - - if w.Code != http.StatusSeeOther { - t.Fatalf("status = %d, want 303 (redirect back, no error page)", w.Code) - } -} - -func TestDisconnectRemovesConnection(t *testing.T) { - fs := newFakeStore() - connectors := map[string]socialconnect.Connector{"instagram": fakeConnector{platform: "instagram"}} - s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors) - cookie := seedAccount(t, fs, "Mandant", "mandant@example.com") - var accID string - for id := range fs.accounts { - accID = id - } - if _, err := fs.UpsertPlatformConnection(context.Background(), accID, "instagram", "u1", "tok", "", nil); err != nil { - t.Fatalf("UpsertPlatformConnection: %v", err) - } - - resp := postForm(t, s, cookie, "/verbindungen/instagram/trennen", url.Values{}) - if resp.Code != http.StatusSeeOther { - t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String()) - } - if _, err := fs.GetPlatformConnection(context.Background(), accID, "instagram"); err == nil { - t.Fatal("expected the connection to be gone after disconnect") - } -} - -func TestConnectionsIsolatedPerTenant(t *testing.T) { - fs := newFakeStore() - connectors := map[string]socialconnect.Connector{"instagram": fakeConnector{platform: "instagram"}} - s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors) - cookieA := seedAccount(t, fs, "Mandant A", "a@example.com") - cookieB := seedAccount(t, fs, "Mandant B", "b@example.com") - - var accA string - for id, acc := range fs.accounts { - if acc.Name == "Mandant A" { - accA = id - } - } - if _, err := fs.UpsertPlatformConnection(context.Background(), accA, "instagram", "u1", "tok", "", nil); err != nil { - t.Fatalf("UpsertPlatformConnection: %v", err) - } - - respA := getWithCookie(t, s, cookieA, "/verbindungen") - if !strings.Contains(respA.Body.String(), "verbunden seit") { - t.Errorf("expected Mandant A to see their own connection, got: %s", respA.Body.String()) - } - respB := getWithCookie(t, s, cookieB, "/verbindungen") - if strings.Contains(respB.Body.String(), "verbunden seit") { - t.Errorf("expected Mandant B to see no connection, got: %s", respB.Body.String()) - } -} diff --git a/internal/web/server.go b/internal/web/server.go index 3988fdd..9b9e380 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -1,6 +1,6 @@ // Package web ist die HTTP-Schicht: Routing, Templates, Handler. // Bewusst html/template + htmx, kein Frontend-Build (siehe CLAUDE.md, -// Stack). Jede Submission gehört einem Account (Mandant); Auth-Cookies +// Stack). Jeder Antrag gehört einem Account (Mandant); Auth-Cookies // binden einen Request an einen angemeldeten Nutzer und damit an dessen // Account — siehe middleware.go. package web @@ -13,10 +13,6 @@ import ( "net/http" "time" - "github.com/netcell-it/deklarix/internal/evidence" - "github.com/netcell-it/deklarix/internal/extract" - "github.com/netcell-it/deklarix/internal/rules" - "github.com/netcell-it/deklarix/internal/socialconnect" "github.com/netcell-it/deklarix/internal/store" ) @@ -26,42 +22,14 @@ var templatesFS embed.FS //go:embed static/* var staticFS embed.FS -// Extractor ist die Schnittstelle, die der Server für Stufe 1 braucht. -// *extract.Engine erfüllt sie (regelbasiert, kein externer Dienst); -// Tests injizieren einen Fake, um Facts unabhängig von der echten -// Erkennungslogik vorzugeben. -type Extractor interface { - Extract(ctx context.Context, in extract.Input) (extract.Result, error) - ModelVersion() string -} - // Store ist die Schnittstelle, die der Server zur Persistenz braucht — // bewusst schmal (nur was diese Handler tatsächlich nutzen), nicht der // komplette *store.Store. *store.Store erfüllt sie; Tests injizieren // einen Fake statt eine echte Datenbank zu brauchen. type Store interface { - CreateSubmission(ctx context.Context, accountID, platform, postType, caption string) (store.Submission, error) - GetSubmission(ctx context.Context, id string) (store.Submission, error) - SetSubmissionStatus(ctx context.Context, id, status string) error - CreateExtraction(ctx context.Context, submissionID string, payload []byte, modelVersion, promptVersion string) (store.Extraction, error) - GetLatestExtraction(ctx context.Context, submissionID string) (store.Extraction, error) - CreateFinding(ctx context.Context, submissionID string, extractionID *string, ruleID string, ruleVersion int, severity, title, fix string, sources []string) (store.Finding, error) - ListCurrentFindings(ctx context.Context, submissionID string) ([]store.Finding, error) - CreateEvidencePackage(ctx context.Context, submissionID, dossierPath, sha256Hex string, timestampToken []byte) (store.EvidencePackage, error) - GetLatestEvidencePackage(ctx context.Context, submissionID string) (store.EvidencePackage, error) - ListSubmissionsForAccount(ctx context.Context, accountID string) ([]store.SubmissionSummary, error) - - CreateParticipant(ctx context.Context, submissionID, role, name string, vorgegeben, freigegeben bool) (store.Participant, error) - ListParticipants(ctx context.Context, submissionID string) ([]store.Participant, error) - GetParticipant(ctx context.Context, id string) (store.Participant, error) - UpdateParticipant(ctx context.Context, id string, vorgegeben, freigegeben bool) (store.Participant, error) - DeleteParticipant(ctx context.Context, id string) error - CreateAccount(ctx context.Context, name string) (store.Account, error) GetAccount(ctx context.Context, id string) (store.Account, error) ListAccounts(ctx context.Context) ([]store.Account, error) - ListVerifiedKanzleien(ctx context.Context) ([]store.Account, error) - SetAccountVerified(ctx context.Context, id string, verified bool) (store.Account, error) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error) GetUserByEmail(ctx context.Context, email string) (store.User, error) GetUser(ctx context.Context, id string) (store.User, error) @@ -71,51 +39,25 @@ type Store interface { DeleteSession(ctx context.Context, token string) error CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error) ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error) - CreateAsset(ctx context.Context, submissionID, kind, purpose, path, sha256Hex string) (store.Asset, error) - GetLatestAssetForSubmission(ctx context.Context, submissionID string) (store.Asset, error) - ListAssetsForSubmission(ctx context.Context, submissionID string) ([]store.Asset, error) - - UpsertPlatformConnection(ctx context.Context, accountID, platform, platformUserID, accessToken, refreshToken string, expiresAt *time.Time) (store.PlatformConnection, error) - ListPlatformConnectionsForAccount(ctx context.Context, accountID string) ([]store.PlatformConnection, error) - DeletePlatformConnection(ctx context.Context, accountID, platform string) error } // Server bündelt Routing und Abhängigkeiten der Web-Schicht. type Server struct { - mux *http.ServeMux - extractor Extractor - ruleSet []rules.Rule - store Store - timestamper evidence.Timestamper - dossierDir string - assetDir string - connectors map[string]socialconnect.Connector - templates *template.Template + mux *http.ServeMux + store Store + templates *template.Template } -// NewServer erstellt den Server. ruleSet kommt von rules.Load und wird -// einmal beim Start geladen, nicht pro Request. dossierDir ist das -// Verzeichnis, in das erzeugte Nachweis-Dossiers geschrieben werden; -// assetDir das Verzeichnis für hochgeladene Standbilder. connectors -// enthält nur die Plattformen, für die echte Client-Credentials -// konfiguriert sind (siehe cmd/deklarix/main.go) — eine leere oder nil -// Map ist gültig, dann zeigt /verbindungen "nicht konfiguriert" statt -// eines Verbinden-Buttons. -func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper evidence.Timestamper, dossierDir, assetDir string, connectors map[string]socialconnect.Connector) (*Server, error) { +// NewServer erstellt den Server. +func NewServer(st Store) (*Server, error) { tmpl, err := template.ParseFS(templatesFS, "templates/*.html") if err != nil { return nil, fmt.Errorf("web: templates parsen: %w", err) } s := &Server{ - extractor: extractor, - ruleSet: ruleSet, - store: st, - timestamper: timestamper, - dossierDir: dossierDir, - assetDir: assetDir, - connectors: connectors, - templates: tmpl, + store: st, + templates: tmpl, } mux := http.NewServeMux() @@ -126,25 +68,10 @@ func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper mux.HandleFunc("POST /login", s.handleLogin) mux.HandleFunc("POST /logout", s.handleLogout) mux.HandleFunc("GET /{$}", s.requirePage(s.handleIndex)) - mux.HandleFunc("POST /pruefen", s.requireAPI(s.handleCheck)) - mux.HandleFunc("POST /veroeffentlichen", s.requireAPI(s.handleArchive)) - mux.HandleFunc("GET /dossier/{id}", s.requireAPI(s.handleDossierDownload)) - mux.HandleFunc("GET /beitraege", s.requirePage(s.handleSubmissionList)) - mux.HandleFunc("GET /beitraege/{id}", s.requirePage(s.handleSubmissionDetail)) - mux.HandleFunc("POST /beitraege/{id}/beteiligte", s.requireAPI(s.handleAddParticipant)) - mux.HandleFunc("POST /beitraege/{id}/beteiligte/{pid}/aktualisieren", s.requireAPI(s.handleUpdateParticipant)) - mux.HandleFunc("POST /beitraege/{id}/beteiligte/{pid}/loeschen", s.requireAPI(s.handleDeleteParticipant)) - mux.HandleFunc("POST /beitraege/{id}/insights", s.requireAPI(s.handleAddInsightsAsset)) - mux.HandleFunc("GET /kanzleien", s.handlePublicKanzleiList) - mux.HandleFunc("GET /verbindungen", s.requirePage(s.handleConnectionsList)) - mux.HandleFunc("GET /oauth/{platform}/start", s.requirePage(s.handleOAuthStart)) - mux.HandleFunc("GET /oauth/{platform}/callback", s.requirePage(s.handleOAuthCallback)) - mux.HandleFunc("POST /verbindungen/{platform}/trennen", s.requireAPI(s.handleDisconnect)) - mux.HandleFunc("GET /admin", s.requireAdmin(s.handleAdminDashboard)) - mux.HandleFunc("GET /admin/accounts", s.requireAdmin(s.handleAdminAccountList)) - mux.HandleFunc("GET /admin/accounts/{id}", s.requireAdmin(s.handleAdminAccountDetail)) - mux.HandleFunc("POST /admin/accounts/{id}/verifizieren", s.requireAdmin(s.handleAdminSetVerified)) - mux.HandleFunc("GET /admin/audit-log", s.requireAdmin(s.handleAdminAuditLog)) + mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard)) + mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList)) + mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail)) + mux.HandleFunc("GET /betreiber/audit-log", s.requireBetreiber(s.handleBetreiberAuditLog)) mux.Handle("GET /static/", http.FileServerFS(staticFS)) s.mux = mux diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 5c6d548..e53515c 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -2,58 +2,23 @@ package web_test import ( "context" - "crypto" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/x509" - "crypto/x509/pkix" - "encoding/asn1" "errors" "fmt" - "math/big" "net/http" "net/http/httptest" "net/url" - "os" "strings" "sync" "testing" "time" - "github.com/digitorus/timestamp" - "github.com/netcell-it/deklarix/internal/auth" - "github.com/netcell-it/deklarix/internal/extract" - "github.com/netcell-it/deklarix/internal/rules" - "github.com/netcell-it/deklarix/internal/socialconnect" "github.com/netcell-it/deklarix/internal/store" "github.com/netcell-it/deklarix/internal/web" ) const testSessionCookie = "deklarix_session" -// ─── Fakes ──────────────────────────────────────────────────────────── - -type fakeExtractor struct { - facts rules.Facts - raw []byte - err error -} - -func (f fakeExtractor) Extract(ctx context.Context, in extract.Input) (extract.Result, error) { - if f.err != nil { - return extract.Result{}, f.err - } - raw := f.raw - if raw == nil { - raw = []byte(`{}`) - } - return extract.Result{Facts: f.facts, RawJSON: raw}, nil -} - -func (f fakeExtractor) ModelVersion() string { return "fake-model-v0" } - // fakeStore ist eine In-Memory-Implementierung von web.Store, damit die // Handler-Tests keine echte Postgres-Instanz brauchen. type fakeStore struct { @@ -63,32 +28,15 @@ type fakeStore struct { users map[string]store.User usersByEmail map[string]string // email -> user id sessions map[string]store.Session - submissions map[string]store.Submission - extractions map[string]store.Extraction - findings map[string][]store.Finding - evidencePkgs map[string]store.EvidencePackage - participants map[string]store.Participant auditLog []store.AuditEntry - assets map[string][]store.Asset // submissionID -> alle Assets, aeltestes zuerst - - // platformConnections ist verschachtelt nach accountID -> platform, - // wie die UNIQUE(account_id, platform)-Beschränkung der echten Tabelle. - platformConnections map[string]map[string]store.PlatformConnection } func newFakeStore() *fakeStore { return &fakeStore{ - accounts: map[string]store.Account{}, - users: map[string]store.User{}, - usersByEmail: map[string]string{}, - sessions: map[string]store.Session{}, - submissions: map[string]store.Submission{}, - extractions: map[string]store.Extraction{}, - findings: map[string][]store.Finding{}, - evidencePkgs: map[string]store.EvidencePackage{}, - participants: map[string]store.Participant{}, - assets: map[string][]store.Asset{}, - platformConnections: map[string]map[string]store.PlatformConnection{}, + accounts: map[string]store.Account{}, + users: map[string]store.User{}, + usersByEmail: map[string]string{}, + sessions: map[string]store.Session{}, } } @@ -125,78 +73,6 @@ func (f *fakeStore) ListAccounts(ctx context.Context) ([]store.Account, error) { return out, nil } -func (f *fakeStore) ListVerifiedKanzleien(ctx context.Context) ([]store.Account, error) { - f.mu.Lock() - defer f.mu.Unlock() - var out []store.Account - for _, acc := range f.accounts { - if !acc.Verified { - continue - } - hasKanzlei := false - for _, u := range f.users { - if u.AccountID == acc.ID && u.Role == "kanzlei" { - hasKanzlei = true - break - } - } - if hasKanzlei { - out = append(out, acc) - } - } - return out, nil -} - -func (f *fakeStore) SetAccountVerified(ctx context.Context, id string, verified bool) (store.Account, error) { - f.mu.Lock() - defer f.mu.Unlock() - acc, ok := f.accounts[id] - if !ok { - return store.Account{}, store.ErrNotFound - } - acc.Verified = verified - f.accounts[id] = acc - return acc, nil -} - -func (f *fakeStore) ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error) { - f.mu.Lock() - defer f.mu.Unlock() - var out []store.User - for _, u := range f.users { - if u.AccountID == accountID { - out = append(out, u) - } - } - return out, nil -} - -func (f *fakeStore) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error) { - f.mu.Lock() - defer f.mu.Unlock() - e := store.AuditEntry{ - ID: f.newID(), ActorUserID: actorUserID, Action: action, TargetType: targetType, - TargetID: targetID, Details: details, CreatedAt: time.Now(), - } - f.auditLog = append(f.auditLog, e) - return e, nil -} - -func (f *fakeStore) ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error) { - f.mu.Lock() - defer f.mu.Unlock() - // Neueste zuerst, wie die echte Store-Implementierung (ORDER BY - // created_at DESC) — hier reicht eine Umkehrung der Einfuegereihenfolge. - out := make([]store.AuditEntry, len(f.auditLog)) - for i, e := range f.auditLog { - out[len(f.auditLog)-1-i] = e - } - if len(out) > limit { - out = out[:limit] - } - return out, nil -} - func (f *fakeStore) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error) { f.mu.Lock() defer f.mu.Unlock() @@ -232,6 +108,18 @@ func (f *fakeStore) GetUser(ctx context.Context, id string) (store.User, error) return u, nil } +func (f *fakeStore) ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []store.User + for _, u := range f.users { + if u.AccountID == accountID { + out = append(out, u) + } + } + return out, nil +} + func (f *fakeStore) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error) { f.mu.Lock() defer f.mu.Unlock() @@ -257,365 +145,44 @@ func (f *fakeStore) DeleteSession(ctx context.Context, token string) error { return nil } -func (f *fakeStore) CreateSubmission(ctx context.Context, accountID, platform, postType, caption string) (store.Submission, error) { +func (f *fakeStore) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error) { f.mu.Lock() defer f.mu.Unlock() - sub := store.Submission{ - ID: f.newID(), AccountID: accountID, Platform: platform, PostType: postType, Caption: caption, - Status: "draft", CreatedAt: time.Now(), UpdatedAt: time.Now(), + e := store.AuditEntry{ + ID: f.newID(), ActorUserID: actorUserID, Action: action, TargetType: targetType, + TargetID: targetID, Details: details, CreatedAt: time.Now(), } - f.submissions[sub.ID] = sub - return sub, nil + f.auditLog = append(f.auditLog, e) + return e, nil } -func (f *fakeStore) GetSubmission(ctx context.Context, id string) (store.Submission, error) { +func (f *fakeStore) ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error) { f.mu.Lock() defer f.mu.Unlock() - sub, ok := f.submissions[id] - if !ok { - return store.Submission{}, store.ErrNotFound + out := make([]store.AuditEntry, len(f.auditLog)) + for i, e := range f.auditLog { + out[len(f.auditLog)-1-i] = e } - return sub, nil -} - -func (f *fakeStore) SetSubmissionStatus(ctx context.Context, id, status string) error { - f.mu.Lock() - defer f.mu.Unlock() - sub, ok := f.submissions[id] - if !ok { - return fmt.Errorf("fakeStore: submission %s nicht gefunden", id) - } - sub.Status = status - f.submissions[id] = sub - return nil -} - -func (f *fakeStore) CreateExtraction(ctx context.Context, submissionID string, payload []byte, modelVersion, promptVersion string) (store.Extraction, error) { - f.mu.Lock() - defer f.mu.Unlock() - ext := store.Extraction{ - ID: f.newID(), SubmissionID: submissionID, Payload: payload, - ModelVersion: modelVersion, PromptVersion: promptVersion, CreatedAt: time.Now(), - } - f.extractions[submissionID] = ext - return ext, nil -} - -func (f *fakeStore) GetLatestExtraction(ctx context.Context, submissionID string) (store.Extraction, error) { - f.mu.Lock() - defer f.mu.Unlock() - ext, ok := f.extractions[submissionID] - if !ok { - return store.Extraction{}, fmt.Errorf("fakeStore: keine Extraktion fuer %s", submissionID) - } - return ext, nil -} - -func (f *fakeStore) CreateFinding(ctx context.Context, submissionID string, extractionID *string, ruleID string, ruleVersion int, severity, title, fix string, sources []string) (store.Finding, error) { - f.mu.Lock() - defer f.mu.Unlock() - finding := store.Finding{ - ID: f.newID(), SubmissionID: submissionID, ExtractionID: extractionID, - RuleID: ruleID, RuleVersion: ruleVersion, Severity: severity, - Title: title, Fix: fix, Sources: sources, CreatedAt: time.Now(), - } - f.findings[submissionID] = append(f.findings[submissionID], finding) - return finding, nil -} - -func (f *fakeStore) ListCurrentFindings(ctx context.Context, submissionID string) ([]store.Finding, error) { - f.mu.Lock() - defer f.mu.Unlock() - return f.findings[submissionID], nil -} - -func (f *fakeStore) CreateEvidencePackage(ctx context.Context, submissionID, dossierPath, sha256Hex string, timestampToken []byte) (store.EvidencePackage, error) { - f.mu.Lock() - defer f.mu.Unlock() - pkg := store.EvidencePackage{ - ID: f.newID(), SubmissionID: submissionID, DossierPath: dossierPath, - SHA256: sha256Hex, TimestampToken: timestampToken, CreatedAt: time.Now(), - } - f.evidencePkgs[submissionID] = pkg - return pkg, nil -} - -func (f *fakeStore) GetLatestEvidencePackage(ctx context.Context, submissionID string) (store.EvidencePackage, error) { - f.mu.Lock() - defer f.mu.Unlock() - pkg, ok := f.evidencePkgs[submissionID] - if !ok { - return store.EvidencePackage{}, fmt.Errorf("fakeStore: kein evidence package fuer %s", submissionID) - } - return pkg, nil -} - -func (f *fakeStore) CreateAsset(ctx context.Context, submissionID, kind, purpose, path, sha256Hex string) (store.Asset, error) { - f.mu.Lock() - defer f.mu.Unlock() - a := store.Asset{ - ID: f.newID(), SubmissionID: submissionID, Kind: kind, Purpose: purpose, - Path: path, SHA256: sha256Hex, CreatedAt: time.Now(), - } - f.assets[submissionID] = append(f.assets[submissionID], a) - return a, nil -} - -// GetLatestAssetForSubmission liefert wie die echte Store-Implementierung -// nur das zuletzt hochgeladene Asset mit purpose="initial". -func (f *fakeStore) GetLatestAssetForSubmission(ctx context.Context, submissionID string) (store.Asset, error) { - f.mu.Lock() - defer f.mu.Unlock() - var latest store.Asset - found := false - for _, a := range f.assets[submissionID] { - if a.Purpose == "initial" { - latest = a - found = true - } - } - if !found { - return store.Asset{}, store.ErrNotFound - } - return latest, nil -} - -func (f *fakeStore) ListAssetsForSubmission(ctx context.Context, submissionID string) ([]store.Asset, error) { - f.mu.Lock() - defer f.mu.Unlock() - return f.assets[submissionID], nil -} - -func (f *fakeStore) UpsertPlatformConnection(ctx context.Context, accountID, platform, platformUserID, accessToken, refreshToken string, expiresAt *time.Time) (store.PlatformConnection, error) { - f.mu.Lock() - defer f.mu.Unlock() - byPlatform, ok := f.platformConnections[accountID] - if !ok { - byPlatform = map[string]store.PlatformConnection{} - f.platformConnections[accountID] = byPlatform - } - c := store.PlatformConnection{ - ID: f.newID(), AccountID: accountID, Platform: platform, PlatformUserID: platformUserID, - AccessToken: accessToken, RefreshToken: refreshToken, ExpiresAt: expiresAt, ConnectedAt: time.Now(), - } - if existing, ok := byPlatform[platform]; ok { - c.ID = existing.ID - } - byPlatform[platform] = c - return c, nil -} - -func (f *fakeStore) ListPlatformConnectionsForAccount(ctx context.Context, accountID string) ([]store.PlatformConnection, error) { - f.mu.Lock() - defer f.mu.Unlock() - var out []store.PlatformConnection - for _, c := range f.platformConnections[accountID] { - out = append(out, c) + if len(out) > limit { + out = out[:limit] } return out, nil } -func (f *fakeStore) GetPlatformConnection(ctx context.Context, accountID, platform string) (store.PlatformConnection, error) { - f.mu.Lock() - defer f.mu.Unlock() - c, ok := f.platformConnections[accountID][platform] - if !ok { - return store.PlatformConnection{}, store.ErrNotFound - } - return c, nil -} - -func (f *fakeStore) DeletePlatformConnection(ctx context.Context, accountID, platform string) error { - f.mu.Lock() - defer f.mu.Unlock() - byPlatform, ok := f.platformConnections[accountID] - if !ok { - return store.ErrNotFound - } - if _, ok := byPlatform[platform]; !ok { - return store.ErrNotFound - } - delete(byPlatform, platform) - return nil -} - -func (f *fakeStore) ListSubmissionsForAccount(ctx context.Context, accountID string) ([]store.SubmissionSummary, error) { - f.mu.Lock() - defer f.mu.Unlock() - var out []store.SubmissionSummary - for _, sub := range f.submissions { - if sub.AccountID != accountID { - continue - } - sum := store.SubmissionSummary{Submission: sub} - rank := 0 - for _, finding := range f.findings[sub.ID] { - sum.FindingCount++ - r := severityRank(finding.Severity) - if r > rank { - rank = r - sum.HighestSeverity = finding.Severity - } - } - out = append(out, sum) - } - return out, nil -} - -func severityRank(severity string) int { - switch severity { - case "hoch": - return 3 - case "mittel": - return 2 - case "niedrig": - return 1 - default: - return 0 - } -} - -func (f *fakeStore) CreateParticipant(ctx context.Context, submissionID, role, name string, vorgegeben, freigegeben bool) (store.Participant, error) { - f.mu.Lock() - defer f.mu.Unlock() - p := store.Participant{ - ID: f.newID(), SubmissionID: submissionID, Role: role, Name: name, - Vorgegeben: vorgegeben, Freigegeben: freigegeben, CreatedAt: time.Now(), - } - if freigegeben { - now := time.Now() - p.ApprovedAt = &now - } - f.participants[p.ID] = p - return p, nil -} - -func (f *fakeStore) ListParticipants(ctx context.Context, submissionID string) ([]store.Participant, error) { - f.mu.Lock() - defer f.mu.Unlock() - var out []store.Participant - for _, p := range f.participants { - if p.SubmissionID == submissionID { - out = append(out, p) - } - } - return out, nil -} - -func (f *fakeStore) GetParticipant(ctx context.Context, id string) (store.Participant, error) { - f.mu.Lock() - defer f.mu.Unlock() - p, ok := f.participants[id] - if !ok { - return store.Participant{}, store.ErrNotFound - } - return p, nil -} - -func (f *fakeStore) UpdateParticipant(ctx context.Context, id string, vorgegeben, freigegeben bool) (store.Participant, error) { - f.mu.Lock() - defer f.mu.Unlock() - p, ok := f.participants[id] - if !ok { - return store.Participant{}, store.ErrNotFound - } - p.Vorgegeben = vorgegeben - p.Freigegeben = freigegeben - if freigegeben && p.ApprovedAt == nil { - now := time.Now() - p.ApprovedAt = &now - } - f.participants[id] = p - return p, nil -} - -func (f *fakeStore) DeleteParticipant(ctx context.Context, id string) error { - f.mu.Lock() - defer f.mu.Unlock() - if _, ok := f.participants[id]; !ok { - return store.ErrNotFound - } - delete(f.participants, id) - return nil -} - -// fakeTimestamper liefert einen offline erzeugten, strukturell gültigen -// (selbstsignierten) RFC-3161-Token — genug, damit evidence.TimestampTime -// ihn parsen kann, ohne eine echte TSA zu brauchen. -type fakeTimestamper struct{} - -func (fakeTimestamper) Timestamp(ctx context.Context, hash []byte) ([]byte, error) { - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return nil, err - } - now := time.Now() - certTemplate := &x509.Certificate{ - SerialNumber: big.NewInt(1), - Subject: pkix.Name{CommonName: "deklarix-test-tsa"}, - NotBefore: now.Add(-time.Hour), - NotAfter: now.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 { - return nil, err - } - cert, err := x509.ParseCertificate(certDER) - if err != nil { - return nil, err - } - ts := timestamp.Timestamp{ - HashAlgorithm: crypto.SHA256, - HashedMessage: hash, - Time: now, - SerialNumber: big.NewInt(1), - Policy: asn1.ObjectIdentifier{1, 2, 3}, - } - respDER, err := ts.CreateResponse(cert, key) - if err != nil { - return nil, err - } - parsed, err := timestamp.ParseResponse(respDER) - if err != nil { - return nil, err - } - return parsed.RawToken, nil -} - // ─── Test-Setup ─────────────────────────────────────────────────────── -func loadRealRules(t *testing.T) []rules.Rule { +func newServer(t *testing.T, fs *fakeStore) *web.Server { t.Helper() - rs, err := rules.Load(os.DirFS("../../rules")) - if err != nil { - t.Fatalf("rules.Load: %v", err) - } - return rs -} - -func newServer(t *testing.T, ex web.Extractor, fs *fakeStore) *web.Server { - t.Helper() - return newServerWithConnectors(t, ex, fs, nil) -} - -func newServerWithConnectors(t *testing.T, ex web.Extractor, fs *fakeStore, connectors map[string]socialconnect.Connector) *web.Server { - t.Helper() - s, err := web.NewServer(ex, loadRealRules(t), fs, fakeTimestamper{}, t.TempDir(), t.TempDir(), connectors) + s, err := web.NewServer(fs) if err != nil { t.Fatalf("NewServer: %v", err) } return s } -// seedAccount legt direkt im fakeStore (ohne HTTP) einen Account, einen -// Nutzer und eine gültige Sitzung an und liefert das Session-Cookie. -func seedAccount(t *testing.T, fs *fakeStore, accountName, email string) *http.Cookie { - t.Helper() - return seedAccountWithRole(t, fs, accountName, email, "creator") -} - +// seedAccountWithRole legt direkt im fakeStore (ohne HTTP) einen +// Account, einen Nutzer und eine gültige Sitzung an und liefert das +// Session-Cookie. func seedAccountWithRole(t *testing.T, fs *fakeStore, accountName, email, role string) *http.Cookie { t.Helper() ctx := context.Background() @@ -641,12 +208,15 @@ func seedAccountWithRole(t *testing.T, fs *fakeStore, accountName, email, role s return &http.Cookie{Name: testSessionCookie, Value: token} } -// newAuthedTestServer ist der Standardfall für Tests, die sich nicht für -// Auth selbst interessieren: ein Server plus ein fertig angemeldeter Mandant. -func newAuthedTestServer(t *testing.T, ex web.Extractor) (*web.Server, *fakeStore, *http.Cookie) { +func seedAccount(t *testing.T, fs *fakeStore, accountName, email string) *http.Cookie { + t.Helper() + return seedAccountWithRole(t, fs, accountName, email, "mitarbeiter") +} + +func newAuthedTestServer(t *testing.T) (*web.Server, *fakeStore, *http.Cookie) { t.Helper() fs := newFakeStore() - s := newServer(t, ex, fs) + s := newServer(t, fs) cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com") return s, fs, cookie } @@ -674,21 +244,10 @@ func getWithCookie(t *testing.T, s *web.Server, cookie *http.Cookie, path string return w } -func checkForm(extra ...string) url.Values { - v := url.Values{ - "platform": {"instagram"}, "post_type": {"reel"}, - "consideration": {"bezahlt"}, "caption": {"Werbung: ..."}, - } - for i := 0; i+1 < len(extra); i += 2 { - v.Set(extra[i], extra[i+1]) - } - return v -} - // ─── Tests: Health, statische Assets (kein Auth nötig) ──────────────── func TestHandleHealth(t *testing.T) { - s, _, _ := newAuthedTestServer(t, fakeExtractor{}) + s, _, _ := newAuthedTestServer(t) req := httptest.NewRequest(http.MethodGet, "/health", nil) w := httptest.NewRecorder() @@ -703,7 +262,7 @@ func TestHandleHealth(t *testing.T) { } func TestHandleStaticServesHTMX(t *testing.T) { - s, _, _ := newAuthedTestServer(t, fakeExtractor{}) + s, _, _ := newAuthedTestServer(t) req := httptest.NewRequest(http.MethodGet, "/static/htmx.min.js", nil) w := httptest.NewRecorder() @@ -721,11 +280,11 @@ func TestHandleStaticServesHTMX(t *testing.T) { func TestRegisterThenLoginThenAccessProtectedPage(t *testing.T) { fs := newFakeStore() - s := newServer(t, fakeExtractor{}, fs) + s := newServer(t, fs) regResp := postForm(t, s, nil, "/register", url.Values{ - "account_name": {"Meine Agentur"}, "email": {"neu@example.com"}, - "password": {"ein-sicheres-passwort"}, "role": {"agentur"}, + "account_name": {"Meine Firma"}, "email": {"neu@example.com"}, + "password": {"ein-sicheres-passwort"}, }) if regResp.Code != http.StatusSeeOther { t.Fatalf("register status = %d, want 303, body: %s", regResp.Code, regResp.Body.String()) @@ -735,19 +294,26 @@ func TestRegisterThenLoginThenAccessProtectedPage(t *testing.T) { t.Fatal("expected a session cookie to be set after registration") } - // Mit dem gesetzten Cookie muss die geschützte Startseite erreichbar sein. indexResp := getWithCookie(t, s, cookies[0], "/") if indexResp.Code != http.StatusOK { t.Fatalf("index status with fresh session = %d, want 200", indexResp.Code) } + + var created store.User + for _, u := range fs.users { + created = u + } + if created.Role != "admin" { + t.Fatalf("expected the first user of a new company to have role admin, got %q", created.Role) + } } func TestRegisterRejectsDuplicateEmail(t *testing.T) { fs := newFakeStore() - s := newServer(t, fakeExtractor{}, fs) + s := newServer(t, fs) form := url.Values{ "account_name": {"A"}, "email": {"doppelt@example.com"}, - "password": {"ein-sicheres-passwort"}, "role": {"creator"}, + "password": {"ein-sicheres-passwort"}, } if resp := postForm(t, s, nil, "/register", form); resp.Code != http.StatusSeeOther { t.Fatalf("first register status = %d, want 303", resp.Code) @@ -763,7 +329,7 @@ func TestRegisterRejectsDuplicateEmail(t *testing.T) { func TestLoginWithCorrectPassword(t *testing.T) { fs := newFakeStore() - s := newServer(t, fakeExtractor{}, fs) + s := newServer(t, fs) hash, err := auth.HashPassword("richtiges-passwort") if err != nil { t.Fatalf("HashPassword: %v", err) @@ -772,7 +338,7 @@ func TestLoginWithCorrectPassword(t *testing.T) { if err != nil { t.Fatalf("CreateAccount: %v", err) } - if _, err := fs.CreateUser(context.Background(), acc.ID, "bestehend@example.com", hash, "marke"); err != nil { + if _, err := fs.CreateUser(context.Background(), acc.ID, "bestehend@example.com", hash, "mitarbeiter"); err != nil { t.Fatalf("CreateUser: %v", err) } @@ -785,39 +351,39 @@ func TestLoginWithCorrectPassword(t *testing.T) { } } -func TestLoginRedirectsAdminToAdminDashboard(t *testing.T) { +func TestLoginRedirectsBetreiberToPlatformDashboard(t *testing.T) { fs := newFakeStore() - s := newServer(t, fakeExtractor{}, fs) + s := newServer(t, fs) hash, err := auth.HashPassword("admin-passwort") if err != nil { t.Fatalf("HashPassword: %v", err) } - acc, err := fs.CreateAccount(context.Background(), "Deklarix Admin") + acc, err := fs.CreateAccount(context.Background(), "Deklarix Betreiber") if err != nil { t.Fatalf("CreateAccount: %v", err) } - if _, err := fs.CreateUser(context.Background(), acc.ID, "admin@example.com", hash, "admin"); err != nil { + if _, err := fs.CreateUser(context.Background(), acc.ID, "betreiber@example.com", hash, "betreiber"); err != nil { t.Fatalf("CreateUser: %v", err) } - resp := postForm(t, s, nil, "/login", url.Values{"email": {"admin@example.com"}, "password": {"admin-passwort"}}) + resp := postForm(t, s, nil, "/login", url.Values{"email": {"betreiber@example.com"}, "password": {"admin-passwort"}}) if resp.Code != http.StatusSeeOther { t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String()) } - if loc := resp.Header().Get("Location"); loc != "/admin" { - t.Fatalf("Location = %q, want /admin for an admin login", loc) + if loc := resp.Header().Get("Location"); loc != "/betreiber" { + t.Fatalf("Location = %q, want /betreiber", loc) } } func TestLoginRejectsWrongPassword(t *testing.T) { fs := newFakeStore() - s := newServer(t, fakeExtractor{}, fs) + s := newServer(t, fs) hash, err := auth.HashPassword("richtiges-passwort") if err != nil { t.Fatalf("HashPassword: %v", err) } acc, _ := fs.CreateAccount(context.Background(), "X") - if _, err := fs.CreateUser(context.Background(), acc.ID, "x@example.com", hash, "marke"); err != nil { + if _, err := fs.CreateUser(context.Background(), acc.ID, "x@example.com", hash, "mitarbeiter"); err != nil { t.Fatalf("CreateUser: %v", err) } @@ -832,7 +398,7 @@ func TestLoginRejectsWrongPassword(t *testing.T) { func TestLoginRejectsUnknownEmailWithSameMessageAsWrongPassword(t *testing.T) { fs := newFakeStore() - s := newServer(t, fakeExtractor{}, fs) + s := newServer(t, fs) resp := postForm(t, s, nil, "/login", url.Values{"email": {"gibtsnicht@example.com"}, "password": {"irgendwas"}}) if !strings.Contains(resp.Body.String(), "E-Mail oder Passwort falsch") { @@ -841,7 +407,7 @@ func TestLoginRejectsUnknownEmailWithSameMessageAsWrongPassword(t *testing.T) { } func TestIndexRedirectsToLoginWithoutSession(t *testing.T) { - s, _, _ := newAuthedTestServer(t, fakeExtractor{}) + s, _, _ := newAuthedTestServer(t) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() @@ -855,17 +421,8 @@ func TestIndexRedirectsToLoginWithoutSession(t *testing.T) { } } -func TestCheckRejectsRequestsWithoutSession(t *testing.T) { - s, _, _ := newAuthedTestServer(t, fakeExtractor{}) - - w := postForm(t, s, nil, "/pruefen", checkForm()) - if w.Code != http.StatusUnauthorized { - t.Fatalf("status = %d, want 401 without a session", w.Code) - } -} - func TestLogoutClearsSession(t *testing.T) { - s, fs, cookie := newAuthedTestServer(t, fakeExtractor{}) + s, fs, cookie := newAuthedTestServer(t) if resp := getWithCookie(t, s, cookie, "/"); resp.Code != http.StatusOK { t.Fatalf("index before logout = %d, want 200", resp.Code) @@ -884,226 +441,106 @@ func TestLogoutClearsSession(t *testing.T) { } } -// ─── Tests: Pre-Publish-Prüfung (angemeldet) ────────────────────────── - -func TestHandleIndexRendersForm(t *testing.T) { - s, _, cookie := newAuthedTestServer(t, fakeExtractor{}) +func TestHandleIndexRendersForMitarbeiter(t *testing.T) { + s, _, cookie := newAuthedTestServer(t) resp := getWithCookie(t, s, cookie, "/") if resp.Code != http.StatusOK { t.Fatalf("status = %d, want 200", resp.Code) } - body := resp.Body.String() - for _, want := range []string{`name="platform"`, `name="post_type"`, `name="caption"`, `hx-post="/pruefen"`} { - if !strings.Contains(body, want) { - t.Errorf("index body missing %q\nbody: %s", want, body) +} + +// ─── Tests: Plattform-Bereich (nur Betreiber) ───────────────────────── + +func TestBetreiberRoutesRejectNonBetreiberWith404(t *testing.T) { + s, _, cookie := newAuthedTestServer(t) + + for _, path := range []string{"/betreiber", "/betreiber/accounts", "/betreiber/audit-log"} { + resp := getWithCookie(t, s, cookie, path) + if resp.Code != http.StatusNotFound { + t.Errorf("GET %s status = %d, want 404 for a non-betreiber user", path, resp.Code) } } } -func TestHandleCheckRejectsMissingFields(t *testing.T) { - s, _, cookie := newAuthedTestServer(t, fakeExtractor{}) +func TestBetreiberRoutesRedirectToLoginWithoutSession(t *testing.T) { + s, _, _ := newAuthedTestServer(t) - w := postForm(t, s, cookie, "/pruefen", url.Values{"platform": {"instagram"}}) - if w.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want 400 for missing fields", w.Code) + resp := getWithCookie(t, s, nil, "/betreiber") + if resp.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303 redirect to /login", resp.Code) } } -func TestHandleCheckPropagatesExtractionError(t *testing.T) { - s, _, cookie := newAuthedTestServer(t, fakeExtractor{err: fmt.Errorf("simulierter Extraktionsfehler")}) - - w := postForm(t, s, cookie, "/pruefen", checkForm()) - if w.Code != http.StatusBadGateway { - t.Fatalf("status = %d, want 502 when extraction fails", w.Code) - } -} - -func TestHandleCheckPersistsSubmissionExtractionAndFindings(t *testing.T) { - s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{ - Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationPaid, - DisclosurePresent: true, DisclosureWording: "Werbung", DisclosureBeforeCut: false, - }, raw: []byte(`{"gegenleistung":"bezahlt"}`)}) - - w := postForm(t, s, cookie, "/pruefen", checkForm()) - if w.Code != http.StatusOK { - t.Fatalf("status = %d, want 200, body: %s", w.Code, w.Body.String()) - } - if !strings.Contains(w.Body.String(), "WK-004") { - t.Fatalf("expected WK-004 in result, got: %s", w.Body.String()) - } - - if len(fs.submissions) != 1 { - t.Fatalf("expected 1 persisted submission, got %d", len(fs.submissions)) - } - var subID string - for id, sub := range fs.submissions { - subID = id - if sub.Status != "checked" { - t.Errorf("submission status = %q, want checked", sub.Status) - } - if sub.AccountID == "" { - t.Error("expected the submission to carry the current account's ID") - } - } - if _, ok := fs.extractions[subID]; !ok { - t.Error("expected an extraction to be persisted") - } - if len(fs.findings[subID]) != 1 || fs.findings[subID][0].RuleID != "WK-004" { - t.Errorf("expected exactly one persisted WK-004 finding, got %+v", fs.findings[subID]) - } -} - -func TestHandleCheckRendersNeedsClarificationAndDoesNotOfferArchive(t *testing.T) { - s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{ - Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationUnclear, - }}) - - w := postForm(t, s, cookie, "/pruefen", checkForm()) - body := w.Body.String() - if strings.Contains(body, "WK-") { - t.Errorf("expected no rule findings for an unclear extraction, got: %s", body) - } - if !strings.Contains(body, "nicht sicher bestimmt") { - t.Errorf("expected a clarification message, got: %s", body) - } - if strings.Contains(body, "/veroeffentlichen") { - t.Errorf("expected no archive option when clarification is needed, got: %s", body) - } - for _, sub := range fs.submissions { - if sub.Status == "checked" { - t.Error("submission should not be marked checked when clarification is needed") - } - } -} - -func TestHandleCheckCleanCaseHasNoFindingsAndOffersArchive(t *testing.T) { - s, _, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{ - Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone, - }}) - - w := postForm(t, s, cookie, "/pruefen", checkForm()) - if w.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", w.Code) - } - body := w.Body.String() - if strings.Contains(body, "WK-") { - t.Errorf("expected no findings for an organic post, got: %s", body) - } - if !strings.Contains(body, "Keine Kennzeichnungsrisiken") { - t.Errorf("expected the no-findings message, got: %s", body) - } - if !strings.Contains(body, "/veroeffentlichen") { - t.Errorf("expected an archive option even with no findings (still a real check), got: %s", body) - } -} - -// ─── Tests: Archivierung + Mandantentrennung ────────────────────────── - -func TestFullCheckThenArchiveFlow(t *testing.T) { - s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{ - Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationPaid, - DisclosurePresent: true, DisclosureWording: "Werbung", DisclosureBeforeCut: false, - }, raw: []byte(`{"gegenleistung":"bezahlt","kennzeichnung_vorhanden":true,"kennzeichnung_wortlaut":"Werbung","kennzeichnung_vor_kuerzung":false}`)}) - - checkResp := postForm(t, s, cookie, "/pruefen", checkForm()) - if checkResp.Code != http.StatusOK { - t.Fatalf("check status = %d, body: %s", checkResp.Code, checkResp.Body.String()) - } - if !strings.Contains(checkResp.Body.String(), `name="submission_id"`) { - t.Fatalf("expected a hidden submission_id field in the result, got: %s", checkResp.Body.String()) - } - - var subID string - for id := range fs.submissions { - subID = id - } - - archiveResp := postForm(t, s, cookie, "/veroeffentlichen", url.Values{"submission_id": {subID}}) - if archiveResp.Code != http.StatusOK { - t.Fatalf("archive status = %d, body: %s", archiveResp.Code, archiveResp.Body.String()) - } - if !strings.Contains(archiveResp.Body.String(), "/dossier/"+subID) { - t.Fatalf("expected a dossier download link, got: %s", archiveResp.Body.String()) - } - - pkg, ok := fs.evidencePkgs[subID] - if !ok { - t.Fatal("expected an evidence package to be persisted") - } - if fs.submissions[subID].Status != "published" { - t.Errorf("submission status = %q, want published", fs.submissions[subID].Status) - } - - downloadReq := httptest.NewRequest(http.MethodGet, "/dossier/"+subID, nil) - downloadReq.AddCookie(cookie) - downloadReq.SetPathValue("id", subID) - downloadW := httptest.NewRecorder() - s.ServeHTTP(downloadW, downloadReq) - if downloadW.Code != http.StatusOK { - t.Fatalf("download status = %d", downloadW.Code) - } - if !strings.HasPrefix(downloadW.Body.String(), "%PDF-") { - t.Fatal("downloaded dossier does not start with the PDF header") - } - - if _, err := os.Stat(pkg.DossierPath); err != nil { - t.Fatalf("dossier file does not exist on disk: %v", err) - } -} - -func TestHandleArchiveRejectsUnknownSubmission(t *testing.T) { - s, _, cookie := newAuthedTestServer(t, fakeExtractor{}) - - w := postForm(t, s, cookie, "/veroeffentlichen", url.Values{"submission_id": {"does-not-exist"}}) - if w.Code != http.StatusNotFound { - t.Fatalf("status = %d, want 404 for an unknown submission", w.Code) - } -} - -func TestTenantIsolationArchiveAndDownload(t *testing.T) { +func TestBetreiberDashboardAccessibleForBetreiber(t *testing.T) { fs := newFakeStore() - s := newServer(t, fakeExtractor{facts: rules.Facts{ - Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone, - }, raw: []byte(`{"gegenleistung":"keine","kennzeichnung_vorhanden":false,"kennzeichnung_wortlaut":"","kennzeichnung_vor_kuerzung":false}`)}, fs) + s := newServer(t, fs) + betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber") - cookieA := seedAccount(t, fs, "Mandant A", "a@example.com") - cookieB := seedAccount(t, fs, "Mandant B", "b@example.com") - - checkResp := postForm(t, s, cookieA, "/pruefen", checkForm()) - if checkResp.Code != http.StatusOK { - t.Fatalf("check status = %d", checkResp.Code) - } - var subID string - for id, sub := range fs.submissions { - if sub.AccountID != "" { - subID = id - } - } - if subID == "" { - t.Fatal("expected a submission to have been created for Mandant A") - } - - // Mandant B darf As Beitrag weder archivieren noch dessen Dossier - // abrufen — beides muss wie "nicht gefunden" aussehen, nicht wie ein - // expliziter Zugriffsfehler. - archiveResp := postForm(t, s, cookieB, "/veroeffentlichen", url.Values{"submission_id": {subID}}) - if archiveResp.Code != http.StatusNotFound { - t.Fatalf("cross-tenant archive status = %d, want 404", archiveResp.Code) - } - - // Damit A tatsächlich etwas zum Herunterladen hat: A archiviert selbst. - ownArchiveResp := postForm(t, s, cookieA, "/veroeffentlichen", url.Values{"submission_id": {subID}}) - if ownArchiveResp.Code != http.StatusOK { - t.Fatalf("own archive status = %d, body: %s", ownArchiveResp.Code, ownArchiveResp.Body.String()) - } - - downloadReq := httptest.NewRequest(http.MethodGet, "/dossier/"+subID, nil) - downloadReq.AddCookie(cookieB) - downloadReq.SetPathValue("id", subID) - downloadW := httptest.NewRecorder() - s.ServeHTTP(downloadW, downloadReq) - if downloadW.Code != http.StatusNotFound { - t.Fatalf("cross-tenant download status = %d, want 404", downloadW.Code) + resp := getWithCookie(t, s, betreiberCookie, "/betreiber") + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body: %s", resp.Code, resp.Body.String()) + } +} + +func TestBetreiberAccountListShowsAllAccountsAcrossTenants(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber") + seedAccount(t, fs, "Mandant A", "a@example.com") + seedAccount(t, fs, "Mandant B", "b@example.com") + + resp := getWithCookie(t, s, betreiberCookie, "/betreiber/accounts") + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) + } + body := resp.Body.String() + for _, want := range []string{"Mandant A", "Mandant B", "Deklarix Betreiber"} { + if !strings.Contains(body, want) { + t.Errorf("expected %q in the betreiber account list, got: %s", want, body) + } + } +} + +func TestBetreiberAccountDetailShowsUsers(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber") + seedAccount(t, fs, "Mandant A", "mitarbeiter-a@example.com") + + var accID string + for id, acc := range fs.accounts { + if acc.Name == "Mandant A" { + accID = id + } + } + + resp := getWithCookie(t, s, betreiberCookie, "/betreiber/accounts/"+accID) + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) + } + if !strings.Contains(resp.Body.String(), "mitarbeiter-a@example.com") { + t.Errorf("expected the user's email on the account detail page, got: %s", resp.Body.String()) + } +} + +func TestBetreiberAuditLogShowsEntries(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber") + betreiber, err := fs.GetUserByEmail(context.Background(), "betreiber@example.com") + if err != nil { + t.Fatalf("GetUserByEmail: %v", err) + } + if _, err := fs.CreateAuditEntry(context.Background(), betreiber.ID, "werkzeug.aktualisiert", "werkzeug", "w1", "manuell geprüft"); err != nil { + t.Fatalf("CreateAuditEntry: %v", err) + } + + resp := getWithCookie(t, s, betreiberCookie, "/betreiber/audit-log") + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) + } + if !strings.Contains(resp.Body.String(), "werkzeug.aktualisiert") { + t.Errorf("expected the audit entry on the audit log page, got: %s", resp.Body.String()) } } diff --git a/internal/web/templates/admin_account_detail.html b/internal/web/templates/admin_account_detail.html deleted file mode 100644 index 73a5aae..0000000 --- a/internal/web/templates/admin_account_detail.html +++ /dev/null @@ -1,39 +0,0 @@ -{{define "admin-account-detail"}} - -{{template "head" .}} - -{{template "nav" .Nav}} -
-

← Alle Accounts

-

{{.Name}}

-

Angelegt am {{.CreatedAt}}

- -

Nutzer

-
    - {{range .Users}} -
  • -
    {{.Email}} ({{.Role}})
    -
  • - {{end}} -
- -{{if .HasKanzlei}} -

Kanzlei-Verzeichnis

-{{if .Verified}} -

Status: verifiziert — im öffentlichen Verzeichnis gelistet

-
- - -
-{{else}} -

Status: nicht verifiziert — noch nicht gelistet

-
- - -
-{{end}} -{{end}} -
- - -{{end}} diff --git a/internal/web/templates/admin_dashboard.html b/internal/web/templates/admin_dashboard.html deleted file mode 100644 index 59a3044..0000000 --- a/internal/web/templates/admin_dashboard.html +++ /dev/null @@ -1,17 +0,0 @@ -{{define "admin-dashboard"}} - -{{template "head" .}} - -{{template "nav" .Nav}} - - - -{{end}} diff --git a/internal/web/templates/archived.html b/internal/web/templates/archived.html deleted file mode 100644 index d8ff6d9..0000000 --- a/internal/web/templates/archived.html +++ /dev/null @@ -1,13 +0,0 @@ -{{define "archived"}} -

- Beitrag archiviert und mit RFC-3161-Zeitstempel versehen - ({{.TimestampedAt}}). -

-

- Nachweis-Dossier (PDF) herunterladen -

-

- Dieses Dossier dokumentiert die durchgeführte Prüfung. Es ist keine - Rechtsberatung und ersetzt keine anwaltliche Prüfung im Einzelfall. -

-{{end}} diff --git a/internal/web/templates/beitraege.html b/internal/web/templates/beitraege.html deleted file mode 100644 index deb2c2a..0000000 --- a/internal/web/templates/beitraege.html +++ /dev/null @@ -1,33 +0,0 @@ -{{define "beitraege"}} - -{{template "head" .}} - -{{template "nav" .Nav}} - - - -{{end}} diff --git a/internal/web/templates/beitrag.html b/internal/web/templates/beitrag.html deleted file mode 100644 index e3e06d7..0000000 --- a/internal/web/templates/beitrag.html +++ /dev/null @@ -1,85 +0,0 @@ -{{define "beitrag"}} - -{{template "head" .}} - -{{template "nav" .Nav}} -
-

← Alle Beiträge

-

{{.Platform}} · {{.PostType}}

-

Angelegt am {{.CreatedAt}} — Status: {{.Status}}

-

{{.Caption}}

- -

Findings

-{{if .Findings}} -
    - {{range .Findings}} -
  • - {{.RuleID}} v{{.Version}} ({{.Severity}}) — {{.Title}} -

    Korrektur: {{.Fix}}

    - {{if .Sources}} -

    Fundstellen: {{range $i, $s := .Sources}}{{if $i}}; {{end}}{{$s}}{{end}}

    - {{end}} -
  • - {{end}} -
-{{else}} -

Keine Kennzeichnungsrisiken nach aktuellem Regelwerk gefunden.

-{{end}} - -{{if .CanArchive}} -
- - -
-
-{{end}} -{{if .IsPublished}} -

Nachweis-Dossier (PDF) herunterladen

- -{{if .InsightsReminder.Show}} -
-

{{.InsightsReminder.Message}}

-
- - - -
-
-{{end}} - -{{if .InsightsAssets}} -

Gesicherte Insights-Nachweise

-
    - {{range .InsightsAssets}} -
  • -
    {{.CreatedAt}} — SHA-256: {{.SHA256}}
    -
  • - {{end}} -
-{{end}} -{{end}} - -

Verantwortungsmatrix

-{{template "beteiligte-liste" .}} - -
- - - - - -
- -

- Diese Prüfung ist keine Rechtsberatung und ersetzt keine anwaltliche - Prüfung im Einzelfall. -

-
- - -{{end}} diff --git a/internal/web/templates/beteiligte_liste.html b/internal/web/templates/beteiligte_liste.html deleted file mode 100644 index ec17a8b..0000000 --- a/internal/web/templates/beteiligte_liste.html +++ /dev/null @@ -1,28 +0,0 @@ -{{define "beteiligte-liste"}} -
-{{if not .Participants}} -

Noch keine Beteiligten erfasst.

-{{else}} -
    - {{range .Participants}} -
  • -
    - {{.Name}} ({{.Role}}) -
    -
    - - - {{if .ApprovedAt}}freigegeben am {{.ApprovedAt}}{{end}} -
    -
    - -
    -
  • - {{end}} -
-{{end}} -
-{{end}} diff --git a/internal/web/templates/betreiber_account_detail.html b/internal/web/templates/betreiber_account_detail.html new file mode 100644 index 0000000..a95790d --- /dev/null +++ b/internal/web/templates/betreiber_account_detail.html @@ -0,0 +1,22 @@ +{{define "betreiber-account-detail"}} + +{{template "head" .}} + +{{template "nav" .Nav}} +
+

← Alle Accounts

+

{{.Name}}

+

Angelegt am {{.CreatedAt}}

+ +

Nutzer

+
    + {{range .Users}} +
  • +
    {{.Email}} ({{.Role}})
    +
  • + {{end}} +
+
+ + +{{end}} diff --git a/internal/web/templates/admin_accounts.html b/internal/web/templates/betreiber_accounts.html similarity index 55% rename from internal/web/templates/admin_accounts.html rename to internal/web/templates/betreiber_accounts.html index 31b7b66..fa7f8b7 100644 --- a/internal/web/templates/admin_accounts.html +++ b/internal/web/templates/betreiber_accounts.html @@ -1,10 +1,10 @@ -{{define "admin-accounts"}} +{{define "betreiber-accounts"}} {{template "head" .}} {{template "nav" .Nav}}
-

← Admin

+

← Plattform

Accounts

{{if not .Accounts}}

Noch keine Accounts.

@@ -12,13 +12,8 @@
    {{range .Accounts}}
  • - + {{.Name}} · {{.CreatedAt}} - {{if .Verified}} - verifiziert - {{else}} - nicht verifiziert - {{end}}
  • {{end}} diff --git a/internal/web/templates/admin_audit_log.html b/internal/web/templates/betreiber_audit_log.html similarity index 83% rename from internal/web/templates/admin_audit_log.html rename to internal/web/templates/betreiber_audit_log.html index 5da5afc..aba6a62 100644 --- a/internal/web/templates/admin_audit_log.html +++ b/internal/web/templates/betreiber_audit_log.html @@ -1,10 +1,10 @@ -{{define "admin-audit-log"}} +{{define "betreiber-audit-log"}} {{template "head" .}} {{template "nav" .Nav}}
    -

    ← Admin

    +

    ← Plattform

    Audit-Log

    {{if not .Entries}}

    Noch keine Einträge.

    diff --git a/internal/web/templates/betreiber_dashboard.html b/internal/web/templates/betreiber_dashboard.html new file mode 100644 index 0000000..72eeeb7 --- /dev/null +++ b/internal/web/templates/betreiber_dashboard.html @@ -0,0 +1,15 @@ +{{define "betreiber-dashboard"}} + +{{template "head" .}} + +{{template "nav" .Nav}} + + + +{{end}} diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index 6e154d1..cd56c53 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -4,51 +4,11 @@ {{template "nav" .Nav}}
    -

    Pre-Publish-Prüfung

    -

    Caption und Plattform eingeben, um auf Kennzeichnungsrisiken zu prüfen.

    - -
    - - - - - - - - -

    - Das kann die Prüfung nicht aus der Caption erraten — nur wer - einreicht, weiß, ob eine Gegenleistung vorlag. -

    - - - - - - -

    - Screenshot des veröffentlichten Beitrags — wird Teil des - Nachweis-Dossiers, sobald der Beitrag archiviert wird. -

    - - -
    - -
    +

    Willkommen bei Deklarix

    +

    + Der geführte Fragebogen für die Antragsprüfung folgt in der nächsten + Ausbaustufe. Datenmodell, Regelwerk und Werkzeugkatalog stehen bereits. +

    diff --git a/internal/web/templates/kanzleien.html b/internal/web/templates/kanzleien.html deleted file mode 100644 index 7340174..0000000 --- a/internal/web/templates/kanzleien.html +++ /dev/null @@ -1,23 +0,0 @@ -{{define "kanzleien"}} - -{{template "head" .}} - -
    -

    Kanzlei-Verzeichnis

    -

    - Kostenloses Verzeichnis auf Deklarix verifizierter Kanzleien. Keine - Vermittlung, keine Empfehlung — die Auswahl trifft allein der Nutzer. -

    -{{if not .Kanzleien}} -

    Noch keine Kanzlei gelistet.

    -{{else}} -
      - {{range .Kanzleien}} -
    • {{.Name}}
    • - {{end}} -
    -{{end}} -
    - - -{{end}} diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index c43b8da..2c4106f 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -8,10 +8,8 @@ {{define "nav"}}