Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9d71b0d54 | ||
|
|
6df1d2961b | ||
|
|
77de1627c5 | ||
|
|
374f4ade31 | ||
|
|
34b1d8d2a6 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
# Build output
|
||||
dist/
|
||||
dossiers/
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
|
||||
44
CLAUDE.md
44
CLAUDE.md
@@ -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
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
198
internal/extract/engine.go
Normal 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)
|
||||
}
|
||||
}
|
||||
164
internal/extract/engine_test.go
Normal file
164
internal/extract/engine_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
121
internal/store/participant.go
Normal file
121
internal/store/participant.go
Normal 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
|
||||
}
|
||||
133
internal/store/participant_test.go
Normal file
133
internal/store/participant_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
237
internal/web/archive_handlers.go
Normal file
237
internal/web/archive_handlers.go
Normal 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)
|
||||
}
|
||||
202
internal/web/archive_handlers_test.go
Normal file
202
internal/web/archive_handlers_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
365
internal/web/static/app.css
Normal 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;
|
||||
}
|
||||
}
|
||||
BIN
internal/web/static/fonts/inter-latin.woff2
Normal file
BIN
internal/web/static/fonts/inter-latin.woff2
Normal file
Binary file not shown.
13
internal/web/static/inter.css
Normal file
13
internal/web/static/inter.css
Normal 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;
|
||||
}
|
||||
33
internal/web/templates/beitraege.html
Normal file
33
internal/web/templates/beitraege.html
Normal 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}}
|
||||
63
internal/web/templates/beitrag.html
Normal file
63
internal/web/templates/beitrag.html
Normal 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">← 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 & 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}}
|
||||
28
internal/web/templates/beteiligte_liste.html
Normal file
28
internal/web/templates/beteiligte_liste.html
Normal 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}}
|
||||
@@ -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}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
Reference in New Issue
Block a user