Compare commits

...

5 Commits

Author SHA1 Message Date
noroot
c9d71b0d54 feat: Archiv-Übersicht und Beteiligten-CRUD im Web-Layer
Bisher gab es nur Prüfen -> Archivieren, keine Möglichkeit, bereits
geprüfte Beiträge wieder anzusehen oder die Verantwortungsmatrix
(Beteiligte: wer hat vorgegeben, wer freigegeben) tatsächlich zu
pflegen — nur der Store-Layer dafür existierte schon.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:15:39 +02:00
29 changed files with 1826 additions and 616 deletions

1
.gitignore vendored
View File

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

View File

@@ -35,15 +35,22 @@ Login als Selbstzweck.
---
## Kernprinzip: das Modell entscheidet nicht
## Kernprinzip: Fakten trennen von Urteil
**Das LLM extrahiert Fakten. Das Regelwerk in Go fällt das Urteil.**
**Stufe 1 bestimmt Fakten (deterministisch, kein LLM). Das Regelwerk in
Go fällt das Urteil.**
- **Stufe 1 — Extraktion (Claude API):** Eingabe Caption plus Bild/Video,
Ausgabe striktes JSON (Plattform, Gegenleistung, Kennzeichnung
vorhanden/Kanal/Wortlaut/Position, Produktkategorie, Verlinkungen, ...).
Ist die Extraktion unsicher, liefert sie `unklar` — nie eine geratene
Bewertung.
- **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
@@ -51,10 +58,17 @@ Login als Selbstzweck.
Darstellungsdetails unterscheiden sich; die gehören in
Regelbedingungen/Extraktion, nie in separate Regelsätze pro Plattform.
**Absolute Regel:** Das Modell nennt niemals selbst Normen, Urteile oder
Randnummern. Fundstellen stehen ausschließlich in den kuratierten
YAML-Regeln. Eine halluzinierte Fundstelle ist in diesem Markt ein
Totalschaden.
**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.
---
@@ -69,7 +83,7 @@ Bewusst langweilig, ein Binary, lokal lauffähig:
| Datenbank | PostgreSQL über `pgx`, Migrationen über `golang-migrate` |
| PDF | serverseitig erzeugt |
| Zeitstempel | RFC-3161 von einer öffentlichen TSA |
| Extraktion | Claude API (nur Fakten-Extraktion, siehe oben) |
| Extraktion | regelbasiert, `internal/extract`, keine externe Abhängigkeit |
| Design | `design/enterprise.css` (Enterprise Light Theme, enconf-Pattern, Primärfarbe `#1677ff`) |
Kein Docker für die App selbst, kein React, keine Queue. Neue
@@ -94,7 +108,7 @@ Managed Postgres in der EU (DSGVO).
│ └── deklarix/
│ └── main.go # Entry Point, HTTP-Server
├── internal/
│ ├── extract/ # Claude-API-Aufruf, JSON-Schema, Validierung
│ ├── extract/ # regelbasierte Fakten-Erkennung (Keywords, Kürzungs-Position)
│ ├── rules/ # YAML-Loader, Auswertung, Versionierung
│ ├── evidence/ # Hashing, Zeitstempel, Append-only-Log
│ ├── dossier/ # PDF-Erzeugung
@@ -356,8 +370,8 @@ git push origin main
sudo systemctl start deklarix
sudo systemctl status deklarix
# Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, ANTHROPIC_API_KEY,
# RULES_DIR, DOSSIER_DIR, TSA_URL)
# Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, RULES_DIR,
# DOSSIER_DIR, TSA_URL)
# Logs prüfen
journalctl -u deklarix -f

View File

