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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user