feat!: Produktwechsel zu KI-Antragsprüfung — Phase 1 (Datenmodell, Regelwerk, Katalog)

Deklarix war eine Pre-Publish-Kennzeichnungsprüfung für Werbe-Content
(UWG/MStV). Dieser Scope wird komplett verworfen und durch eine
KI-Antragsprüfung ersetzt: Mitarbeitende beschreiben ein KI-Vorhaben,
das System leitet Datenklasse und KI-VO-Einstufung ab, gleicht sie
gegen einen Werkzeugkatalog ab und erzeugt einen Entscheidungsvorschlag
mit Herleitung — ein Mensch entscheidet, das System bereitet nur vor.

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

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

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

Volle Testsuite inkl. echter Postgres-Tests grün. End-to-End gegen
einen laufenden Server verifiziert: Firma-Registrierung legt Account +
admin-Nutzer an, Betreiber-Login leitet zu /betreiber, mandanten-
übergreifende Accounts-Liste sichtbar für betreiber, 404 für
mitarbeiter auf /betreiber, 303 zu /login ohne Sitzung.
This commit is contained in:
noroot
2026-08-28 21:39:04 +02:00
parent 6fd7831784
commit b4d4ee8d3c
96 changed files with 2348 additions and 7612 deletions

View File

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