diff --git a/internal/store/crud_test.go b/internal/store/crud_test.go index b4afe6a..bacffa9 100644 --- a/internal/store/crud_test.go +++ b/internal/store/crud_test.go @@ -128,7 +128,7 @@ func TestFindingCRUDAndSupersedes(t *testing.T) { t.Fatalf("CreateSubmission: %v", err) } - old, err := s.CreateFinding(ctx, sub.ID, nil, "WK-004", 1, "hoch", "alte Fassung") + old, err := s.CreateFinding(ctx, sub.ID, nil, "WK-004", 1, "hoch", "alte Fassung", "alte Korrektur", []string{"§ 5a Abs. 4 UWG"}) if err != nil { t.Fatalf("CreateFinding (old): %v", err) } @@ -140,11 +140,14 @@ func TestFindingCRUDAndSupersedes(t *testing.T) { if len(current) != 1 || current[0].ID != old.ID { t.Fatalf("expected exactly the old finding before any correction, got %+v", current) } + if len(current[0].Sources) != 1 || current[0].Sources[0] != "§ 5a Abs. 4 UWG" { + t.Fatalf("Sources = %v, want [§ 5a Abs. 4 UWG]", current[0].Sources) + } // Korrektur: neue Zeile, die die alte per supersedes ersetzt. _, err = s.Pool.Exec(ctx, ` - INSERT INTO finding (submission_id, rule_id, rule_version, severity, message, supersedes) - VALUES ($1, 'WK-004', 2, 'hoch', 'korrigierte Fassung', $2) + INSERT INTO finding (submission_id, rule_id, rule_version, severity, title, fix, sources, supersedes) + VALUES ($1, 'WK-004', 2, 'hoch', 'korrigierte Fassung', 'neue Korrektur', '{}', $2) `, sub.ID, old.ID) if err != nil { t.Fatalf("insert superseding finding: %v", err) @@ -157,7 +160,7 @@ func TestFindingCRUDAndSupersedes(t *testing.T) { 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" { + if current[0].Title != "korrigierte Fassung" { t.Fatalf("expected the corrected finding to be current, got %+v", current[0]) } } diff --git a/internal/store/finding.go b/internal/store/finding.go index 2e0cbc7..8379b0d 100644 --- a/internal/store/finding.go +++ b/internal/store/finding.go @@ -9,6 +9,11 @@ import ( // Finding ist das Ergebnis einer Regel für einen Beitrag. Append-only: // siehe Migration. ExtractionID ist optional (nil wenn ein Finding nicht // direkt aus einer Extraktion, sondern z. B. manuell erzeugt wurde). +// Title/Fix/Sources sind zum Zeitpunkt der Regelauswertung fixiert +// gespeichert (nicht nur rule_id/rule_version referenziert), weil ein +// späteres Update der Regel-YAML eine ältere Version sonst nicht mehr +// nachträglich auflösen könnte — der Wortlaut zum Zeitpunkt des +// Findings ist der Beweis, kein Verweis darauf. type Finding struct { ID string SubmissionID string @@ -16,19 +21,21 @@ type Finding struct { RuleID string RuleVersion int Severity string - Message string + Title string + Fix string + Sources []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) { +func (s *Store) CreateFinding(ctx context.Context, submissionID string, extractionID *string, ruleID string, ruleVersion int, severity, title, fix string, sources []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, + INSERT INTO finding (submission_id, extraction_id, rule_id, rule_version, severity, title, fix, sources) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, submission_id, extraction_id, rule_id, rule_version, severity, title, fix, sources, created_at + `, submissionID, extractionID, ruleID, ruleVersion, severity, title, fix, sources).Scan( + &f.ID, &f.SubmissionID, &f.ExtractionID, &f.RuleID, &f.RuleVersion, &f.Severity, &f.Title, &f.Fix, &f.Sources, &f.CreatedAt, ) if err != nil { return Finding{}, fmt.Errorf("store: create finding: %w", err) @@ -41,7 +48,7 @@ func (s *Store) CreateFinding(ctx context.Context, submissionID string, extracti // 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 + SELECT f.id, f.submission_id, f.extraction_id, f.rule_id, f.rule_version, f.severity, f.title, f.fix, f.sources, f.created_at FROM finding f WHERE f.submission_id = $1 AND NOT EXISTS (SELECT 1 FROM finding f2 WHERE f2.supersedes = f.id) @@ -55,7 +62,7 @@ func (s *Store) ListCurrentFindings(ctx context.Context, submissionID string) ([ 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 { + if err := rows.Scan(&f.ID, &f.SubmissionID, &f.ExtractionID, &f.RuleID, &f.RuleVersion, &f.Severity, &f.Title, &f.Fix, &f.Sources, &f.CreatedAt); err != nil { return nil, fmt.Errorf("store: scan finding: %w", err) } findings = append(findings, f) diff --git a/internal/store/migrations/0002_finding_structured_content.down.sql b/internal/store/migrations/0002_finding_structured_content.down.sql new file mode 100644 index 0000000..fcb6ac3 --- /dev/null +++ b/internal/store/migrations/0002_finding_structured_content.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE finding DROP COLUMN title; +ALTER TABLE finding DROP COLUMN fix; +ALTER TABLE finding DROP COLUMN sources; +ALTER TABLE finding ADD COLUMN message TEXT NOT NULL DEFAULT ''; +ALTER TABLE finding ALTER COLUMN message DROP DEFAULT; diff --git a/internal/store/migrations/0002_finding_structured_content.up.sql b/internal/store/migrations/0002_finding_structured_content.up.sql new file mode 100644 index 0000000..aa6fbca --- /dev/null +++ b/internal/store/migrations/0002_finding_structured_content.up.sql @@ -0,0 +1,11 @@ +-- finding.message war ein einzelnes Freitextfeld. Fürs Dossier brauchen +-- wir Titel, Korrekturvorschlag und Fundstellen aber strukturiert und +-- getrennt (siehe internal/dossier), sonst müsste die Anzeige Text +-- wieder auseinanderparsen, den wir selbst zusammengefügt haben. +ALTER TABLE finding DROP COLUMN message; +ALTER TABLE finding ADD COLUMN title TEXT NOT NULL DEFAULT ''; +ALTER TABLE finding ADD COLUMN fix TEXT NOT NULL DEFAULT ''; +ALTER TABLE finding ADD COLUMN sources TEXT[] NOT NULL DEFAULT '{}'; +ALTER TABLE finding ALTER COLUMN title DROP DEFAULT; +ALTER TABLE finding ALTER COLUMN fix DROP DEFAULT; +ALTER TABLE finding ALTER COLUMN sources DROP DEFAULT; diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 10cdf0f..7d1b3cd 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -71,15 +71,15 @@ func TestFindingIsAppendOnly(t *testing.T) { var findingID string err = s.Pool.QueryRow(ctx, ` - INSERT INTO finding (submission_id, rule_id, rule_version, severity, message) - VALUES ($1, 'WK-004', 3, 'hoch', 'Testfeststellung') + INSERT INTO finding (submission_id, rule_id, rule_version, severity, title, fix, sources) + VALUES ($1, 'WK-004', 3, 'hoch', 'Testfeststellung', 'Testkorrektur', '{}') RETURNING id `, submissionID).Scan(&findingID) if err != nil { t.Fatalf("insert finding: %v", err) } - _, err = s.Pool.Exec(ctx, `UPDATE finding SET message = 'geändert' WHERE id = $1`, findingID) + _, err = s.Pool.Exec(ctx, `UPDATE finding SET title = 'geändert' WHERE id = $1`, findingID) if err == nil { t.Fatal("expected UPDATE on finding to be rejected, but it succeeded") }