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:
noroot
2026-08-28 10:10:44 +02:00
parent 5813e6209c
commit 6fd7831784
13 changed files with 512 additions and 56 deletions

View File

@@ -69,7 +69,7 @@ type fakeStore struct {
evidencePkgs map[string]store.EvidencePackage
participants map[string]store.Participant
auditLog []store.AuditEntry
assets map[string]store.Asset // submissionID -> zuletzt hochgeladenes Asset
assets map[string][]store.Asset // submissionID -> alle Assets, aeltestes zuerst
// platformConnections ist verschachtelt nach accountID -> platform,
// wie die UNIQUE(account_id, platform)-Beschränkung der echten Tabelle.
@@ -87,7 +87,7 @@ func newFakeStore() *fakeStore {
findings: map[string][]store.Finding{},
evidencePkgs: map[string]store.EvidencePackage{},
participants: map[string]store.Participant{},
assets: map[string]store.Asset{},
assets: map[string][]store.Asset{},
platformConnections: map[string]map[string]store.PlatformConnection{},
}
}
@@ -350,24 +350,40 @@ func (f *fakeStore) GetLatestEvidencePackage(ctx context.Context, submissionID s
return pkg, nil
}
func (f *fakeStore) CreateAsset(ctx context.Context, submissionID, kind, path, sha256Hex string) (store.Asset, error) {
func (f *fakeStore) CreateAsset(ctx context.Context, submissionID, kind, purpose, path, sha256Hex string) (store.Asset, error) {
f.mu.Lock()
defer f.mu.Unlock()
a := store.Asset{
ID: f.newID(), SubmissionID: submissionID, Kind: kind, Path: path, SHA256: sha256Hex, CreatedAt: time.Now(),
ID: f.newID(), SubmissionID: submissionID, Kind: kind, Purpose: purpose,
Path: path, SHA256: sha256Hex, CreatedAt: time.Now(),
}
f.assets[submissionID] = a
f.assets[submissionID] = append(f.assets[submissionID], a)
return a, nil
}
// GetLatestAssetForSubmission liefert wie die echte Store-Implementierung
// nur das zuletzt hochgeladene Asset mit purpose="initial".
func (f *fakeStore) GetLatestAssetForSubmission(ctx context.Context, submissionID string) (store.Asset, error) {
f.mu.Lock()
defer f.mu.Unlock()
a, ok := f.assets[submissionID]
if !ok {
var latest store.Asset
found := false
for _, a := range f.assets[submissionID] {
if a.Purpose == "initial" {
latest = a
found = true
}
}
if !found {
return store.Asset{}, store.ErrNotFound
}
return a, nil
return latest, nil
}
func (f *fakeStore) ListAssetsForSubmission(ctx context.Context, submissionID string) ([]store.Asset, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.assets[submissionID], nil
}
func (f *fakeStore) UpsertPlatformConnection(ctx context.Context, accountID, platform, platformUserID, accessToken, refreshToken string, expiresAt *time.Time) (store.PlatformConnection, error) {