@@ -29,14 +29,7 @@ func main() {
}
defer db.Close()
apiKey := os.Getenv("ANTHROPIC_API_KEY")
if apiKey == "" {
log.Fatal("ANTHROPIC_API_KEY is required")
}
extractor, err := extract.NewClient(apiKey)
if err != nil {
log.Fatalf("extract client: %v", err)
}
extractor := extract.NewEngine()
rulesDir := os.Getenv("RULES_DIR")
if rulesDir == "" {

View File

@@ -1,111 +0,0 @@
package extract
import "encoding/json"
// Diese Typen bilden nur den Ausschnitt der Anthropic-Messages-API ab,
// den die Extraktion tatsächlich braucht (Tool-Use für strukturierte
// Ausgabe) — kein vollständiger API-Client.
type messageRequest struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
System string `json:"system,omitempty"`
Messages []message `json:"messages"`
Tools []tool `json:"tools"`
ToolChoice toolChoice `json:"tool_choice"`
}
type message struct {
Role string `json:"role"`
Content []contentBlock `json:"content"`
}
type contentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
Source *imageSource `json:"source,omitempty"`
}
type imageSource struct {
Type string `json:"type"`
MediaType string `json:"media_type"`
Data string `json:"data"`
}
type tool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema toolInputSchema `json:"input_schema"`
}
type toolInputSchema struct {
Type string `json:"type"`
Properties map[string]any `json:"properties"`
Required []string `json:"required"`
}
type toolChoice struct {
Type string `json:"type"`
Name string `json:"name"`
}
type messageResponse struct {
Content []responseBlock `json:"content"`
Error *apiError `json:"error,omitempty"`
}
type responseBlock struct {
Type string `json:"type"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
}
type apiError struct {
Type string `json:"type"`
Message string `json:"message"`
}
// extractionArgs spiegelt extractionTool.InputSchema — das JSON, das
// die Extraktion vom Modell zurückbekommt.
type extractionArgs struct {
Consideration string `json:"gegenleistung"`
DisclosurePresent bool `json:"kennzeichnung_vorhanden"`
DisclosureWording string `json:"kennzeichnung_wortlaut"`
DisclosureBeforeCut bool `json:"kennzeichnung_vor_kuerzung"`
}
var extractionTool = tool{
Name: "extrahiere_fakten",
Description: "Extrahiere ausschließlich beobachtbare Fakten aus Caption und Bild eines " +
"Social-Media-Beitrags für eine Kennzeichnungsprüfung. Triff KEINE rechtliche " +
"Bewertung. Ist ein Feld nicht sicher zu bestimmen, wähle den vorgesehenen " +
"Unsicherheitswert statt zu raten.",
InputSchema: toolInputSchema{
Type: "object",
Properties: map[string]any{
"gegenleistung": map[string]any{
"type": "string",
"enum": []string{"bezahlt", "sachbezug", "keine", "unklar"},
"description": "Erhält der/die Postende eine Gegenleistung (Geld, Produkt, Einladung)? 'unklar' wenn nicht sicher bestimmbar.",
},
"kennzeichnung_vorhanden": map[string]any{
"type": "boolean",
"description": "Enthält die Caption einen Kennzeichnungshinweis wie 'Werbung' oder 'Anzeige'?",
},
"kennzeichnung_wortlaut": map[string]any{
"type": "string",
"description": "Der exakte Wortlaut der Kennzeichnung, falls vorhanden, sonst leerer String.",
},
"kennzeichnung_vor_kuerzung": map[string]any{
"type": "boolean",
"description": "Ist die Kennzeichnung sichtbar, BEVOR die Plattform die Caption hinter 'mehr anzeigen' kürzt? Ohne Kürzung: true.",
},
},
Required: []string{
"gegenleistung",
"kennzeichnung_vorhanden",
"kennzeichnung_wortlaut",
"kennzeichnung_vor_kuerzung",
},
},
}

View File

@@ -1,233 +0,0 @@
// Package extract ist Stufe 1 aus dem Kernprinzip: es extrahiert
// ausschließlich beobachtbare Fakten aus Caption und Bild eines
// Beitrags über die Claude API. Es bewertet nichts — das Urteil fällt
// ausschließlich das Regelwerk in internal/rules.
package extract
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"github.com/netcell-it/deklarix/internal/rules"
)
const (
defaultBaseURL = "https://api.anthropic.com"
defaultModel = "claude-sonnet-5"
anthropicVersion = "2023-06-01"
// PromptVersion wird zusammen mit jeder Extraktion gespeichert
// (siehe Datenmodell: extraction.prompt_version), damit sich ein
// späteres Finding auf den exakten Prompt-Stand zurückführen lässt,
// der es erzeugt hat. Hochzählen bei jeder inhaltlichen Änderung an
// systemPrompt oder extractionTool.
PromptVersion = "v1"
)
const systemPrompt = `Du extrahierst ausschließlich beobachtbare Fakten aus einem Social-Media-Beitrag (Caption und Bild) für eine Kennzeichnungsprüfung nach deutschem Recht.
Du triffst KEINE rechtliche Bewertung und nennst KEINE Gesetze, Paragraphen oder Urteile — das ist nicht deine Aufgabe.
Ist ein Fakt nicht sicher aus Caption oder Bild zu bestimmen, wähle den dafür vorgesehenen Unsicherheitswert (z. B. "unklar"). Rate niemals.`
// Input ist, was Stufe 1 zur Extraktion braucht. Platform und
// Jurisdiction kommen vom Aufrufer (der Nutzer wählt Plattform und
// Rechtsordnung beim Einreichen) statt vom Modell erraten zu werden —
// aus Caption/Bild lässt sich keine Rechtsordnung ablesen.
type Input struct {
Platform string
Jurisdiction string
Caption string
ImageMediaType string // z. B. "image/jpeg", "image/png"
ImageData []byte
}
// Result ist die Ausgabe von Extract: die für das Regelwerk
// aufbereiteten Facts, plus RawJSON — das exakte, unveränderte JSON, das
// das Modell zurückgegeben 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
}
// Client ruft die Claude API zur Fakten-Extraktion auf.
type Client struct {
apiKey string
model string
baseURL string
httpClient *http.Client
}
// Option konfiguriert einen Client.
type Option func(*Client)
// WithModel überschreibt das Standardmodell.
func WithModel(model string) Option {
return func(c *Client) { c.model = model }
}
// WithBaseURL überschreibt die API-Basis-URL (für Tests).
func WithBaseURL(url string) Option {
return func(c *Client) { c.baseURL = url }
}
// WithHTTPClient überschreibt den verwendeten *http.Client (für Tests).
func WithHTTPClient(hc *http.Client) Option {
return func(c *Client) { c.httpClient = hc }
}
// NewClient erstellt einen Extraktions-Client. apiKey darf nicht leer
// sein — es gibt keinen stillen Fallback auf einen ungültigen Zustand.
func NewClient(apiKey string, opts ...Option) (*Client, error) {
if apiKey == "" {
return nil, fmt.Errorf("extract: apiKey darf nicht leer sein")
}
c := &Client{
apiKey: apiKey,
model: defaultModel,
baseURL: defaultBaseURL,
httpClient: http.DefaultClient,
}
for _, opt := range opts {
opt(c)
}
return c, nil
}
// ModelVersion ist der Modell-Identifier, der zusammen mit jeder
// Extraktion gespeichert werden sollte (siehe Datenmodell:
// extraction.model_version).
func (c *Client) ModelVersion() string { return c.model }
// Extract ruft die Claude API auf und liefert die extrahierten Fakten.
// Bei jedem Fehler (Netzwerk, API-Fehler, unerwartete Antwortform,
// ungültiger Enum-Wert) wird ein Fehler zurückgegeben statt stumm ein
// Zero-Value-Facts zu liefern — ein falsches "keine Gegenleistung" wäre
// hier schlimmer als ein sichtbarer Fehler.
func (c *Client) Extract(ctx context.Context, in Input) (Result, error) {
if in.Caption == "" && len(in.ImageData) == 0 {
return Result{}, fmt.Errorf("extract: caption und bild sind beide leer")
}
var content []contentBlock
if len(in.ImageData) > 0 {
if in.ImageMediaType == "" {
return Result{}, fmt.Errorf("extract: ImageMediaType fehlt für vorhandenes Bild")
}
content = append(content, contentBlock{
Type: "image",
Source: &imageSource{
Type: "base64",
MediaType: in.ImageMediaType,
Data: base64.StdEncoding.EncodeToString(in.ImageData),
},
})
}
content = append(content, contentBlock{Type: "text", Text: "Caption:\n" + in.Caption})
reqBody := messageRequest{
Model: c.model,
MaxTokens: 1024,
System: systemPrompt,
Messages: []message{{Role: "user", Content: content}},
Tools: []tool{extractionTool},
ToolChoice: toolChoice{Type: "tool", Name: extractionTool.Name},
}
payload, err := json.Marshal(reqBody)
if err != nil {
return Result{}, fmt.Errorf("extract: request marshal: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/messages", bytes.NewReader(payload))
if err != nil {
return Result{}, fmt.Errorf("extract: request bauen: %w", err)
}
req.Header.Set("content-type", "application/json")
req.Header.Set("x-api-key", c.apiKey)
req.Header.Set("anthropic-version", anthropicVersion)
resp, err := c.httpClient.Do(req)
if err != nil {
return Result{}, fmt.Errorf("extract: request fehlgeschlagen: %w", err)
}
defer resp.Body.Close()
var msg messageResponse
if err := json.NewDecoder(resp.Body).Decode(&msg); err != nil {
return Result{}, fmt.Errorf("extract: response decode: %w", err)
}
if resp.StatusCode != http.StatusOK {
if msg.Error != nil {
return Result{}, fmt.Errorf("extract: API-Fehler (%s): %s", msg.Error.Type, msg.Error.Message)
}
return Result{}, fmt.Errorf("extract: API-Status %d", resp.StatusCode)
}
var toolUse *responseBlock
for i := range msg.Content {
if msg.Content[i].Type == "tool_use" && msg.Content[i].Name == extractionTool.Name {
toolUse = &msg.Content[i]
break
}
}
if toolUse == nil {
return Result{}, fmt.Errorf("extract: keine tool_use-Antwort für %q enthalten", extractionTool.Name)
}
facts, err := parseArgsToFacts(toolUse.Input, in.Platform, in.Jurisdiction)
if err != nil {
return Result{}, fmt.Errorf("extract: %w", err)
}
return Result{Facts: facts, RawJSON: toolUse.Input}, nil
}
// 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 Modell-Payloads.
func ParsePayload(payload []byte, platform, jurisdiction string) (rules.Facts, error) {
facts, err := parseArgsToFacts(payload, platform, jurisdiction)
if err != nil {
return rules.Facts{}, fmt.Errorf("extract: %w", err)
}
return facts, nil
}
func parseArgsToFacts(raw []byte, platform, jurisdiction string) (rules.Facts, error) {
var args extractionArgs
if err := json.Unmarshal(raw, &args); err != nil {
return rules.Facts{}, fmt.Errorf("tool-input parse: %w", err)
}
consideration, err := parseConsideration(args.Consideration)
if err != nil {
return rules.Facts{}, err
}
return rules.Facts{
Platform: platform,
Jurisdiction: jurisdiction,
Consideration: consideration,
DisclosurePresent: args.DisclosurePresent,
DisclosureWording: args.DisclosureWording,
DisclosureBeforeCut: args.DisclosureBeforeCut,
}, nil
}
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("unerwarteter gegenleistung-Wert %q vom Modell", s)
}
}

View File

@@ -1,225 +0,0 @@
package extract_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/extract"
"github.com/netcell-it/deklarix/internal/rules"
)
// toolUseResponse baut eine minimale Anthropic-Messages-API-Antwort mit
// genau einem tool_use-Block, wie sie extract.Client erwartet.
func toolUseResponse(t *testing.T, toolName string, args any) []byte {
t.Helper()
input, err := json.Marshal(args)
if err != nil {
t.Fatalf("marshal args: %v", err)
}
body := map[string]any{
"content": []map[string]any{
{"type": "tool_use", "name": toolName, "input": json.RawMessage(input)},
},
}
data, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal response: %v", err)
}
return data
}
func newTestClient(t *testing.T, handler http.HandlerFunc) *extract.Client {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
c, err := extract.NewClient("test-key", extract.WithBaseURL(server.URL))
if err != nil {
t.Fatalf("NewClient: %v", err)
}
return c
}
func TestNewClientRejectsEmptyAPIKey(t *testing.T) {
if _, err := extract.NewClient(""); err == nil {
t.Fatal("expected error for empty apiKey, got nil")
}
}
func TestExtractRejectsEmptyInput(t *testing.T) {
called := false
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
called = true
})
_, err := c.Extract(context.Background(), extract.Input{Platform: "instagram"})
if err == nil {
t.Fatal("expected error for empty caption+image, got nil")
}
if called {
t.Fatal("expected no HTTP call for invalid input, but the server was called")
}
}
func TestExtractSuccess(t *testing.T) {
var gotBody map[string]any
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/messages" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
if got := r.Header.Get("x-api-key"); got != "test-key" {
t.Errorf("x-api-key = %q, want test-key", got)
}
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
w.WriteHeader(http.StatusOK)
w.Write(toolUseResponse(t, "extrahiere_fakten", map[string]any{
"gegenleistung": "bezahlt",
"kennzeichnung_vorhanden": true,
"kennzeichnung_wortlaut": "Werbung",
"kennzeichnung_vor_kuerzung": false,
}))
})
got, err := c.Extract(context.Background(), extract.Input{
Platform: "instagram",
Jurisdiction: "DE",
Caption: "Schaut euch dieses Produkt an! Werbung wegen ...",
ImageMediaType: "image/jpeg",
ImageData: []byte("fake-jpeg-bytes"),
})
if err != nil {
t.Fatalf("Extract: %v", err)
}
want := rules.Facts{
Platform: "instagram",
Jurisdiction: "DE",
Consideration: rules.ConsiderationPaid,
DisclosurePresent: true,
DisclosureWording: "Werbung",
DisclosureBeforeCut: false,
}
if got.Facts != want {
t.Fatalf("Extract().Facts = %+v, want %+v", got.Facts, want)
}
var rawArgs map[string]any
if err := json.Unmarshal(got.RawJSON, &rawArgs); err != nil {
t.Fatalf("RawJSON does not parse as JSON: %v", err)
}
if rawArgs["gegenleistung"] != "bezahlt" {
t.Fatalf("RawJSON = %s, expected it to contain the model's raw field names", got.RawJSON)
}
toolChoice, _ := gotBody["tool_choice"].(map[string]any)
if toolChoice["name"] != "extrahiere_fakten" {
t.Errorf("tool_choice.name = %v, want extrahiere_fakten", toolChoice["name"])
}
if gotBody["system"] == nil || gotBody["system"] == "" {
t.Error("expected a non-empty system prompt in the request")
}
}
func TestExtractPassesThroughUnclear(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write(toolUseResponse(t, "extrahiere_fakten", map[string]any{
"gegenleistung": "unklar",
"kennzeichnung_vorhanden": false,
"kennzeichnung_wortlaut": "",
"kennzeichnung_vor_kuerzung": false,
}))
})
got, err := c.Extract(context.Background(), extract.Input{Platform: "tiktok", Caption: "..."})
if err != nil {
t.Fatalf("Extract: %v", err)
}
if got.Facts.Consideration != rules.ConsiderationUnclear {
t.Fatalf("Consideration = %q, want unklar", got.Facts.Consideration)
}
}
func TestExtractRejectsInvalidConsiderationValue(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write(toolUseResponse(t, "extrahiere_fakten", map[string]any{
"gegenleistung": "vielleicht", // nicht im Enum
"kennzeichnung_vorhanden": false,
"kennzeichnung_wortlaut": "",
"kennzeichnung_vor_kuerzung": false,
}))
})
if _, err := c.Extract(context.Background(), extract.Input{Platform: "instagram", Caption: "x"}); err == nil {
t.Fatal("expected error for out-of-enum gegenleistung value, got nil")
}
}
func TestExtractRejectsMissingToolUseBlock(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"content":[{"type":"text","text":"kein tool_use hier"}]}`))
})
if _, err := c.Extract(context.Background(), extract.Input{Platform: "instagram", Caption: "x"}); err == nil {
t.Fatal("expected error when response has no tool_use block, got nil")
}
}
func TestExtractRejectsMalformedToolInput(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"content":[{"type":"tool_use","name":"extrahiere_fakten","input":"not-an-object"}]}`))
})
if _, err := c.Extract(context.Background(), extract.Input{Platform: "instagram", Caption: "x"}); err == nil {
t.Fatal("expected error for malformed tool input, got nil")
}
}
func TestParsePayloadRoundTrip(t *testing.T) {
payload := []byte(`{"gegenleistung":"sachbezug","kennzeichnung_vorhanden":true,"kennzeichnung_wortlaut":"Anzeige","kennzeichnung_vor_kuerzung":true}`)
facts, err := extract.ParsePayload(payload, "tiktok", "DE")
if err != nil {
t.Fatalf("ParsePayload: %v", err)
}
want := rules.Facts{
Platform: "tiktok",
Jurisdiction: "DE",
Consideration: rules.ConsiderationInKind,
DisclosurePresent: true,
DisclosureWording: "Anzeige",
DisclosureBeforeCut: true,
}
if facts != want {
t.Fatalf("ParsePayload() = %+v, want %+v", facts, want)
}
}
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")
}
}
func TestExtractPropagatesAPIError(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
w.Write([]byte(`{"error":{"type":"rate_limit_error","message":"too many requests"}}`))
})
_, err := c.Extract(context.Background(), extract.Input{Platform: "instagram", Caption: "x"})
if err == nil {
t.Fatal("expected error for non-200 status, got nil")
}
if got := err.Error(); !strings.Contains(got, "too many requests") {
t.Fatalf("error %q does not mention API message", got)
}
}

198
internal/extract/engine.go Normal file
View File

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

View File

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

View File

@@ -27,8 +27,14 @@ type Finding struct {
CreatedAt time.Time
}
// CreateFinding speichert ein Finding.
// 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)

View File

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

View File

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

View File

@@ -61,6 +61,62 @@ func (s *Store) GetSubmission(ctx context.Context, id string) (Submission, error
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

View File

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

View File

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

View File

@@ -60,8 +60,9 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
platform := r.FormValue("platform")
postType := r.FormValue("post_type")
caption := r.FormValue("caption")
if platform == "" || postType == "" || caption == "" {
http.Error(w, "Plattform, Beitragstyp und Caption sind Pflichtfelder", http.StatusBadRequest)
consideration := r.FormValue("consideration")
if platform == "" || postType == "" || caption == "" || consideration == "" {
http.Error(w, "Plattform, Beitragstyp, Gegenleistung und Caption sind Pflichtfelder", http.StatusBadRequest)
return
}
@@ -69,9 +70,10 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
accountID := currentUser(r).AccountID
result, err := s.extractor.Extract(ctx, extract.Input{
Platform: platform,
Jurisdiction: "DE",
Caption: caption,
Platform: platform,
Jurisdiction: "DE",
Consideration: consideration,
Caption: caption,
})
if err != nil {
http.Error(w, "Extraktion fehlgeschlagen: "+err.Error(), http.StatusBadGateway)

View File

@@ -26,8 +26,9 @@ var templatesFS embed.FS
var staticFS embed.FS
// Extractor ist die Schnittstelle, die der Server für Stufe 1 braucht.
// *extract.Client erfüllt sie; Tests injizieren einen Fake statt echte
// Claude-API-Aufrufe zu machen.
// *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
@@ -47,6 +48,13 @@ type Store interface {
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)
CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error)
@@ -97,6 +105,11 @@ func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper
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.Handle("GET /static/", http.FileServerFS(staticFS))
s.mux = mux

View File

@@ -66,6 +66,7 @@ type fakeStore struct {
extractions map[string]store.Extraction
findings map[string][]store.Finding
evidencePkgs map[string]store.EvidencePackage
participants map[string]store.Participant
}
func newFakeStore() *fakeStore {
@@ -78,6 +79,7 @@ func newFakeStore() *fakeStore {
extractions: map[string]store.Extraction{},
findings: map[string][]store.Finding{},
evidencePkgs: map[string]store.EvidencePackage{},
participants: map[string]store.Participant{},
}
}
@@ -247,6 +249,106 @@ func (f *fakeStore) GetLatestEvidencePackage(ctx context.Context, submissionID s
return pkg, 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.
@@ -373,7 +475,10 @@ func getWithCookie(t *testing.T, s *web.Server, cookie *http.Cookie, path string
}
func checkForm(extra ...string) url.Values {
v := url.Values{"platform": {"instagram"}, "post_type": {"reel"}, "caption": {"..."}}
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])
}

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

@@ -0,0 +1,365 @@
/* Deklarix — eigenes, schlankes Stylesheet auf Basis der Design-Tokens
aus dem enconf Enterprise Light Theme (design/enterprise.css): gleiche
Marke (Primärblau #1677ff, Inter, Radius-Skala), aber ohne dessen
Ant-Design-/Desktop-Sidebar-Layout, das für Deklarix nicht passt.
Mobile-first: Basis-Stile gelten fürs Telefon, @media (min-width)
erweitert für größere Bildschirme. */
@import url('/static/inter.css');
:root {
--branding-primary: #1677ff;
--radius: 6px;
--radius-md: 8px;
--radius-lg: 10px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--color-bg: #f8fafc;
--color-text: #334155;
--color-heading: #0f172a;
--color-border: #e2e8f0;
--color-muted: #64748b;
--color-hoch: #b91c1c;
--color-hoch-bg: #fef2f2;
--color-mittel: #b45309;
--color-mittel-bg: #fffbeb;
--color-niedrig: #166534;
--color-niedrig-bg: #f0fdf4;
}
* {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--color-bg);
color: var(--color-text);
font-size: 16px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 {
color: var(--color-heading);
font-weight: 600;
letter-spacing: -0.02em;
margin: 0 0 0.5em;
}
h1 {
font-size: 1.5rem;
}
p {
margin: 0 0 1em;
}
a {
color: var(--branding-primary);
}
/* Container: volle Breite + Innenabstand auf dem Handy, zentriert mit
fester Breite ab Tablet-Größe aufwärts. */
.page {
padding: 16px;
max-width: 640px;
margin: 0 auto;
}
nav {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16px;
padding: 12px 16px;
}
nav a {
color: var(--color-muted);
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
}
nav a:hover {
color: var(--color-text);
}
/* Formulare: großzügige Touch-Ziele (min. 44px Höhe), volle Breite auf
dem Handy. */
form {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 24px;
}
label {
font-weight: 500;
font-size: 0.875rem;
color: var(--color-heading);
margin-top: 12px;
}
input, select, textarea, button {
font: inherit;
font-size: 16px; /* verhindert Auto-Zoom beim Fokussieren auf iOS */
}
input, select, textarea {
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: #fff;
color: var(--color-text);
min-height: 44px;
width: 100%;
}
textarea {
min-height: 120px;
resize: vertical;
}
input:focus, select:focus, textarea:focus {
outline: 2px solid var(--branding-primary);
outline-offset: 1px;
border-color: var(--branding-primary);
}
button {
margin-top: 16px;
padding: 12px 20px;
min-height: 44px;
border: none;
border-radius: var(--radius);
background: var(--branding-primary);
color: #fff;
font-weight: 500;
cursor: pointer;
box-shadow: var(--shadow-sm);
}
button:hover {
filter: brightness(0.94);
}
button:active {
filter: brightness(0.88);
}
nav button {
margin-top: 0;
background: transparent;
color: var(--color-muted);
border: 1px solid var(--color-border);
box-shadow: none;
min-height: 36px;
padding: 6px 14px;
font-size: 0.875rem;
}
.hinweis {
font-size: 0.8125rem;
color: var(--color-muted);
margin-top: 4px;
}
.disclaimer {
font-size: 0.8125rem;
color: var(--color-muted);
border-top: 1px solid var(--color-border);
padding-top: 12px;
margin-top: 24px;
}
.fehler {
background: var(--color-hoch-bg);
color: var(--color-hoch);
border-radius: var(--radius);
padding: 12px;
font-size: 0.9375rem;
}
.rueckfrage {
background: var(--color-mittel-bg);
color: var(--color-mittel);
border-radius: var(--radius);
padding: 12px;
}
.keine-findings {
background: var(--color-niedrig-bg);
color: var(--color-niedrig);
border-radius: var(--radius);
padding: 12px;
}
.archiviert {
background: var(--color-niedrig-bg);
color: var(--color-niedrig);
border-radius: var(--radius);
padding: 12px;
}
.findings {
list-style: none;
margin: 0 0 16px;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.finding {
border-radius: var(--radius-md);
padding: 12px 14px;
box-shadow: var(--shadow);
}
.finding p {
margin: 6px 0 0;
font-size: 0.9375rem;
}
.finding-hoch {
background: var(--color-hoch-bg);
border-left: 4px solid var(--color-hoch);
}
.finding-mittel {
background: var(--color-mittel-bg);
border-left: 4px solid var(--color-mittel);
}
.finding-niedrig {
background: var(--color-niedrig-bg);
border-left: 4px solid var(--color-niedrig);
}
.status {
display: inline-block;
font-size: 0.75rem;
font-weight: 500;
padding: 2px 8px;
border-radius: var(--radius);
background: var(--color-border);
color: var(--color-muted);
}
.status-published {
background: var(--color-niedrig-bg);
color: var(--color-niedrig);
}
.status-checked {
background: var(--color-mittel-bg);
color: var(--color-mittel);
}
.beitraege-liste {
list-style: none;
margin: 0 0 16px;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.beitraege-liste li a {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 12px 14px;
border-radius: var(--radius-md);
box-shadow: var(--shadow);
background: #fff;
color: var(--color-text);
text-decoration: none;
}
.beteiligte {
list-style: none;
margin: 0 0 16px;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.beteiligter {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: var(--radius-md);
box-shadow: var(--shadow);
background: #fff;
}
.beteiligter-kopf {
flex: 1 1 100%;
}
.beteiligter .rolle {
color: var(--color-muted);
font-weight: 400;
}
.beteiligter-form {
flex-direction: row;
align-items: center;
gap: 12px;
margin: 0;
flex: 1 1 auto;
}
.beteiligter-form label {
display: flex;
align-items: center;
gap: 4px;
font-weight: 400;
margin: 0;
}
.beteiligter-form input[type="checkbox"] {
width: auto;
min-height: 0;
}
.beteiligter form:last-child {
margin: 0;
}
button.entfernen {
margin: 0;
background: transparent;
color: var(--color-hoch);
border: 1px solid var(--color-hoch-bg);
box-shadow: none;
min-height: 36px;
padding: 6px 14px;
font-size: 0.875rem;
}
/* Ab hier mehr Platz (Tablet/Desktop) — der Container bekommt spürbaren
Rand statt volle Breite, sonst bleibt alles identisch. */
@media (min-width: 640px) {
.page {
padding: 32px 24px;
}
h1 {
font-size: 1.75rem;
}
}

Binary file not shown.

View File

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

View File

@@ -0,0 +1,33 @@
{{define "beitraege"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .}}
<div class="page">
<h1>Beiträge</h1>
{{if not .Submissions}}
<p class="hinweis">Noch keine Beiträge geprüft.</p>
{{else}}
<ul class="beitraege-liste">
{{range .Submissions}}
<li>
<a href="/beitraege/{{.ID}}">
<strong>{{.Platform}}</strong> · {{.PostType}} · {{.CreatedAt}}
<span class="status status-{{.Status}}">{{.Status}}</span>
{{if .HighestSeverity}}
<span class="finding-{{.HighestSeverity}}">{{.FindingCount}} Finding(s), höchste: {{.HighestSeverity}}</span>
{{else}}
<span class="keine-findings">keine Findings</span>
{{end}}
</a>
</li>
{{end}}
</ul>
{{end}}
<p><a href="/">Neuen Beitrag prüfen</a></p>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,63 @@
{{define "beitrag"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .}}
<div class="page">
<p><a href="/beitraege">&larr; Alle Beiträge</a></p>
<h1>{{.Platform}} · {{.PostType}}</h1>
<p class="hinweis">Angelegt am {{.CreatedAt}} — Status: <span class="status status-{{.Status}}">{{.Status}}</span></p>
<p>{{.Caption}}</p>
<h2>Findings</h2>
{{if .Findings}}
<ul class="findings">
{{range .Findings}}
<li class="finding finding-{{.Severity}}">
<strong>{{.RuleID}} v{{.Version}}</strong> ({{.Severity}}) — {{.Title}}
<p>Korrektur: {{.Fix}}</p>
{{if .Sources}}
<p>Fundstellen: {{range $i, $s := .Sources}}{{if $i}}; {{end}}{{$s}}{{end}}</p>
{{end}}
</li>
{{end}}
</ul>
{{else}}
<p class="keine-findings">Keine Kennzeichnungsrisiken nach aktuellem Regelwerk gefunden.</p>
{{end}}
{{if .CanArchive}}
<form hx-post="/veroeffentlichen" hx-target="#archiv-ergebnis" hx-swap="innerHTML">
<input type="hidden" name="submission_id" value="{{.SubmissionID}}">
<button type="submit">Als veröffentlicht markieren &amp; archivieren</button>
</form>
<div id="archiv-ergebnis"></div>
{{end}}
{{if .IsPublished}}
<p><a href="{{.DossierURL}}">Nachweis-Dossier (PDF) herunterladen</a></p>
{{end}}
<h2>Verantwortungsmatrix</h2>
{{template "beteiligte-liste" .}}
<form hx-post="/beitraege/{{.SubmissionID}}/beteiligte" hx-target="#beteiligte" hx-swap="outerHTML">
<label for="role">Rolle</label>
<select id="role" name="role" required>
<option value="creator">Creator</option>
<option value="agentur">Agentur</option>
<option value="marke">Marke</option>
<option value="kanzlei">Kanzlei</option>
</select>
<label for="name">Name</label>
<input type="text" id="name" name="name" required>
<button type="submit">Beteiligten hinzufügen</button>
</form>
<p class="disclaimer">
Diese Prüfung ist keine Rechtsberatung und ersetzt keine anwaltliche
Prüfung im Einzelfall.
</p>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,28 @@
{{define "beteiligte-liste"}}
<div id="beteiligte">
{{if not .Participants}}
<p class="hinweis">Noch keine Beteiligten erfasst.</p>
{{else}}
<ul class="beteiligte">
{{range .Participants}}
<li class="beteiligter">
<div class="beteiligter-kopf">
<strong>{{.Name}}</strong> <span class="rolle">({{.Role}})</span>
</div>
<form class="beteiligter-form"
hx-post="/beitraege/{{$.SubmissionID}}/beteiligte/{{.ID}}/aktualisieren"
hx-target="#beteiligte" hx-swap="outerHTML" hx-trigger="change">
<label><input type="checkbox" name="vorgegeben" {{if .Vorgegeben}}checked{{end}}> Vorgegeben</label>
<label><input type="checkbox" name="freigegeben" {{if .Freigegeben}}checked{{end}}> Freigegeben</label>
{{if .ApprovedAt}}<span class="hinweis">freigegeben am {{.ApprovedAt}}</span>{{end}}
</form>
<form hx-post="/beitraege/{{$.SubmissionID}}/beteiligte/{{.ID}}/loeschen"
hx-target="#beteiligte" hx-swap="outerHTML" hx-confirm="Beteiligten wirklich entfernen?">
<button type="submit" class="entfernen">Entfernen</button>
</form>
</li>
{{end}}
</ul>
{{end}}
</div>
{{end}}

View File

@@ -3,6 +3,7 @@
<head>{{template "head" .}}</head>
<body>
{{template "nav" .}}
<div class="page">
<h1>Pre-Publish-Prüfung</h1>
<p>Caption und Plattform eingeben, um auf Kennzeichnungsrisiken zu prüfen.</p>
@@ -21,6 +22,19 @@
<option value="video">Video</option>
</select>
<label for="consideration">Gegenleistung</label>
<select id="consideration" name="consideration" required>
<option value="">— bitte wählen —</option>
<option value="bezahlt">Bezahlt</option>
<option value="sachbezug">Sachbezug (Produkt, Einladung, ...)</option>
<option value="keine">Keine</option>
<option value="unklar">Unklar</option>
</select>
<p class="hinweis">
Das kann die Prüfung nicht aus der Caption erraten — nur wer
einreicht, weiß, ob eine Gegenleistung vorlag.
</p>
<label for="caption">Caption</label>
<textarea id="caption" name="caption" rows="6" required></textarea>
@@ -28,6 +42,7 @@
</form>
<div id="ergebnis"></div>
</div>
</body>
</html>
{{end}}

View File

@@ -2,11 +2,14 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} — Deklarix</title>
<link rel="stylesheet" href="/static/app.css">
<script src="/static/htmx.min.js"></script>
{{end}}
{{define "nav"}}
<nav>
<a href="/">Prüfen</a>
<a href="/beitraege">Beiträge</a>
<form method="post" action="/logout" style="display:inline">
<button type="submit">Abmelden</button>
</form>

View File

@@ -2,6 +2,7 @@
<html lang="de">
<head>{{template "head" .}}</head>
<body>
<div class="page">
<h1>Anmelden</h1>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<form method="post" action="/login">
@@ -14,6 +15,7 @@
<button type="submit">Anmelden</button>
</form>
<p><a href="/register">Noch kein Konto? Registrieren</a></p>
</div>
</body>
</html>
{{end}}

View File

@@ -2,6 +2,7 @@
<html lang="de">
<head>{{template "head" .}}</head>
<body>
<div class="page">
<h1>Registrieren</h1>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<form method="post" action="/register">
@@ -25,6 +26,7 @@
<button type="submit">Konto anlegen</button>
</form>
<p><a href="/login">Schon ein Konto? Anmelden</a></p>
</div>
</body>
</html>
{{end}}

View File

@@ -42,22 +42,34 @@ case "$1" in
chown "root:$SERVICE_USER" "$CONFIG_DIR/deklarix.env"
fi
# ─── Healing: fehlende Variablen aus älteren Installationen ───
# deklarix.env wird bei Upgrades nie überschrieben (Secrets/
# Anpassungen bleiben erhalten) — Variablen, die erst nach einer
# Erstinstallation dazugekommen sind, fehlen dort sonst schlicht
# und lassen den Dienst mit einem für den Admin überraschenden
# Fehler abbrechen (z. B. RULES_DIR fehlt -> main.go sucht Regeln
# im falschen Verzeichnis, obwohl DATABASE_URL längst gesetzt
# war). Bekannte Variablen mit unkritischem Default werden hier
# ergänzt, falls sie fehlen — nie überschrieben, nur angehängt.
if [ -f "$CONFIG_DIR/deklarix.env" ]; then
grep -q '^RULES_DIR=' "$CONFIG_DIR/deklarix.env" || \
echo "RULES_DIR=/usr/share/deklarix/rules" >> "$CONFIG_DIR/deklarix.env"
fi
systemctl daemon-reload 2>/dev/null || true
systemctl enable deklarix.service >/dev/null 2>&1 || true
# Nicht blind starten — ohne gesetzte DATABASE_URL/ANTHROPIC_API_KEY
# würde der Dienst nur in eine Restart-Schleife laufen (main.go
# bricht sonst bewusst mit log.Fatal ab, siehe CLAUDE.md "keine
# stillen Fallbacks"). Die Vorlage liefert beide auskommentiert aus —
# ein Treffer hier bedeutet also wirklich "vom Admin gesetzt", nicht
# den Platzhalter.
if grep -qE '^DATABASE_URL=.+' "$CONFIG_DIR/deklarix.env" 2>/dev/null \
&& grep -qE '^ANTHROPIC_API_KEY=.+' "$CONFIG_DIR/deklarix.env" 2>/dev/null; then
# Nicht blind starten — ohne gesetzte DATABASE_URL würde der Dienst
# nur in eine Restart-Schleife laufen (main.go bricht sonst bewusst
# mit log.Fatal ab, siehe CLAUDE.md "keine stillen Fallbacks"). Die
# Vorlage liefert sie auskommentiert aus — ein Treffer hier bedeutet
# also wirklich "vom Admin gesetzt", nicht den Platzhalter.
if grep -qE '^DATABASE_URL=.+' "$CONFIG_DIR/deklarix.env" 2>/dev/null; then
systemctl restart deklarix.service
else
echo ""
echo " → Deklarix installiert, aber noch nicht gestartet."
echo " DATABASE_URL und ANTHROPIC_API_KEY in $CONFIG_DIR/deklarix.env setzen, dann:"
echo " DATABASE_URL in $CONFIG_DIR/deklarix.env setzen, dann:"
echo " systemctl start deklarix"
echo ""
fi

View File

@@ -18,10 +18,8 @@ DOSSIER_DIR=/var/lib/deklarix/dossiers
# Kundeneinsatz auf einen eIDAS-qualifizierten Dienst umstellen.
#TSA_URL=https://freetsa.org/tsr
# Pflicht — der Dienst startet nicht ohne gültige DATABASE_URL und
# ANTHROPIC_API_KEY. Beide auskommentiert lassen, bis echte Werte
# eingetragen sind: postinst prüft genau diese beiden Zeilen, um den
# Dienst nicht blind in eine Restart-Schleife gegen Platzhalter laufen
# zu lassen.
# Pflicht — der Dienst startet nicht ohne gültige DATABASE_URL.
# Auskommentiert lassen, bis ein echter Wert eingetragen ist: postinst
# prüft genau diese Zeile, um den Dienst nicht blind in eine Restart-
# Schleife gegen einen Platzhalter laufen zu lassen.
#DATABASE_URL=postgres://user:password@host:5432/deklarix?sslmode=require
#ANTHROPIC_API_KEY=