diff --git a/internal/store/finding.go b/internal/store/finding.go index 8379b0d..733a651 100644 --- a/internal/store/finding.go +++ b/internal/store/finding.go @@ -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) diff --git a/internal/store/participant.go b/internal/store/participant.go new file mode 100644 index 0000000..15e1549 --- /dev/null +++ b/internal/store/participant.go @@ -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 +} diff --git a/internal/store/participant_test.go b/internal/store/participant_test.go new file mode 100644 index 0000000..e77f2c0 --- /dev/null +++ b/internal/store/participant_test.go @@ -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]) + } +} diff --git a/internal/store/submission.go b/internal/store/submission.go index 3415be3..1f3864a 100644 --- a/internal/store/submission.go +++ b/internal/store/submission.go @@ -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