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
|
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) {
|
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
|
var f Finding
|
||||||
err := s.Pool.QueryRow(ctx, `
|
err := s.Pool.QueryRow(ctx, `
|
||||||
INSERT INTO finding (submission_id, extraction_id, rule_id, rule_version, severity, title, fix, sources)
|
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
|
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,
|
// SetSubmissionStatus setzt den Status eines Beitrags (submission ist,
|
||||||
// anders als extraction/finding/evidence_package, NICHT append-only —
|
// anders als extraction/finding/evidence_package, NICHT append-only —
|
||||||
// der Lebenszyklus draft → checked → published → archived ist eine
|
// der Lebenszyklus draft → checked → published → archived ist eine
|
||||||
|
|||||||
Reference in New Issue
Block a user