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>
122 lines
4.3 KiB
Go
122 lines
4.3 KiB
Go
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
|
|
}
|