feat: add participant CRUD and per-account submission listing

Participant (the Verantwortungsmatrix — who briefed, who approved) is
not append-only like finding/extraction/evidence_package; getting a
role wrong and correcting it isn't rewriting evidence, so full CRUD is
legitimate here: Create/List/Get/Update/Delete. UpdateParticipant sets
approved_at the first time freigegeben flips to true and never moves it
again on subsequent no-op updates — it marks when approval first
happened, not "last touched".

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
noroot
2026-08-27 17:46:31 +02:00
parent 77de1627c5
commit 6df1d2961b
4 changed files with 317 additions and 1 deletions

View File

@@ -27,8 +27,14 @@ type Finding struct {
CreatedAt time.Time
}
// CreateFinding speichert ein Finding.
// CreateFinding speichert ein Finding. sources ist NOT NULL in der DB
// (TEXT[]) — ein nil-Slice (z. B. eine Regel ohne fundstelle-Eintrag)
// würde als SQL-NULL ankommen und mit einer wenig hilfreichen Constraint-
// Fehlermeldung abgelehnt; hier stattdessen auf eine leere Liste normiert.
func (s *Store) CreateFinding(ctx context.Context, submissionID string, extractionID *string, ruleID string, ruleVersion int, severity, title, fix string, sources []string) (Finding, error) {
if sources == nil {
sources = []string{}
}
var f Finding
err := s.Pool.QueryRow(ctx, `
INSERT INTO finding (submission_id, extraction_id, rule_id, rule_version, severity, title, fix, sources)

View File

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

View File

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

View File

@@ -61,6 +61,62 @@ func (s *Store) GetSubmission(ctx context.Context, id string) (Submission, error
return sub, nil
}
// SubmissionSummary ist eine Submission plus einer Kurzfassung ihrer
// aktuell gültigen Findings, wie sie eine Übersichtsliste braucht (ohne
// für jede Zeile extra ListCurrentFindings aufzurufen).
type SubmissionSummary struct {
Submission
FindingCount int
HighestSeverity string // "" wenn keine Findings
}
// ListSubmissionsForAccount liefert alle Beiträge eines Mandanten,
// neueste zuerst, mit Findings-Kurzfassung.
func (s *Store) ListSubmissionsForAccount(ctx context.Context, accountID string) ([]SubmissionSummary, error) {
rows, err := s.Pool.Query(ctx, `
SELECT
s.id, s.account_id, s.platform, s.post_type, s.caption, s.status, s.created_at, s.updated_at,
COUNT(f.id) AS finding_count,
COALESCE(MAX(CASE f.severity WHEN 'hoch' THEN 3 WHEN 'mittel' THEN 2 WHEN 'niedrig' THEN 1 ELSE 0 END), 0) AS severity_rank
FROM submission s
LEFT JOIN finding f
ON f.submission_id = s.id
AND NOT EXISTS (SELECT 1 FROM finding f2 WHERE f2.supersedes = f.id)
WHERE s.account_id = $1
GROUP BY s.id
ORDER BY s.created_at DESC
`, accountID)
if err != nil {
return nil, fmt.Errorf("store: list submissions for account: %w", err)
}
defer rows.Close()
var out []SubmissionSummary
for rows.Next() {
var sub SubmissionSummary
var severityRank int
if err := rows.Scan(
&sub.ID, &sub.AccountID, &sub.Platform, &sub.PostType, &sub.Caption, &sub.Status, &sub.CreatedAt, &sub.UpdatedAt,
&sub.FindingCount, &severityRank,
); err != nil {
return nil, fmt.Errorf("store: scan submission summary: %w", err)
}
switch severityRank {
case 3:
sub.HighestSeverity = "hoch"
case 2:
sub.HighestSeverity = "mittel"
case 1:
sub.HighestSeverity = "niedrig"
}
out = append(out, sub)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list submissions for account: %w", err)
}
return out, nil
}
// SetSubmissionStatus setzt den Status eines Beitrags (submission ist,
// anders als extraction/finding/evidence_package, NICHT append-only —
// der Lebenszyklus draft → checked → published → archived ist eine