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

@@ -34,11 +34,11 @@ type uploadedAsset struct {
sha256Hex string
}
// readUploadedAsset liest das optionale "standbild"-Feld. Liefert
// readUploadedAsset liest das optionale Datei-Feld fieldName. Liefert
// (nil, nil), wenn kein Bild hochgeladen wurde — das ist der Normalfall,
// ein Standbild ist keine Pflichtangabe.
func (s *Server) readUploadedAsset(r *http.Request) (*uploadedAsset, error) {
file, header, err := r.FormFile("standbild")
func (s *Server) readUploadedAsset(r *http.Request, fieldName string) (*uploadedAsset, error) {
file, header, err := r.FormFile(fieldName)
// ErrNotMultipart: die Anfrage war gar kein multipart/form-data (z. B.
// ältere Clients oder Tests mit urlencoded-Formular) — dann kann auch
// kein Standbild dabei sein, das ist derselbe Fall wie ErrMissingFile.
@@ -70,16 +70,22 @@ func (s *Server) readUploadedAsset(r *http.Request) (*uploadedAsset, error) {
}
// storeAsset schreibt ein zuvor gelesenes Standbild auf die Platte und
// speichert die Asset-Zeile.
func (s *Server) storeAsset(ctx context.Context, submissionID string, ua *uploadedAsset) error {
// speichert die Asset-Zeile. purpose ist "initial" (das Beweisfoto beim
// Prüfen, ein Dateiname pro Submission reicht) oder "insights" (kann
// mehrfach vorkommen, braucht daher einen eindeutigen Dateinamen).
func (s *Server) storeAsset(ctx context.Context, submissionID, purpose string, ua *uploadedAsset) error {
if err := os.MkdirAll(s.assetDir, 0o750); err != nil {
return fmt.Errorf("Asset-Verzeichnis konnte nicht angelegt werden: %w", err)
}
path := filepath.Join(s.assetDir, submissionID+ua.extension)
filename := submissionID + ua.extension
if purpose != "initial" {
filename = fmt.Sprintf("%s-%s-%d%s", submissionID, purpose, time.Now().UnixNano(), ua.extension)
}
path := filepath.Join(s.assetDir, filename)
if err := os.WriteFile(path, ua.data, 0o640); err != nil {
return fmt.Errorf("Standbild konnte nicht gespeichert werden: %w", err)
}
if _, err := s.store.CreateAsset(ctx, submissionID, "image", path, ua.sha256Hex); err != nil {
if _, err := s.store.CreateAsset(ctx, submissionID, "image", purpose, path, ua.sha256Hex); err != nil {
return fmt.Errorf("Asset konnte nicht gespeichert werden: %w", err)
}
return nil
@@ -145,7 +151,7 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
return
}
asset, err := s.readUploadedAsset(r)
asset, err := s.readUploadedAsset(r, "standbild")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@@ -172,7 +178,7 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
}
if asset != nil {
if err := s.storeAsset(ctx, sub.ID, asset); err != nil {
if err := s.storeAsset(ctx, sub.ID, "initial", asset); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}