package store import ( "context" "errors" "fmt" "time" "github.com/jackc/pgx/v5" ) // Asset ist eine zu einem Beitrag hochgeladene Datei. Append-only wie // extraction/finding/evidence_package — ein hochgeladenes Beweisstück // wird nicht nachträglich ausgetauscht, siehe Migration. Purpose // unterscheidet das ursprüngliche Beweisfoto beim Prüfen ("initial") // von einem späteren Nachweis flüchtiger Kennzahlen ("insights") — // siehe Migration 0007 und CLAUDE.md, Abschnitt Insights-Erinnerung. type Asset struct { ID string SubmissionID string Kind string Purpose string Path string SHA256 string CreatedAt time.Time } // CreateAsset speichert ein Asset. sha256Hex ist der Hex-kodierte // SHA-256-Digest der Datei (siehe evidence.HashBytes) — dieselbe Form, // in der evidence_package.SHA256 seinen Hash speichert. func (s *Store) CreateAsset(ctx context.Context, submissionID, kind, purpose, path, sha256Hex string) (Asset, error) { var a Asset err := s.Pool.QueryRow(ctx, ` INSERT INTO asset (submission_id, kind, purpose, path, sha256) VALUES ($1, $2, $3, $4, $5) RETURNING id, submission_id, kind, purpose, path, sha256, created_at `, submissionID, kind, purpose, path, sha256Hex).Scan( &a.ID, &a.SubmissionID, &a.Kind, &a.Purpose, &a.Path, &a.SHA256, &a.CreatedAt, ) if err != nil { return Asset{}, fmt.Errorf("store: create asset: %w", err) } return a, nil } // GetLatestAssetForSubmission liefert das zuletzt hochgeladene Asset // mit purpose="initial" eines Beitrags — bewusst ohne spätere // "insights"-Assets, damit ein erneutes Archivieren immer denselben // ursprünglichen Beweis referenziert, egal wie viele Insights- // Screenshots danach noch hinzukommen. Liefert ErrNotFound, wenn keins // hochgeladen wurde — das ist der Normalfall (ein Standbild ist // optional), kein Fehler, den Aufrufer wie einen echten // Datenbankfehler behandeln sollten. func (s *Store) GetLatestAssetForSubmission(ctx context.Context, submissionID string) (Asset, error) { var a Asset err := s.Pool.QueryRow(ctx, ` SELECT id, submission_id, kind, purpose, path, sha256, created_at FROM asset WHERE submission_id = $1 AND purpose = 'initial' ORDER BY created_at DESC LIMIT 1 `, submissionID).Scan( &a.ID, &a.SubmissionID, &a.Kind, &a.Purpose, &a.Path, &a.SHA256, &a.CreatedAt, ) if errors.Is(err, pgx.ErrNoRows) { return Asset{}, ErrNotFound } if err != nil { return Asset{}, fmt.Errorf("store: get latest asset: %w", err) } return a, nil } // ListAssetsForSubmission liefert alle Assets eines Beitrags // (initiales Standbild und alle Insights-Nachweise), älteste zuerst. func (s *Store) ListAssetsForSubmission(ctx context.Context, submissionID string) ([]Asset, error) { rows, err := s.Pool.Query(ctx, ` SELECT id, submission_id, kind, purpose, path, sha256, created_at FROM asset WHERE submission_id = $1 ORDER BY created_at `, submissionID) if err != nil { return nil, fmt.Errorf("store: list assets for submission: %w", err) } defer rows.Close() var out []Asset for rows.Next() { var a Asset if err := rows.Scan(&a.ID, &a.SubmissionID, &a.Kind, &a.Purpose, &a.Path, &a.SHA256, &a.CreatedAt); err != nil { return nil, fmt.Errorf("store: scan asset: %w", err) } out = append(out, a) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("store: list assets for submission: %w", err) } return out, nil }