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>
136 lines
4.5 KiB
Go
136 lines
4.5 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// Submission ist ein eingereichter Beitrag. AccountID ist der Mandant,
|
|
// dem der Beitrag gehört (Mandantentrennung) — jede Abfrage, die einen
|
|
// Beitrag ausliefert, muss AccountID gegen den angemeldeten Account
|
|
// prüfen (siehe internal/web-Middleware), store selbst erzwingt das
|
|
// nicht auf Zeilenebene.
|
|
type Submission struct {
|
|
ID string
|
|
AccountID string
|
|
Platform string
|
|
PostType string
|
|
Caption string
|
|
Status string
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// CreateSubmission legt einen neuen Beitrag für einen Mandanten an
|
|
// (Status "draft").
|
|
func (s *Store) CreateSubmission(ctx context.Context, accountID, platform, postType, caption string) (Submission, error) {
|
|
var sub Submission
|
|
err := s.Pool.QueryRow(ctx, `
|
|
INSERT INTO submission (account_id, platform, post_type, caption)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id, account_id, platform, post_type, caption, status, created_at, updated_at
|
|
`, accountID, platform, postType, caption).Scan(
|
|
&sub.ID, &sub.AccountID, &sub.Platform, &sub.PostType, &sub.Caption, &sub.Status, &sub.CreatedAt, &sub.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return Submission{}, fmt.Errorf("store: create submission: %w", err)
|
|
}
|
|
return sub, nil
|
|
}
|
|
|
|
// GetSubmission liest einen Beitrag anhand seiner ID — ohne
|
|
// Mandanten-Prüfung, das ist Sache des Aufrufers (siehe Submission.AccountID).
|
|
func (s *Store) GetSubmission(ctx context.Context, id string) (Submission, error) {
|
|
var sub Submission
|
|
err := s.Pool.QueryRow(ctx, `
|
|
SELECT id, account_id, platform, post_type, caption, status, created_at, updated_at
|
|
FROM submission WHERE id = $1
|
|
`, id).Scan(
|
|
&sub.ID, &sub.AccountID, &sub.Platform, &sub.PostType, &sub.Caption, &sub.Status, &sub.CreatedAt, &sub.UpdatedAt,
|
|
)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return Submission{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return Submission{}, fmt.Errorf("store: get submission: %w", err)
|
|
}
|
|
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
|
|
// normale Zustandsänderung, kein Beweis-Eintrag).
|
|
func (s *Store) SetSubmissionStatus(ctx context.Context, id, status string) error {
|
|
tag, err := s.Pool.Exec(ctx, `
|
|
UPDATE submission SET status = $2, updated_at = now() WHERE id = $1
|
|
`, id, status)
|
|
if err != nil {
|
|
return fmt.Errorf("store: set submission status: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return fmt.Errorf("store: set submission status: submission %s nicht gefunden", id)
|
|
}
|
|
return nil
|
|
}
|