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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user