feat: add store CRUD methods for submission/extraction/finding/evidence

Create/Get for submission (including the one legitimate status
transition — submission is not append-only, unlike the other three),
Create/GetLatest for extraction and evidence_package, Create for
finding plus ListCurrentFindings which applies the anti-join documented
in the migration (a finding referenced by another row's `supersedes`
is not "current").

Tested against real Postgres, including that the append-only trigger
still rejects UPDATE on evidence_package via this new code path, and
that ListCurrentFindings actually hides a finding once a correction
supersedes it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
noroot
2026-08-27 14:30:08 +02:00
parent 31caa8f66a
commit e0218337e7
5 changed files with 436 additions and 0 deletions

193
internal/store/crud_test.go Normal file
View File

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

View File

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

View File

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

67
internal/store/finding.go Normal file
View File

@@ -0,0 +1,67 @@
package store
import (
"context"
"fmt"
"time"
)
// Finding ist das Ergebnis einer Regel für einen Beitrag. Append-only:
// siehe Migration. ExtractionID ist optional (nil wenn ein Finding nicht
// direkt aus einer Extraktion, sondern z. B. manuell erzeugt wurde).
type Finding struct {
ID string
SubmissionID string
ExtractionID *string
RuleID string
RuleVersion int
Severity string
Message string
CreatedAt time.Time
}
// CreateFinding speichert ein Finding.
func (s *Store) CreateFinding(ctx context.Context, submissionID string, extractionID *string, ruleID string, ruleVersion int, severity, message string) (Finding, error) {
var f Finding
err := s.Pool.QueryRow(ctx, `
INSERT INTO finding (submission_id, extraction_id, rule_id, rule_version, severity, message)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, submission_id, extraction_id, rule_id, rule_version, severity, message, created_at
`, submissionID, extractionID, ruleID, ruleVersion, severity, message).Scan(
&f.ID, &f.SubmissionID, &f.ExtractionID, &f.RuleID, &f.RuleVersion, &f.Severity, &f.Message, &f.CreatedAt,
)
if err != nil {
return Finding{}, fmt.Errorf("store: create finding: %w", err)
}
return f, nil
}
// ListCurrentFindings liefert die aktuell gültigen Findings eines
// Beitrags — Zeilen, die von keiner anderen Zeile per supersedes
// ersetzt wurden (siehe Migrationskommentar zu finding.supersedes).
func (s *Store) ListCurrentFindings(ctx context.Context, submissionID string) ([]Finding, error) {
rows, err := s.Pool.Query(ctx, `
SELECT f.id, f.submission_id, f.extraction_id, f.rule_id, f.rule_version, f.severity, f.message, f.created_at
FROM finding f
WHERE f.submission_id = $1
AND NOT EXISTS (SELECT 1 FROM finding f2 WHERE f2.supersedes = f.id)
ORDER BY f.created_at
`, submissionID)
if err != nil {
return nil, fmt.Errorf("store: list current findings: %w", err)
}
defer rows.Close()
var findings []Finding
for rows.Next() {
var f Finding
if err := rows.Scan(&f.ID, &f.SubmissionID, &f.ExtractionID, &f.RuleID, &f.RuleVersion, &f.Severity, &f.Message, &f.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan finding: %w", err)
}
findings = append(findings, f)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list current findings: %w", err)
}
return findings, nil
}

View File

@@ -0,0 +1,66 @@
package store
import (
"context"
"fmt"
"time"
)
// Submission ist ein eingereichter Beitrag.
type Submission struct {
ID string
Platform string
PostType string
Caption string
Status string
CreatedAt time.Time
UpdatedAt time.Time
}
// CreateSubmission legt einen neuen Beitrag an (Status "draft").
func (s *Store) CreateSubmission(ctx context.Context, platform, postType, caption string) (Submission, error) {
var sub Submission
err := s.Pool.QueryRow(ctx, `
INSERT INTO submission (platform, post_type, caption)
VALUES ($1, $2, $3)
RETURNING id, platform, post_type, caption, status, created_at, updated_at
`, platform, postType, caption).Scan(
&sub.ID, &sub.Platform, &sub.PostType, &sub.Caption, &sub.Status, &sub.CreatedAt, &sub.UpdatedAt,
)
if err != nil {
return Submission{}, fmt.Errorf("store: create submission: %w", err)
}
return sub, nil
}
// GetSubmission liest einen Beitrag anhand seiner ID.
func (s *Store) GetSubmission(ctx context.Context, id string) (Submission, error) {
var sub Submission
err := s.Pool.QueryRow(ctx, `
SELECT id, platform, post_type, caption, status, created_at, updated_at
FROM submission WHERE id = $1
`, id).Scan(
&sub.ID, &sub.Platform, &sub.PostType, &sub.Caption, &sub.Status, &sub.CreatedAt, &sub.UpdatedAt,
)
if err != nil {
return Submission{}, fmt.Errorf("store: get submission: %w", err)
}
return sub, nil
}
// SetSubmissionStatus setzt den Status eines Beitrags (submission ist,
// anders als extraction/finding/evidence_package, NICHT append-only —
// der Lebenszyklus draft → checked → published → archived ist eine
// normale Zustandsänderung, kein Beweis-Eintrag).
func (s *Store) SetSubmissionStatus(ctx context.Context, id, status string) error {
tag, err := s.Pool.Exec(ctx, `
UPDATE submission SET status = $2, updated_at = now() WHERE id = $1
`, id, status)
if err != nil {
return fmt.Errorf("store: set submission status: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("store: set submission status: submission %s nicht gefunden", id)
}
return nil
}