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).
This commit is contained in:
@@ -9,14 +9,17 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Asset ist eine zu einem Beitrag hochgeladene Datei (aktuell: das
|
||||
// Standbild/der Screenshot). Append-only wie extraction/finding/
|
||||
// evidence_package — ein hochgeladenes Beweisstück wird nicht
|
||||
// nachträglich ausgetauscht, siehe Migration.
|
||||
// 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
|
||||
@@ -25,14 +28,14 @@ type Asset struct {
|
||||
// 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, path, sha256Hex string) (Asset, error) {
|
||||
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, path, sha256)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, submission_id, kind, path, sha256, created_at
|
||||
`, submissionID, kind, path, sha256Hex).Scan(
|
||||
&a.ID, &a.SubmissionID, &a.Kind, &a.Path, &a.SHA256, &a.CreatedAt,
|
||||
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)
|
||||
@@ -41,19 +44,23 @@ func (s *Store) CreateAsset(ctx context.Context, submissionID, kind, path, sha25
|
||||
}
|
||||
|
||||
// GetLatestAssetForSubmission liefert das zuletzt hochgeladene Asset
|
||||
// eines Beitrags. Liefert ErrNotFound, wenn kein Asset hochgeladen wurde
|
||||
// — das ist der Normalfall (ein Standbild ist optional), kein Fehler,
|
||||
// den Aufrufer wie einen echten Datenbankfehler behandeln sollten.
|
||||
// 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, path, sha256, created_at
|
||||
SELECT id, submission_id, kind, purpose, path, sha256, created_at
|
||||
FROM asset
|
||||
WHERE submission_id = $1
|
||||
WHERE submission_id = $1 AND purpose = 'initial'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`, submissionID).Scan(
|
||||
&a.ID, &a.SubmissionID, &a.Kind, &a.Path, &a.SHA256, &a.CreatedAt,
|
||||
&a.ID, &a.SubmissionID, &a.Kind, &a.Purpose, &a.Path, &a.SHA256, &a.CreatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Asset{}, ErrNotFound
|
||||
@@ -63,3 +70,29 @@ func (s *Store) GetLatestAssetForSubmission(ctx context.Context, submissionID st
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user