Files
deklarix/internal/store/asset.go
noroot 6fd7831784 feat: Erinnerung zum Sichern von Story-Insights vor Ablauf
Ausgangspunkt: Instagram hält Story-Insights nach eigener Aussage nur
24 Stunden vor, auch der offizielle Datenexport enthält sie nicht mehr
danach. Der Standbild-Screenshot beim Prüfen entsteht direkt beim
Veröffentlichen, bevor nennenswerte Kennzahlen existieren — er kann
das strukturell nicht auffangen. Eine OAuth-Anbindung allein löst das
auch nicht: selbst mit API-Zugriff bräuchte es einen Abruf innerhalb
desselben 24h-Fensters.

- Migration 0007: asset.purpose ('initial' | 'insights', Default
  'initial' erhält die Bedeutung aller Bestandszeilen). Ein Beitrag
  kann jetzt mehrere Insights-Nachweise über die Zeit bekommen.
  GetLatestAssetForSubmission berücksichtigt weiterhin nur 'initial',
  damit ein späterer Insights-Upload nie den beim Archivieren
  referenzierten Original-Screenshot verdrängt.
- internal/web/insights_reminder.go: computeInsightsReminder — reine,
  ungetestete gegen echte Instagram-Daten, aber isoliert testbare
  Logik fürs Erinnerungs-Timing (Produktentscheidung, keine Rechtsnorm,
  daher nicht in rules/*.yaml).
- GET /beitraege/{id} zeigt die Erinnerung bei veröffentlichten
  "story"-Beiträgen ohne existierendes insights-Asset; POST
  /beitraege/{id}/insights speichert einen weiteren Screenshot (gleiche
  Validierung wie das initiale Standbild, wiederverwendet über
  readUploadedAsset/storeAsset mit purpose-Parameter).
- Bewusst nur In-App-Banner in dieser Ausbaustufe, kein Mail-/Push-
  Versand — dafür fehlt aktuell ein SMTP-Relay/Versanddienst, siehe
  CLAUDE.md-Hinweis dazu.

Volle Testsuite inkl. echter Postgres-Tests grün; End-to-End gegen
einen laufenden Server verifiziert (Story archivieren → Erinnerung
sichtbar → Insights-Upload → Erinnerung verschwindet, Nachweis
gelistet, Mandantentrennung beim Upload durchgesetzt).
2026-08-28 10:10:44 +02:00

99 lines
3.4 KiB
Go

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
}